Compare commits
40 Commits
aa4552ff84
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 45c3f87f01 | |||
| 748ce4733f | |||
| 5a6d1c2541 | |||
| bf074696b4 | |||
| fdad1633cd | |||
| 54e873c115 | |||
| 7a2feac04d | |||
| 0b99cfd2bd | |||
| 1cb9e52117 | |||
| 2e7911074f | |||
| 0a4bcabd98 | |||
| 1484d04917 | |||
| 854da1a000 | |||
| 60214a7c92 | |||
| 45673bb066 | |||
| 45b8533f18 | |||
| 84365bba7f | |||
| a333bce1b3 | |||
| bbd305861f | |||
| 8aaddea4ef | |||
| 54cc493230 | |||
| bb1b8f8c76 | |||
| 00a3919c9f | |||
| b0e92c363a | |||
| 7262b34ae9 | |||
| ebc70cc0b8 | |||
| f9e672c707 | |||
| fe79be2eee | |||
| 41ee4ee3ab | |||
| f7a1a7724b | |||
| 750619d3d7 | |||
| 48b138bbfd | |||
| c0a7b1a5ee | |||
| a0fa33da42 | |||
| 6b264a9b71 | |||
| 26977d73ff | |||
| 12f5de00a0 | |||
| 0540498e85 | |||
| a87d1ec865 | |||
| 8c1ee75b81 |
@@ -35,6 +35,39 @@ wartet auf die Züchterin, `Resolved` ist fertig — beide überspringen.
|
||||
- Feedback überlebt den Import-Ingest (FK-freie Spalten) — Tickets bleiben
|
||||
erhalten, auch wenn die DB neu befüllt wird.
|
||||
|
||||
## Ziel-System: lokal ODER Prod (WICHTIG — zuerst klären)
|
||||
|
||||
Die Tickets liegen dort, wo die Züchterin sie meldet — im Regelfall **Prod**
|
||||
(TrueNAS Custom-App). Kläre zu Beginn, gegen welches System der Lauf geht, und
|
||||
setze die **API-Base-URL** entsprechend für ALLE `feedback`/`gerbils`/`litters`-Aufrufe:
|
||||
|
||||
| System | API-Base | Bookmark der Züchterin |
|
||||
|---|---|---|
|
||||
| **Prod** (Regelfall) | `http://truenas:8090/api` | http://truenas:8090/ |
|
||||
| **Lokal** (Aspire) | `http://localhost:5179` | — (App via `dotnet run --project GerbilManager.AppHost`) |
|
||||
|
||||
- **Ticket-CRUD läuft immer gegen das Ziel-System** (Prod: `…/api/feedback`).
|
||||
Einzelticket: `GET /feedback/{id}`; nur die offenen: `GET /feedback?status=Open,Answered`
|
||||
(spart den 2-MB-Volldump). Encoding: über Datei holen + Python-UTF-8 lesen.
|
||||
- **Untersuchung bleibt lokal**: die Quellen (Stammbaum-xlsx, `_rpro3.db`, Wurfchronik,
|
||||
`output/*.json`) liegen lokal. Datenfixes gehören re-ingest-stabil in
|
||||
`conflict-decisions.json` / `rpro3-decisions.json` — NICHT die Live-DB patchen.
|
||||
- **Prod-Re-Ingest ohne SSH** (bevorzugt): lokal `resolved_import.json` generieren
|
||||
(extract → extract_contracts → merge_and_resolve), dann **per HTTP hochladen + ingesten**:
|
||||
```bash
|
||||
curl -X POST http://truenas:8090/api/import/ingest-resolved/upload \
|
||||
-F "resolved=@tools/import/output/resolved_import.json" \
|
||||
[-F "photos=@neue_fotos.zip"] # photos optional, nur bei neuen Fotos
|
||||
```
|
||||
Der Endpoint staget die Datei serverseitig und ruft denselben UPSERT-Ingest auf
|
||||
(manuell/verified/feedback überleben). **DB-Copy vom lokalen Stand ist VERBOTEN**,
|
||||
sobald Prod eigenständig kuratiert wurde — es überschreibt die Prod-Kuratierung;
|
||||
nur der Upsert-Ingest bewahrt sie. (Fallback ohne Endpoint: `resolved_import.json`
|
||||
per `scp` + `docker cp` nach `ix-gerbilmanager-api-1:/tools/import/output/` und
|
||||
`POST …/api/import/ingest-resolved`.)
|
||||
- Vor jeder **Prod-Mutation** (Ingest, Ticket-Schließen im großen Stil) kurz beim
|
||||
Nutzer rückversichern.
|
||||
|
||||
## Ablauf
|
||||
|
||||
### 1. Tickets holen & einteilen
|
||||
|
||||
@@ -23,6 +23,10 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
# Manueller „Run workflow"-Button in Gitea: testet, baut, pusht und deployt frisch.
|
||||
# Fuer einen Rollback auf ein altes Image stattdessen auf der NAS:
|
||||
# TAG=<git-sha> sh /opt/gerbilmanager/deploy/truenas/scripts/deploy.sh
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.rismer.de
|
||||
@@ -100,7 +104,7 @@ jobs:
|
||||
name: Docker Build & Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend, test-frontend]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -133,3 +137,52 @@ jobs:
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:latest
|
||||
${{ env.REGISTRY }}/${{ env.REGISTRY_OWNER }}/gerbilmanager-frontend:${{ github.sha }}
|
||||
|
||||
deploy:
|
||||
name: Deploy auf TrueNAS (Custom App)
|
||||
# Laeuft auf dem vorhandenen containerisierten Gitea-Runner (Label ubuntu-latest).
|
||||
# Der Runner hat KEINEN Host-Dateisystem-/midclt-Zugriff, daher: per SSH zum NAS-Host
|
||||
# verbinden, die Deploy-Dateien in das App-Home auf dem Pool kopieren und dort
|
||||
# truenas-deploy.sh ausfuehren (legt die TrueNAS "Custom App" an bzw. rollt sie neu
|
||||
# aus via `midclt` — die App bleibt unter Apps sichtbar).
|
||||
# Hinweis: / (inkl. /opt) ist auf TrueNAS Goldeye read-only -> App-Home auf dem Pool.
|
||||
#
|
||||
# Benoetigte Repo-Secrets (Gitea -> Einstellungen -> Actions -> Secrets):
|
||||
# NAS_SSH_HOST z. B. 192.168.2.115 (Host-LAN-IP der NAS)
|
||||
# NAS_SSH_KEY privater SSH-Deploy-Key (Pubkey liegt in /root/.ssh/authorized_keys)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-and-push]
|
||||
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
env:
|
||||
APP_HOME: /mnt/JailStorage/DockerVolumes/gerbilmanager
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: SSH-Client + Key einrichten
|
||||
run: |
|
||||
# Nur openssh-client wird gebraucht (Dateitransfer laeuft unten per "tar | ssh").
|
||||
# FRUEHER stand hier zusaetzlich rsync im apt-Aufruf, der aber nie lief: der Guard
|
||||
# prueft nur `ssh`, und ssh ist im Runner-Image vorhanden => "rsync: command not
|
||||
# found" kippte den Deploy-Job (Run 107). Deshalb kein rsync mehr.
|
||||
command -v ssh >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends openssh-client)
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "${{ secrets.NAS_SSH_KEY }}" > ~/.ssh/id_deploy
|
||||
chmod 600 ~/.ssh/id_deploy
|
||||
ssh-keyscan -H "${{ secrets.NAS_SSH_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Deploy-Dateien auf den NAS-Host kopieren
|
||||
run: |
|
||||
NAS="root@${{ secrets.NAS_SSH_HOST }}"
|
||||
SSH="ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=accept-new"
|
||||
$SSH "$NAS" "mkdir -p $APP_HOME/deploy/truenas/scripts"
|
||||
# tar-over-ssh statt rsync: rsync fehlt im Runner-Image, tar gibt es auf beiden
|
||||
# Seiten. -o beim Entpacken => Dateien gehoeren root; Modi bleiben erhalten,
|
||||
# die Deploy-Skripte bleiben also ausfuehrbar.
|
||||
tar -C deploy/truenas -cf - custom-app.compose.yaml scripts \
|
||||
| $SSH "$NAS" "tar -C $APP_HOME/deploy/truenas -xof -"
|
||||
|
||||
- name: Custom App neu ausrollen (midclt create/redeploy + Health-Check)
|
||||
run: |
|
||||
NAS="root@${{ secrets.NAS_SSH_HOST }}"
|
||||
ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=accept-new "$NAS" \
|
||||
"sh $APP_HOME/deploy/truenas/scripts/truenas-deploy.sh"
|
||||
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -140,9 +140,24 @@ 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/
|
||||
|
||||
# Runtime: gespeicherte Vertrags-Dateien
|
||||
GerbilManagerWebAPI/contract-storage/
|
||||
|
||||
# Agent-Worktrees (lokal, nicht versionieren)
|
||||
.claude/worktrees/
|
||||
|
||||
# Lokaler Publish-Output der öffentlichen Seite (Aspire dev)
|
||||
deploy/.local-publicsite/
|
||||
|
||||
# RennmausPro III SQLite-Quelle (lokal, nicht committen)
|
||||
tools/import/_rpro3.db
|
||||
tools/import/_rpro3_extract/
|
||||
tools/import/_rpro3_backup.zip
|
||||
|
||||
# Lokale Scratch-/Werkzeug-Verzeichnisse (nicht versionieren)
|
||||
.agents/
|
||||
tools/rag/
|
||||
|
||||
# Produktions-Secrets (nur auf dem NAS, nie im Repo)
|
||||
deploy/truenas/.env
|
||||
|
||||
247
CLAUDE.md
247
CLAUDE.md
@@ -32,11 +32,81 @@ dotnet run --project GerbilManager.AppHost
|
||||
- Postgres läuft als Container (Name wechselt pro Run, z. B. `postgres-xxxx`); die
|
||||
**Daten liegen in einem persistenten Volume** → überleben Neustarts. DB-Name: `gerbilmanager`.
|
||||
|
||||
## Produktion / Deployment (TrueNAS)
|
||||
|
||||
Prod läuft als **TrueNAS SCALE Custom-App** `gerbilmanager` auf dem Host **`truenas`**
|
||||
(LAN-IP `192.168.2.115`, TrueNAS 25.10 „Goldeye", Docker). Erstinstallation 2026-07-19.
|
||||
|
||||
- **Zugriff (Bookmark der Züchterin):** **http://truenas:8090/** · API-Doku: `/scalar`.
|
||||
(Port 80 ist durch den nginx-Reverse-Proxy der NAS belegt → App auf **8090**, LAN-only, keine Auth.)
|
||||
- **Sichtbar unter Apps**, weil via `midclt call app.create {custom_app:true, app_name:"gerbilmanager",
|
||||
custom_compose_config_string:<yaml>}` angelegt — **nicht** via `docker compose` (das taucht in Apps
|
||||
NICHT auf). Container: `ix-gerbilmanager-{db,api,frontend,backup}-1`.
|
||||
- **5 Dienste:** `db` (postgres:18) · `api` (.NET, EF-Migrationen laufen beim Start) · `frontend`
|
||||
(nginx: SPA + `/api`-Proxy → `api:8080`, Host-Port 8090) · `publicsite` (nginx, **nur intern**,
|
||||
Host-Port **8081** — serviert statisches HTML aus dem `publicsite`-Volume, das die API via
|
||||
`POST /api/publish` schreibt; ohne Publish leer → 404) · `backup` (täglich 03:00 pg_dump +
|
||||
Foto-Archiv, Shell-Scheduler).
|
||||
- **App-Home auf dem Pool:** `/mnt/JailStorage/DockerVolumes/gerbilmanager/` mit
|
||||
`pgdata/ photos/ keys/ backups/ scripts/ publicsite/` und `deploy/truenas/{custom-app.compose.yaml,.env,scripts}`.
|
||||
Images aus der Gitea-Registry `git.rismer.de/gulum/gerbilmanager-{api,frontend}:latest`.
|
||||
|
||||
### Deployen
|
||||
|
||||
**Automatisch (Regelfall):** Push auf `main` → Gitea Actions (`.gitea/workflows/ci.yml`): Tests →
|
||||
Images bauen+pushen → Job `deploy` verbindet sich per SSH auf den NAS-Host und ruft
|
||||
`deploy/truenas/scripts/truenas-deploy.sh` (→ `midclt app.redeploy`, zieht `:latest` neu, `pgdata`/
|
||||
`photos` bleiben erhalten). Braucht die Gitea-Repo-Secrets **`NAS_SSH_HOST`** (=192.168.2.115) und
|
||||
**`NAS_SSH_KEY`** (privater Deploy-Key; Pubkey liegt in `/root/.ssh/authorized_keys`).
|
||||
|
||||
**Manuell (auf dem NAS-Host):**
|
||||
```bash
|
||||
sh /mnt/JailStorage/DockerVolumes/gerbilmanager/deploy/truenas/scripts/truenas-deploy.sh
|
||||
```
|
||||
Legt die App an, wenn sie fehlt (create), sonst redeploy. Rendert `custom-app.compose.yaml` mit den
|
||||
Werten aus `deploy/truenas/.env` (enthält `POSTGRES_PASSWORD` — **nur auf der NAS, nie im Repo**).
|
||||
|
||||
**Lokale DB → Prod kopieren** (einmalig gemacht): `pg_dump` der Aspire-DB → `psql`-Restore in den
|
||||
`ix-gerbilmanager-db-1`-Container (dabei `api` kurz stoppen); Fotos aus `GerbilManagerWebAPI/photo-storage/`
|
||||
per `tar | ssh` nach `…/gerbilmanager/photos/`.
|
||||
|
||||
### Deployment-Stolperfallen
|
||||
- **`/opt` ist auf Goldeye read-only** → App-Home MUSS auf den Pool (`/mnt/JailStorage/...`).
|
||||
- **`postgres:18`** legt PGDATA in `/var/lib/postgresql/18/docker` ab und deklariert das Volume als
|
||||
`/var/lib/postgresql`. Mount auf `/var/lib/postgresql/data` (alte v≤17-Konvention) lässt v18 **nicht
|
||||
starten** ("data in unused mount/volume") → db unhealthy → App-Rollback. Immer `/var/lib/postgresql`
|
||||
mounten. (Debian-Image, nicht alpine → Locale `libc/en_US.utf8` = Quell-DB.)
|
||||
- Gitea-Runner ist **containerisiert** (Label `ubuntu-latest`, kein `goldeye`, kein Host-/`midclt`-Zugriff)
|
||||
und läuft **`maxParallel=1`** (Jobs sequenziell) — daher der SSH-Weg im Deploy-Job.
|
||||
- **Auto-Deploy lief bis 2026-08-19 nie durch** (Runs 99–107 rot), zwei Ursachen: (1) die Repo-Secrets
|
||||
`NAS_SSH_HOST`/`NAS_SSH_KEY` waren nie angelegt → `ssh root@` mit leerem Key („Could not resolve
|
||||
hostname"); jetzt gesetzt, Deploy-Key `gitea-ci-deploy@gerbilmanager` (privat nur im Gitea-Secret,
|
||||
Pubkey in `/root/.ssh/authorized_keys`). (2) Der Runner hat **kein `rsync`**, und der apt-Guard prüfte
|
||||
nur `ssh` → „rsync: command not found". Dateitransfer läuft daher per **`tar | ssh`** (tar gibt es auf
|
||||
beiden Seiten; `-xof` erhält die Modi, Skripte bleiben ausführbar).
|
||||
- CI-Run-Status notfalls aus der Gitea-DB: `docker exec -e PGPASSWORD=gitea ix-gitea-postgresdb-1
|
||||
psql -U gitea -d gitea -c "select name,status from action_run_job where run_id=(select max(id) from action_run);"`
|
||||
(`status`: 1=success, 2=failure, 4=skipped).
|
||||
|
||||
## Import → DB (3-stufig, WICHTIG)
|
||||
|
||||
Die DB wird **nicht** direkt von Python beschrieben. Nach jeder Import-Code-Änderung
|
||||
neu generieren UND neu einspielen, sonst zeigt die App den alten Stand.
|
||||
|
||||
> **Arbeitsweise (verbindlich, Owner-Regel — aktualisiert 2026-07-12):** Für **variable**
|
||||
> (von der Züchterin **nicht** angefasste) Import-Tiere gilt weiter: Datenfehler IMMER **im
|
||||
> Importierer** beheben (Pipeline-Code + `conflict-decisions.json` / `rpro3-decisions.json`)
|
||||
> und **neu importieren** — für diese Tiere **nicht** die Live-DB patchen. Jeder Importer-Fix
|
||||
> muss **re-ingest-stabil** sein (data-driven, deterministische GUIDs).
|
||||
>
|
||||
> **Neu:** Das Ziel ist **nicht** mehr „Importer perfekt, dann einmal final", sondern
|
||||
> **Tier für Tier konvergieren**. Die Züchterin **darf** über die UI kuratieren, und das
|
||||
> **überlebt den Ingest** (der wischt **nicht** mehr alles): (a) **manuell angelegte Tiere/
|
||||
> Würfe/Kontakte** (`IsManual=true`) und (b) **Overrides** (`GerbilOverride`) für als
|
||||
> **„vollständig korrekt"** markierte bzw. **von Hand bearbeitete** Tiere. Deren eigene Felder
|
||||
> werden nach jedem Ingest **eingefroren** (Freeze). Siehe „Geprüfte Tiere / Freeze" unten.
|
||||
> Zusätzlich zu diesen Overrides überleben weiterhin **Tickets/Feedback**.
|
||||
|
||||
```bash
|
||||
# 1. Stammbäume + Wurfchronik → animals.json/litters.json (+ Fotos)
|
||||
python tools/import/extract.py
|
||||
@@ -44,16 +114,30 @@ python tools/import/extract.py
|
||||
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
|
||||
# 4. In die laufende DB laden (API muss laufen): UPSERT + Freeze
|
||||
curl -X POST http://localhost:5179/import/ingest-resolved
|
||||
# 4b. Gegen PROD (ohne SSH): resolved_import.json per HTTP hochladen + ingesten
|
||||
curl -X POST http://truenas:8090/api/import/ingest-resolved/upload \
|
||||
-F "resolved=@tools/import/output/resolved_import.json" # [-F "photos=@neue_fotos.zip"] nur bei neuen Fotos
|
||||
```
|
||||
- **`POST /import/ingest-resolved/upload`** (multipart `resolved` + optional `photos`-ZIP): staged die
|
||||
hochgeladene Datei serverseitig in `Import:SourcePath` und ruft denselben UPSERT-Ingest — damit läuft
|
||||
der Import **gegen Prod ohne SSH/`docker cp`**. Der alte `POST /import/ingest-resolved` (liest die
|
||||
serverseitige Datei) bleibt unverändert. **DB-Copy vom lokalen Stand ist verboten**, sobald Prod
|
||||
kuratiert wurde — nur der Upsert-Ingest bewahrt manuelle/verified/feedback-Zeilen.
|
||||
|
||||
- **`IngestResolvedService` 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.
|
||||
- **`IngestResolvedService` (seit 2026-07-12: UPSERT statt Total-Wipe).** Importierte Zeilen
|
||||
(Gerbils/Litters/Contacts) werden **per deterministischer Id gematcht und in place
|
||||
aktualisiert**; **stale** importierte Zeilen (nicht mehr im Payload) werden entfernt (dangling
|
||||
Referenzen manueller Zeilen werden vorher genullt + im Rückgabestring gewarnt). **Nie gelöscht/
|
||||
überschrieben:** Zeilen mit `IsManual=true` sowie **user-eingegebene Sub-Records**
|
||||
(Weights/Health) und Fotos manueller Tiere. **SaleContracts** werden weiter aus dem Payload
|
||||
neu erzeugt (manuelle Verträge sind noch **nicht** geschützt — Folgeaufgabe). Am Ende werden
|
||||
alle **`GerbilOverride`** angewandt (**Freeze** der eigenen Felder) und für verifizierte Tiere
|
||||
der Rohimport-Stand vorher als Drift-Snapshot erfasst.
|
||||
- **Feedback + GerbilOverride überleben den Ingest** (werden nicht gewischt) — daher haben sie nur
|
||||
**lose, nullable Guid-Spalten ohne FK** (`Feedback`: GerbilId/LitterId/ContactId; `GerbilOverride`:
|
||||
GerbilId). Dieses Muster für alles nutzen, was Re-Ingests überleben soll.
|
||||
- `resolved_import.json` ist **gitignored** (Runtime-Output); `review-report.md` ist getrackt.
|
||||
- Deterministische GUIDs via `generate_guid("...")` → stabile IDs über Re-Ingests.
|
||||
|
||||
@@ -108,9 +192,45 @@ python test_extract.py test_extract_docx.py test_genotype.py test_merge_resol
|
||||
Indiz). UI: Diagramm führt den Vorfahren-Ast zusammen (Verweis-Knoten); Akte zeigt
|
||||
„⚭ Geschwisterverpaarung"-Chip.
|
||||
- **Box-Farbe im Stammbaum-xlsx = Geschlecht**: weiß = weiblich, blau = männlich.
|
||||
- **Bestandstier (`isResident`) vs. reiner Vorfahre** (Regel der Züchterin, Ticket 09bcac78):
|
||||
Ein Tier ist **ihr Zuchttier** ⇔ es hatte bei ihr Nachzucht mit Herkunft **„von den
|
||||
Kleinen Chaoten"** (nur dann konnten die Jungtiere ihren Zuchtnamen tragen). Trägt die
|
||||
Nachzucht einen **fremden** Herkunftsnamen (z. B. „of Black Forest", „of Topolino"), ist
|
||||
das Tier nur **Vorfahre** (`isResident=false`, keine Würfe/Charakter-Bereich). **Ausnahme:
|
||||
zugekaufte Zuchttiere** (fremder Herkunftsname, aber bei ihr gezüchtet → Nachzucht „von den
|
||||
Kleinen Chaoten") bleiben **Bestandstiere** (z. B. Echo/Elia of Black Forest).
|
||||
**Sweep umgesetzt (2026-06-27, Ticket 381f7e51):** Der Wurfchronik-Import-Default ist
|
||||
**nicht mehr `is_resident=True`**. Neue Regel in `merge_and_resolve.py` (später Sweep
|
||||
nach Eltern-/`addLitters`-Verknüpfung): `isResident=true` **genau dann**, wenn (1) ein
|
||||
expliziter `conflict-decisions.json`-`isResident`-Override greift (gewinnt immer — `true`
|
||||
wie Echo/Elia, `false` wie Akane/Naémi/Bentley), **oder** (2) das Tier **Elternteil eines
|
||||
eigenen Wurfs** ist (nicht-virtuell ODER aus Wurfchronik ODER Clan-Nachzucht ODER mit
|
||||
residentem Elternteil ODER `addLitter` mit `ShowInChronicle`). Reine virtuelle
|
||||
Ahnen-Verpaarungen machen **nicht** resident (z. B. Antares bleibt `false`). Sonst
|
||||
`false`. `ShowInChronicle` wird **nach** dem Sweep berechnet (nutzt die finale Residenz).
|
||||
Effekt: resident **1711 → 442**. UI-Label (`de.ts` `isResidentLabel`) = **„Zuchtanwärter/
|
||||
innen"** — die Züchterin markiert künftige Zuchttiere selbst. **Offene Folgeaufgabe:**
|
||||
manuelle UI-Residenz-Markierungen überleben den Re-Ingest noch **nicht** (bräuchten eine
|
||||
ingest-stabile, FK-freie Override-Ablage analog `Feedback`).
|
||||
- **Genotyp/Farbschlag**: 8-Locus-Notation (siehe `tools/import/genotype.py`,
|
||||
`gerbil-manager-web/src/genetics`). Unbekanntes Allel = `-` (nicht `?`).
|
||||
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel.
|
||||
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel. Rezessiver Fuchs ist
|
||||
zwingend homozygot → `e-` ist ungültig (wirft), `ee[-]`→`ee`.
|
||||
- **C-Locus-Zygotie (GEN-6, Tickets bde4ec70/f89e95ad — Regel der Züchterin):** Platin,
|
||||
Saphir und Platin-Hell unterscheiden sich **nur** in der C-Zygotie: `aa CC …pp` = **Platin**,
|
||||
`aa Cc[chm] …pp` = **Saphir**, `aa Cc[h] …pp` = **Platin-Hell**. Der Katalog-Match kann
|
||||
deshalb pro Locus ein **exaktes, ungeordnetes Allelpaar** verlangen — Token-Form `"x/y"`
|
||||
(z. B. `C: 'C/cchm'`), sonst reduziert `locusToken()` auf das dominante Allel und der erste
|
||||
Treffer (Platin) verschluckt die anderen. Gilt in **beiden** Mirrors (`catalog.ts` +
|
||||
`genotype.py`) und im Seed (`colorVarietySeed.*`, `ApplicationContext.SeedColorVarieties`,
|
||||
dort in Kompaktnotation `Ccchm`/`Cch` ohne Klammern).
|
||||
- **`c[hm]` ist ein Schreibfehler-Alias für `c[chm]`** (fehlendes c in einigen Stammbaum-Charts):
|
||||
beide Parser normalisieren ihn, sonst ist der Gencode unparsebar bzw. ergibt „…-Hell".
|
||||
- **Genetik-Engine = korrektheitskritisch: IMMER vollständig mit Tests absichern.**
|
||||
Jede Änderung an `src/genetics/**` (+ Backend-Mirror + `genotype.py`) braucht pro
|
||||
Use-Case/Ticket einen Regressionstest (`src/genetics/__tests__/genetics.test.ts`
|
||||
und `tools/import/test_genotype.py`), damit nie eine Regression entsteht. `npx vitest
|
||||
run` + `python test_genotype.py` müssen grün sein, bevor „fertig".
|
||||
- **Stammbaum-Charts**: Generationen = Spaltenbänder (Proband links in Spalte B/2,
|
||||
je Generation +3 Spalten); Eltern-Position: Vater oben, Mutter unten. Fotos liegen
|
||||
je nach Datei links/auf der Namenszelle — die Seite wird heuristisch über die
|
||||
@@ -143,11 +263,115 @@ python test_extract.py test_extract_docx.py test_genotype.py test_merge_resol
|
||||
attribuierte `history[]`, inkl. „⚠ … verworfen — Grund; … verwendet").
|
||||
- **Fehler melden (Feedback)**: `src/components/ReportErrorDialog.tsx`,
|
||||
`src/api/feedback.ts`, Backend `Endpoints/FeedbackEndpoints.cs` + `Models/Feedback.cs`.
|
||||
- **Geprüfte Tiere / „vollständig korrekt"-Freeze**: Backend `Models/GerbilOverride.cs` (FK-frei,
|
||||
ingest-überlebend), `Import/GerbilSnapshotService.cs` (Akten-Snapshot + Freeze-Map + Diff, von App
|
||||
**und** Test geteilt), `Endpoints/VerifiedGerbilEndpoints.cs` (`/verified-gerbils`: POST markieren,
|
||||
DELETE entfernen/Toggle, GET Liste+Drift, GET `/export` = Golden-Fixture). Freeze wird in
|
||||
`IngestResolvedService` angewandt; `GerbilEndpoints` setzt `IsManual=true` bei POST und legt bei
|
||||
Edit eines importierten Tiers einen per-field-Override an (verifiziert → Golden auto-aktualisiert).
|
||||
Frontend: `src/api/verified.ts`, Badge/Toggle/Drift-Karte in `pages/GerbilDetailPage.tsx`,
|
||||
Übersicht `pages/VerifizierteTierePage.tsx` (`/hilfe/verifizierte-tiere`); Texte in `strings/de.ts`
|
||||
(`verified`-Block). **Modell:** `OverrideJson` = Feld→Wert-Map; `IsVerified=true` = zertifiziert +
|
||||
voller Freeze + Regression/Export, `false` = „geschützt" (nur geänderte Felder). Status/Gehege und
|
||||
Abstammung/Nachzucht werden **nicht** eingefroren (nur erkannt/gemeldet). Regressionstest:
|
||||
`GerbilManager.Tests/VerifiedGoldenTests.cs` gegen `tools/import/verified-golden.json` (via
|
||||
`GET /verified-gerbils/export` erzeugen + committen; skippt, wenn Fixture/`resolved_import.json` fehlt).
|
||||
**Scope-Grenze:** Freeze/Override gelten für **Gerbils**; Edits an importierten Würfen/Kontakten und
|
||||
manuelle SaleContracts sind noch **nicht** geschützt (Folgeaufgaben).
|
||||
- **Verträge**: `Models/SaleContract.cs`, `Endpoints/ContractEndpoints.cs`,
|
||||
`ContractGenerator.cs`, Frontend `src/pages/Vertraege*`; Import: `extract_contracts.py`
|
||||
+ `enrich_from_contracts` in `merge_and_resolve.py`.
|
||||
- **Import-Kernlogik**: `tools/import/merge_and_resolve.py` (Dedup, Eltern-Resolver mit
|
||||
Gender-/Alters-Plausibilität, Litter-/Sibling-Dedup, Provenance, Vertrags-Anreicherung).
|
||||
- **RennmausPro-III-Import (rpro3)**: Backend-Importer `GerbilManagerWebAPI/Import/Rpro3/*`
|
||||
(`Rpro3ImportService` Analyze/Execute, `Rpro3Dedup` = automatischer Dubletten-Merge, Port von
|
||||
`tools/import/compare_rpro3.py`). **Manuelle Dubletten-Entscheidungen der Züchterin** (aus den
|
||||
„rpro3-import"-Tickets) liegen in `GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json` und
|
||||
werden von `Rpro3Dedup.Run(animals, decisions)` honoriert: `same` = Force-Merge (eine rid zieht
|
||||
den Auto-Cluster mit), `different` = Force-Split, `fields` = Feld-Overrides (Farbe/DOB/Herkunft/
|
||||
`genotype` (roher Fcode)/`resident`/Notiz) am Cluster. Der `genotype`-Override ist nötig, wenn
|
||||
Varianten sich im Gencode unterscheiden (z. B. C-Locus c[h] vs. c[chm]) und der Auto-Merge sonst
|
||||
den falschen Gencode (erstes Cluster-Mitglied) zöge — die Züchterin gibt den korrekten vor
|
||||
(z. B. MilkyWay → `aa Cc[h] DD ee Gg P- spsp`). **Schlüssel ist die RPRO3-Nummer (rid), NIE der Variantenbuchstabe**
|
||||
(Buchstaben verschieben sich bei geänderter Clusterbildung) — rids aus dem Ticket-`agentContext`.
|
||||
Triage-Helfer `tools/import/rpro3_lookup.py` (Name- oder `--rids`-Modus) zieht Farbe/Gencode/
|
||||
Eltern/Nachzucht+Partner aus `_rpro3.db` für die Rückfragen an die Züchterin.
|
||||
- **Eltern an der Tier-Akte bearbeiten (QOL)**: Im Tier-Formular gibt es den Block
|
||||
**„Abstammung"** (`gerbil-manager-web/src/pages/GerbilFormPage.tsx`) mit Wurf-Auswahl +
|
||||
Vater/Mutter-Picker (`components/AnimalPicker`), damit die Züchterin die Eltern **ohne** den
|
||||
Umweg über die Wurf-Seite pflegen kann. **Datenmodell unverändert**: Eltern hängen weiter am
|
||||
Geburtswurf. Backend `PUT /gerbils/{id}/parents` (`GerbilEndpoints`, DTOs `GerbilParentsInput`/
|
||||
`GerbilParentsResult`, Tests `GerbilManager.Tests/GerbilParentsTests.cs`) schreibt genau dorthin:
|
||||
hat das Tier einen Wurf → dessen `FatherId`/`MotherId` (gilt zwangsläufig für alle Geschwister,
|
||||
die UI warnt mit `siblingCount`); hat es keinen → ein Wurf mit **gleichem Elternpaar + gleichem
|
||||
Datum** wird verknüpft (`litterAttached`), sonst wird ein Träger-Wurf angelegt (`litterCreated`,
|
||||
Name „Wurf von X + Y", `ShowInChronicle=false`, `IsManual=true`). Geschlechts-Validierung
|
||||
wiederverwendet `LitterEndpoints.ValidateParents` (400 `InvalidParentGender`).
|
||||
**Grenze:** Eltern/Abstammung sind vom Freeze **ausgenommen** (`GerbilSnapshotService.FreezeFields`
|
||||
ohne `litterId`) → an einem **importierten** Wurf überlebt eine Eltern-Korrektur den Re-Ingest
|
||||
**nicht** (gleiches Verhalten wie beim Editieren über die Wurf-Seite; dauerhaft nur über den
|
||||
Importer/`conflict-decisions.json`).
|
||||
- **Manuell ergänzte Würfe / versteckte Würfe**: `conflict-decisions.json` kennt neben
|
||||
`resolutions`/`addAnimals` auch **`addLitters`** [{name, mother(+motherDob), father, children[],
|
||||
showInChronicle, totalBorn, date, notes}] — materialisiert in `merge_and_resolve.py` als
|
||||
**späten** Schritt (nach Dedup/Parent-Roles, vor json.dump): Wurf mit deterministischer
|
||||
`generate_guid("decision-litter-<slug>")`, verknüpft Mutter/Vater/Kinder per Name(+dob),
|
||||
setzt Kinder-`LitterId`. Die name+dob-Resolutions können zusätzlich `isResident`, `notes`,
|
||||
`receiver` (Kontaktname→ReceiverContactId) auf bestehende Tiere setzen (läuft NACH der
|
||||
Residenz-Propagation). `Litter.ShowInChronicle` (bool, Default true; Migration
|
||||
`AddLitterShowInChronicle`): `false` blendet den Wurf aus der Wurfchronik
|
||||
(`WuerfeListPage`, Filter `showInChronicle==true`) aus, zeigt ihn aber weiter auf der
|
||||
Tier-Akte (parentLitters-Query, ungefiltert). Use-Case: extern entstandener Wurf eines
|
||||
nicht-residenten Tieres (z. B. Akane × Bonaparte).
|
||||
- **Über-gemergte Würfe korrigieren**: `conflict-decisions.json` kennt **`litterChildren`**
|
||||
[{date, matchChild, keep[], renameTo?, note}] — autoritative Kinder-Whitelist eines Wurfs
|
||||
(später Schritt in `merge_and_resolve.py` nach `litterOrder`). Match per `date` + `matchChild`
|
||||
(ein aktuell zugeordnetes Kind, disambiguiert gleich-datierte Würfe); Kinder außerhalb `keep`
|
||||
werden aus dem Wurf gelöst (`LitterId=None`, Tier bleibt erhalten), optional `renameTo` setzt
|
||||
den kanonischen Wurf-Namen. Use-Case: Wurfchronik-Abnehmer-Sammelseiten, die fälschlich in einen
|
||||
datierten Buchstaben-Wurf gemergt wurden (Tickets 4b9f49fb M-Wurf, 8d259edf Gale).
|
||||
**`add`** [Name | {name, dob}] ist das Gegenstück (seit 2026-08-18): hängt ein Jungtier an
|
||||
**diesen** Wurf, wenn die Chart-Positions-Heuristik es in einen falschen **virtuellen**
|
||||
Stammbaum-Wurf gezogen hat; wird der alte Wurf dadurch kinderlos UND virtuell, entfernt der
|
||||
Importer ihn (sonst bliebe ein Phantom-Wurf in der Wurfchronik stehen). Use-Case Akane
|
||||
(Ticket 88389f8e): die neuen Stammbäume führen die Eltern eine Zeile unter dem Kind-Block
|
||||
(Vater N80/Mutter N81) — `_reconstruct_parents` greift dann eine Zeile zu hoch und paart die
|
||||
Mutter des Nachbar-Blocks mit dem echten Vater.
|
||||
- **EMF/WMF sind keine Fotos**: Excel legt neben dem Foto teils ein Vektor-Metafile ab; Browser
|
||||
können es nicht darstellen (kaputte Bildkachel in der Akte) → `extract._attach_photos`
|
||||
überspringt `.emf`/`.wmf` (Regressionstest in `test_extract.py`).
|
||||
- **Resolution-Schlüssel in `conflict-decisions.json` (Stand 2026-08-19)**: neben
|
||||
`father/mother(+Dob)`, `isResident`, `notes`, `receiver`, `dateOfDeath`, `deceased`,
|
||||
`correctDob`, `renameTo`, `mergeExternalRefs`, `suppressExternalRefs`, `farbschlag` auch
|
||||
**`goHomeDate`** (autoritativ, überschreibt auch einen vorhandenen/kaputten Wert und zieht
|
||||
Status `GivenAway` nach, sofern nicht `Deceased`) und **`spottingType`** (Scheckungsart —
|
||||
der Importer leitet sie aus keiner Quelle ab; `IngestResolvedService` überträgt sie nur,
|
||||
wenn der Payload einen Wert hat, sonst bliebe eine von Hand erfasste Angabe nicht stehen).
|
||||
**`genotype`** gewinnt bei **präzisem** Match (externalRef ODER name+dob) auch gegen einen
|
||||
bereits gefüllten Wert — bei Wurfchronik-Tieren ist der meist nur der Kanon-Fill des
|
||||
Farbschlags; ein name-only-Match füllt weiterhin nur leere Felder (Namensvetter-Schutz).
|
||||
- **Pro Tier greift nur EINE Resolution** (first match: externalRef → endswith → name+dob →
|
||||
name-only). Nie einen zweiten Eintrag mit derselben externalRef/demselben (name,dob) anlegen,
|
||||
sondern den bestehenden **in place** erweitern — sonst gewinnt still der letzte und Felder
|
||||
gehen verloren. **Wurfchronik-Jungtiere haben zur Override-Zeit noch kein DOB** (das kommt
|
||||
später aus dem Wurfdatum) → dort ist `externalRef` der einzige zuverlässige Schlüssel.
|
||||
- **`renameTo` zieht durch Freitext-Notizen** (`apply_rename_note_sweep`, letzter Schritt vor
|
||||
dem Payload-Dump): der alte Name blieb sonst in Wurf-Notizen stehen („Blacky + Kruke v. …").
|
||||
Wortgenau, mit Guards — Quellname < 4 Zeichen oder > 2 Wörter wird ignoriert, kein Ersetzen,
|
||||
wenn der alte Name im neuen Rufnamen steckt, und Notizen, die die Umbenennung selbst
|
||||
dokumentieren („umbenannt"/„ursprünglich"), bleiben unberührt. Ohne diese Guards trifft der
|
||||
Sweep Abnehmer-Namen und Gewichtsnotizen (im Probelauf 16 von 22 Ersetzungen falsch).
|
||||
- **`parse_date` verwirft implausible Jahre** (< 1900 oder > aktuelles Jahr + 1) → `None`;
|
||||
Wurfchronik-Transkriptionen bauen Daten sonst falsch zusammen (JackJack: „1310-05-13").
|
||||
- **Sternloses Geburtsdatum in Stammbaum-Charts**: `extract.py` normalisiert Zellen wie
|
||||
`22.08.2019/+09.04.2024` (ohne `*`) zu `*…`, wenn Block-Kontext vorliegt (Genotyp ≤3 Zeilen
|
||||
darunter, Namenszelle ≤3 darüber). Ohne das erkennt die Blockheuristik das Tier nicht und
|
||||
`_reconstruct_parents` greift in den Nachbar-Ast (Ticket 65266679 Bijou).
|
||||
- **`renameTo` ist eltern-verknüpfungssicher**: umbenannte Tiere werden im Eltern-Namensindex
|
||||
zusätzlich unter ihrem **Quell-Namen** (`_pre_rename_name`) geführt, sonst verlieren Würfe, die
|
||||
den alten Namen referenzieren, ihren Eltern-Link (Ticket e0a0c304 Kruke→Kuke). `dateOfDeath`
|
||||
ist als Resolution-Override unterstützt (setzt Todesdatum + Status Deceased; rpro3 hat keine
|
||||
Sterbespalte).
|
||||
|
||||
## Verifikations-Checkliste vor „fertig"
|
||||
|
||||
@@ -175,6 +399,13 @@ Die Anbindung ist im Repo persistiert und lädt automatisch:
|
||||
(`mempalace_search` / `mempalace_kg_query`), bevor du im Code suchst; am Ende neue
|
||||
durable Erkenntnisse ablegen.
|
||||
|
||||
**Subagenten MÜSSEN MemPalace ebenfalls nutzen** — das im Subagent-Prompt IMMER explizit
|
||||
anweisen: zu Aufgabenbeginn den Palast abfragen (am einfachsten per CLI, kein Key nötig:
|
||||
`mempalace search "<keywords>"` bzw. `mempalace search "<…>" --wing gerbilmanager`; falls
|
||||
MCP-Tools verfügbar auch `mempalace_search`/`mempalace_kg_query`) — relevante Domänenregeln,
|
||||
frühere Entscheidungen und Stolperfallen holen, BEVOR im Code gesucht wird; durable
|
||||
Erkenntnisse am Ende ablegen (`mempalace add-drawer`/MCP). Gilt für JEDEN gespawnten Agenten.
|
||||
|
||||
### Memory-Policy — WANN / WIE / WO eine Memory anlegen
|
||||
|
||||
**WANN** (anlegen): nur **dauerhaft** nützliches, **nicht-offensichtliches** Wissen, das
|
||||
|
||||
@@ -4,6 +4,13 @@ using System.Net.Sockets;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
// WEB-2: lokales Verzeichnis für die veröffentlichte öffentliche Seite. Die API schreibt
|
||||
// nach <root>/live (POST /api/publish), der publicsite-nginx serviert genau dieses live/
|
||||
// — dieselbe Form wie in Produktion (deploy/truenas), nur lokal zum Testen.
|
||||
var publicSiteRoot = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "deploy", ".local-publicsite"));
|
||||
Directory.CreateDirectory(Path.Combine(publicSiteRoot, "live")); // leeres live/, damit nginx sofort startet
|
||||
var publicSiteConf = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "deploy", "truenas", "nginx", "publicsite.conf"));
|
||||
|
||||
var postgres = builder.AddPostgres("postgres")
|
||||
.WithDataVolume();
|
||||
|
||||
@@ -19,6 +26,8 @@ var webapi = builder.AddProject<Projects.GerbilManagerWebAPI>("webapi")
|
||||
// Proxyless so the app binds the port directly — Program.cs then widens the
|
||||
// host to 0.0.0.0 for LAN access (the DCP proxy binds localhost only).
|
||||
.WithEndpoint("http", e => { e.Port = ApiHttpPort; e.IsProxied = false; }, createIfNotExists: false)
|
||||
// WEB-2: Publish-Ziel der öffentlichen Seite (sonst gibt POST /api/publish 503).
|
||||
.WithEnvironment("PublicSite__RootPath", publicSiteRoot)
|
||||
.WithExternalHttpEndpoints();
|
||||
|
||||
// The machine's LAN IP — the SPA runs in the phone's browser, so it must reach
|
||||
@@ -40,6 +49,15 @@ builder.AddViteApp("frontend", "../gerbil-manager-web")
|
||||
context.EnvironmentVariables["VITE_API_BASE_URL"] = $"http://{lanIp}:{ApiHttpPort}";
|
||||
});
|
||||
|
||||
// WEB-2: publicsite-nginx — serviert die veröffentlichte öffentliche Seite lokal (gleiche
|
||||
// nginx-Config wie in Produktion). Liest das live/-Verzeichnis aus dem Publish-Output der API.
|
||||
// Test: in der App veröffentlichen (oder POST /api/publish), dann http://localhost:8081 öffnen.
|
||||
builder.AddContainer("publicsite", "nginx", "alpine")
|
||||
.WithBindMount(publicSiteRoot, "/usr/share/nginx/html", isReadOnly: true)
|
||||
.WithBindMount(publicSiteConf, "/etc/nginx/conf.d/default.conf", isReadOnly: true)
|
||||
.WithHttpEndpoint(port: 8081, targetPort: 80, name: "http")
|
||||
.WithExternalHttpEndpoints();
|
||||
|
||||
builder.Build().Run();
|
||||
|
||||
// Picks the IPv4 address of the active physical LAN adapter (Wi-Fi/Ethernet),
|
||||
|
||||
@@ -29,6 +29,13 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
public string ContractRoot { get; } =
|
||||
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>Optionaler Isolations-Override für den Import-Quellordner (Import:SourcePath),
|
||||
/// damit der Upload-Ingest-Test in einem Temp-Verzeichnis arbeitet statt im echten Repo.</summary>
|
||||
public string? ImportSourcePath { get; init; }
|
||||
|
||||
/// <summary>Optionaler Isolations-Override für den Foto-Ordner (Photos:RootPath).</summary>
|
||||
public string? PhotosRootPath { get; init; }
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
@@ -37,6 +44,8 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
|
||||
builder.UseSetting("ConnectionStrings:gerbilmanager",
|
||||
"Host=localhost;Database=test;Username=test;Password=test");
|
||||
builder.UseSetting("Contracts:RootPath", ContractRoot);
|
||||
if (ImportSourcePath is not null) builder.UseSetting("Import:SourcePath", ImportSourcePath);
|
||||
if (PhotosRootPath is not null) builder.UseSetting("Photos:RootPath", PhotosRootPath);
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
|
||||
@@ -100,15 +100,138 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
Assert.Equal("Open", reopened.GetProperty("status").GetString());
|
||||
Assert.Equal(JsonValueKind.Null, reopened.GetProperty("resolvedAt").ValueKind);
|
||||
|
||||
// Delete -> 204, then 404 on subsequent edit/delete
|
||||
// Soft-Delete -> 204; das Ticket bleibt erhalten (DeletedAt gesetzt) und ist über GET noch da.
|
||||
var del = await client.DeleteAsync($"/feedback/{id}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
|
||||
var delAgain = await client.DeleteAsync($"/feedback/{id}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, delAgain.StatusCode);
|
||||
var afterDelete = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var deletedRow = afterDelete.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.NotEqual(JsonValueKind.Null, deletedRow.GetProperty("deletedAt").ValueKind);
|
||||
|
||||
var editGone = await client.PutAsJsonAsync($"/feedback/{id}", new { message = "noch da?" });
|
||||
Assert.Equal(HttpStatusCode.NotFound, editGone.StatusCode);
|
||||
// Wiederherstellen -> DeletedAt wieder null, Ticket weiterhin bearbeitbar.
|
||||
var restore = await client.PostAsync($"/feedback/{id}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.OK, restore.StatusCode);
|
||||
var restored = JsonDocument.Parse(await restore.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal(JsonValueKind.Null, restored.GetProperty("deletedAt").ValueKind);
|
||||
|
||||
// Delete/Restore eines unbekannten Tickets -> 404.
|
||||
var delMissing = await client.DeleteAsync($"/feedback/{Guid.NewGuid()}");
|
||||
Assert.Equal(HttpStatusCode.NotFound, delMissing.StatusCode);
|
||||
var restoreMissing = await client.PostAsync($"/feedback/{Guid.NewGuid()}/restore", null);
|
||||
Assert.Equal(HttpStatusCode.NotFound, restoreMissing.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reopen_resolved_ticket_without_Rueckfrage_sets_NeedsInfo_and_ReopenedAt()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Farbschlag stimmt nicht.",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Direkt (ohne Rückfrage) lösen …
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved", fixNote = "behoben" });
|
||||
|
||||
// … und wieder öffnen: Frontend schickt NeedsInfo + Bitte-um-Infos-Frage.
|
||||
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
status = "NeedsInfo",
|
||||
question = "Bitte beschreibe, was noch fehlt.",
|
||||
});
|
||||
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
|
||||
Assert.Equal("Bitte beschreibe, was noch fehlt.", r.GetProperty("question").GetString());
|
||||
// Echt gelöstes Ticket ohne vorherige Rückfrage -> ReopenedAt gesetzt.
|
||||
Assert.NotEqual(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attachment_upload_list_serve_delete_lifecycle()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Foto vom Fellschlag.",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// 1×1-PNG hochladen.
|
||||
const string pngB64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||
{
|
||||
fileName = "maus.png",
|
||||
contentType = "image/png",
|
||||
dataBase64 = pngB64,
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.Created, up.StatusCode);
|
||||
var att = JsonDocument.Parse(await up.Content.ReadAsStringAsync()).RootElement;
|
||||
var attId = att.GetProperty("id").GetString();
|
||||
Assert.Equal("maus.png", att.GetProperty("fileName").GetString());
|
||||
Assert.True(att.GetProperty("size").GetInt32() > 0);
|
||||
|
||||
// GET /feedback liefert die Metadaten (ohne Bytes).
|
||||
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var row = listed.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.Equal(1, row.GetProperty("attachments").GetArrayLength());
|
||||
|
||||
// Bytes ausliefern.
|
||||
var bytes = await client.GetByteArrayAsync($"/feedback/attachments/{attId}");
|
||||
Assert.Equal(Convert.FromBase64String(pngB64).Length, bytes.Length);
|
||||
|
||||
// Löschen -> danach keine Anhänge mehr.
|
||||
var del = await client.DeleteAsync($"/feedback/attachments/{attId}");
|
||||
Assert.Equal(HttpStatusCode.NoContent, del.StatusCode);
|
||||
var after = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||
var rowAfter = after.EnumerateArray().Single(f => f.GetProperty("id").GetString() == id);
|
||||
Assert.Equal(0, rowAfter.GetProperty("attachments").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attachment_upload_rejects_invalid_base64()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new { message = "x", context = "gerbil-detail" });
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
var up = await client.PostAsJsonAsync($"/feedback/{id}/attachments", new
|
||||
{
|
||||
fileName = "x.png",
|
||||
contentType = "image/png",
|
||||
dataBase64 = "###nicht base64###",
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, up.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reopen_resolved_ticket_that_had_a_Rueckfrage_keeps_question_and_no_ReopenedAt()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var create = await client.PostAsJsonAsync("/feedback", new
|
||||
{
|
||||
message = "Welches Tier ist gemeint?",
|
||||
context = "gerbil-detail",
|
||||
});
|
||||
var id = JsonDocument.Parse(await create.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString();
|
||||
|
||||
// Rückfrage stellen (NeedsInfo) …
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { question = "Meinst du A oder B?" });
|
||||
// … manuell lösen …
|
||||
await client.PutAsJsonAsync($"/feedback/{id}", new { status = "Resolved" });
|
||||
// … und wieder öffnen: Frontend schickt dieselbe Frage erneut (keine Bitte-um-Infos).
|
||||
var reopen = await client.PutAsJsonAsync($"/feedback/{id}", new
|
||||
{
|
||||
status = "NeedsInfo",
|
||||
question = "Meinst du A oder B?",
|
||||
});
|
||||
var r = JsonDocument.Parse(await reopen.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.Equal("NeedsInfo", r.GetProperty("status").GetString());
|
||||
Assert.Equal("Meinst du A oder B?", r.GetProperty("question").GetString());
|
||||
// Hatte bereits eine offene Rückfrage -> KEIN „Wieder geöffnet am"-Zeitstempel.
|
||||
Assert.Equal(JsonValueKind.Null, r.GetProperty("reopenedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -377,6 +500,38 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_single_feedback_by_id_and_status_filter()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// Zwei Tickets mit unterscheidbaren Namen anlegen.
|
||||
var open = await client.PostAsJsonAsync("/feedback", new { message = "Offenes Ticket", context = "gerbil-detail", entityName = "FilterOpen" });
|
||||
var openId = JsonDocument.Parse(await open.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
|
||||
var other = await client.PostAsJsonAsync("/feedback", new { message = "Zweites Ticket", context = "gerbil-detail", entityName = "FilterOther" });
|
||||
var otherId = JsonDocument.Parse(await other.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetString()!;
|
||||
|
||||
// Einzel-GET liefert genau dieses Ticket (mit attachments-Feld).
|
||||
var single = JsonDocument.Parse(await client.GetStringAsync($"/feedback/{openId}")).RootElement;
|
||||
Assert.Equal("FilterOpen", single.GetProperty("entityName").GetString());
|
||||
Assert.Equal(JsonValueKind.Array, single.GetProperty("attachments").ValueKind);
|
||||
|
||||
// Unbekannte Id -> 404.
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/feedback/{Guid.NewGuid()}")).StatusCode);
|
||||
|
||||
// Zweites Ticket auf Answered setzen, dann Status-Filter prüfen.
|
||||
await client.PutAsJsonAsync($"/feedback/{otherId}", new { status = "Answered" });
|
||||
var answered = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Answered")).RootElement;
|
||||
Assert.Contains(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == otherId);
|
||||
Assert.DoesNotContain(answered.EnumerateArray(), f => f.GetProperty("id").GetString() == openId);
|
||||
|
||||
// Kombinierter Filter (Open,Answered) enthält beide.
|
||||
var both = JsonDocument.Parse(await client.GetStringAsync("/feedback?status=Open,Answered")).RootElement;
|
||||
var ids = both.EnumerateArray().Select(f => f.GetProperty("id").GetString()).ToHashSet();
|
||||
Assert.Contains(openId, ids);
|
||||
Assert.Contains(otherId, ids);
|
||||
}
|
||||
|
||||
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||
{
|
||||
Id = id,
|
||||
|
||||
188
GerbilManager.Tests/GerbilParentsTests.cs
Normal file
188
GerbilManager.Tests/GerbilParentsTests.cs
Normal file
@@ -0,0 +1,188 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// QOL-ELTERN: PUT /gerbils/{id}/parents — Eltern direkt an der Tier-Akte pflegen.
|
||||
/// Das Datenmodell bleibt unverändert (Eltern hängen am Geburtswurf), der Endpoint
|
||||
/// schreibt nur dorthin bzw. legt den Träger-Wurf an, wenn es noch keinen gibt.
|
||||
/// </summary>
|
||||
public class GerbilParentsTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
public GerbilParentsTests(ApiFactory factory) => _client = factory.CreateClient();
|
||||
|
||||
private async Task<string> CreateGerbil(string name, string gender, string? dob = null, string? litterId = null)
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/gerbils", new
|
||||
{
|
||||
name,
|
||||
gender,
|
||||
dateOfBirth = dob,
|
||||
litterId,
|
||||
});
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement
|
||||
.GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<string> CreateLitter(string name, string date, string? fatherId = null, string? motherId = null)
|
||||
{
|
||||
var resp = await _client.PostAsJsonAsync("/litters", new { name, date, fatherId, motherId });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement
|
||||
.GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> SetParents(string gerbilId, string? fatherId, string? motherId)
|
||||
{
|
||||
var resp = await _client.PutAsJsonAsync($"/gerbils/{gerbilId}/parents", new { fatherId, motherId });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetLitter(string litterId) =>
|
||||
JsonDocument.Parse(await _client.GetStringAsync($"/litters/{litterId}")).RootElement;
|
||||
|
||||
private static string? Str(JsonElement el, string prop) =>
|
||||
el.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||
|
||||
[Fact]
|
||||
public async Task Parents_of_existing_litter_are_written_to_that_litter()
|
||||
{
|
||||
var father = await CreateGerbil("Vater A", "male", "2024-01-01");
|
||||
var mother = await CreateGerbil("Mutter A", "female", "2024-01-01");
|
||||
var litterId = await CreateLitter("A-Wurf", "2025-03-01");
|
||||
var pup = await CreateGerbil("Jungtier A", "female", "2025-03-01", litterId);
|
||||
|
||||
var result = await SetParents(pup, father, mother);
|
||||
|
||||
Assert.Equal(litterId, Str(result, "litterId"));
|
||||
Assert.False(result.GetProperty("litterCreated").GetBoolean());
|
||||
Assert.False(result.GetProperty("litterAttached").GetBoolean());
|
||||
var litter = await GetLitter(litterId);
|
||||
Assert.Equal(father, Str(litter, "fatherId"));
|
||||
Assert.Equal(mother, Str(litter, "motherId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiblingCount_reports_the_other_pups_the_change_applies_to()
|
||||
{
|
||||
var litterId = await CreateLitter("B-Wurf", "2025-04-01");
|
||||
var pup = await CreateGerbil("Jungtier B1", "female", "2025-04-01", litterId);
|
||||
await CreateGerbil("Jungtier B2", "male", "2025-04-01", litterId);
|
||||
await CreateGerbil("Jungtier B3", "male", "2025-04-01", litterId);
|
||||
var mother = await CreateGerbil("Mutter B", "female", "2024-02-01");
|
||||
|
||||
var result = await SetParents(pup, null, mother);
|
||||
|
||||
Assert.Equal(2, result.GetProperty("siblingCount").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gerbil_without_litter_gets_a_hidden_carrier_litter()
|
||||
{
|
||||
var father = await CreateGerbil("Vater C", "male", "2024-03-01");
|
||||
var mother = await CreateGerbil("Mutter C", "female", "2024-03-01");
|
||||
var orphan = await CreateGerbil("Waise C", "female", "2025-05-01");
|
||||
|
||||
var result = await SetParents(orphan, father, mother);
|
||||
|
||||
Assert.True(result.GetProperty("litterCreated").GetBoolean());
|
||||
var litterId = Str(result, "litterId")!;
|
||||
Assert.Equal("Wurf von Vater C + Mutter C", Str(result, "litterName"));
|
||||
|
||||
var litter = await GetLitter(litterId);
|
||||
Assert.Equal(father, Str(litter, "fatherId"));
|
||||
Assert.Equal(mother, Str(litter, "motherId"));
|
||||
// Nur zur Abstammung — der Hilfs-Wurf darf die Wurfchronik nicht zumüllen.
|
||||
Assert.False(litter.GetProperty("showInChronicle").GetBoolean());
|
||||
// Wurfdatum = Geburtsdatum des Tiers.
|
||||
Assert.Equal("2025-05-01", Str(litter, "date"));
|
||||
|
||||
// Das Tier hängt jetzt an diesem Wurf.
|
||||
var gerbil = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{orphan}")).RootElement;
|
||||
Assert.Equal(litterId, Str(gerbil, "litterId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Same_parents_and_same_dob_attach_to_the_existing_litter_instead_of_duplicating()
|
||||
{
|
||||
var father = await CreateGerbil("Vater D", "male", "2024-04-01");
|
||||
var mother = await CreateGerbil("Mutter D", "female", "2024-04-01");
|
||||
var first = await CreateGerbil("Waise D1", "female", "2025-06-01");
|
||||
var second = await CreateGerbil("Waise D2", "male", "2025-06-01");
|
||||
|
||||
var created = await SetParents(first, father, mother);
|
||||
Assert.True(created.GetProperty("litterCreated").GetBoolean());
|
||||
|
||||
var attached = await SetParents(second, father, mother);
|
||||
Assert.False(attached.GetProperty("litterCreated").GetBoolean());
|
||||
Assert.True(attached.GetProperty("litterAttached").GetBoolean());
|
||||
// Beide sind jetzt Geschwister im selben Wurf.
|
||||
Assert.Equal(Str(created, "litterId"), Str(attached, "litterId"));
|
||||
Assert.Equal(1, attached.GetProperty("siblingCount").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clearing_parents_nulls_them_on_the_litter()
|
||||
{
|
||||
var father = await CreateGerbil("Vater E", "male", "2024-05-01");
|
||||
var litterId = await CreateLitter("E-Wurf", "2025-07-01", father);
|
||||
var pup = await CreateGerbil("Jungtier E", "female", "2025-07-01", litterId);
|
||||
|
||||
await SetParents(pup, null, null);
|
||||
|
||||
var litter = await GetLitter(litterId);
|
||||
Assert.Equal(JsonValueKind.Null, litter.GetProperty("fatherId").ValueKind);
|
||||
Assert.Equal(JsonValueKind.Null, litter.GetProperty("motherId").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_litter_and_no_parents_is_a_no_op()
|
||||
{
|
||||
var orphan = await CreateGerbil("Waise F", "female", "2025-08-01");
|
||||
|
||||
var result = await SetParents(orphan, null, null);
|
||||
|
||||
Assert.Equal(JsonValueKind.Null, result.GetProperty("litterId").ValueKind);
|
||||
Assert.False(result.GetProperty("litterCreated").GetBoolean());
|
||||
var gerbil = JsonDocument.Parse(await _client.GetStringAsync($"/gerbils/{orphan}")).RootElement;
|
||||
Assert.Equal(JsonValueKind.Null, gerbil.GetProperty("litterId").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wrong_gender_parent_returns_400_InvalidParentGender()
|
||||
{
|
||||
var female = await CreateGerbil("Weibchen G", "female", "2024-06-01");
|
||||
var pup = await CreateGerbil("Jungtier G", "male", "2025-09-01");
|
||||
|
||||
var resp = await _client.PutAsJsonAsync($"/gerbils/{pup}/parents",
|
||||
new { fatherId = female, motherId = (string?)null });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
Assert.Contains("InvalidParentGender", await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Self_as_parent_returns_400()
|
||||
{
|
||||
var g = await CreateGerbil("Selbstbezug H", "male", "2025-10-01");
|
||||
|
||||
var resp = await _client.PutAsJsonAsync($"/gerbils/{g}/parents",
|
||||
new { fatherId = g, motherId = (string?)null });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_gerbil_returns_404()
|
||||
{
|
||||
var resp = await _client.PutAsJsonAsync($"/gerbils/{Guid.NewGuid()}/parents",
|
||||
new { fatherId = (string?)null, motherId = (string?)null });
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, resp.StatusCode);
|
||||
}
|
||||
}
|
||||
61
GerbilManager.Tests/ImportIngestUploadEndpointTests.cs
Normal file
61
GerbilManager.Tests/ImportIngestUploadEndpointTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// POST /import/ingest-resolved/upload — Upload-Variante des Ingests: eine hochgeladene
|
||||
/// resolved_import.json wird serverseitig in den Import-Quellordner gestaged und direkt ingested.
|
||||
/// Damit lässt sich der Import gegen PROD fahren, ohne die Datei per SSH/docker cp in den Container
|
||||
/// zu kopieren. Der Test isoliert Quell-/Fotoordner über ApiFactory in Temp-Verzeichnisse.
|
||||
/// </summary>
|
||||
public class ImportIngestUploadEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Upload_stages_resolved_json_and_runs_ingest()
|
||||
{
|
||||
var srcDir = Path.Combine(Path.GetTempPath(), "ingest-upload-" + Guid.NewGuid().ToString("N"));
|
||||
var photoDir = Path.Combine(Path.GetTempPath(), "ingest-upload-photos-" + Guid.NewGuid().ToString("N"));
|
||||
using var factory = new ApiFactory { ImportSourcePath = srcDir, PhotosRootPath = photoDir };
|
||||
var client = factory.CreateClient();
|
||||
|
||||
// Minimaler, aber valider Payload (leere Sammlungen) — genügt, um Staging + Ingest-Lauf zu prüfen.
|
||||
const string json = "{\"Gerbils\":[],\"Litters\":[],\"Contacts\":[],\"SaleContracts\":[],\"GerbilPhotos\":[]}";
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(Encoding.UTF8.GetBytes(json));
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
form.Add(file, "resolved", "resolved_import.json");
|
||||
|
||||
var resp = await client.PostAsync("/import/ingest-resolved/upload", form);
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("Error:", body); // Ingest lief, keine "file not found"-Fehlermeldung
|
||||
|
||||
// Die hochgeladene Datei wurde an den erwarteten Ort gestaged.
|
||||
Assert.True(File.Exists(Path.Combine(srcDir, "resolved_import.json")));
|
||||
|
||||
try { Directory.Delete(srcDir, recursive: true); } catch { /* best effort */ }
|
||||
try { Directory.Delete(photoDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upload_rejects_empty_file_with_400()
|
||||
{
|
||||
var srcDir = Path.Combine(Path.GetTempPath(), "ingest-upload-" + Guid.NewGuid().ToString("N"));
|
||||
using var factory = new ApiFactory { ImportSourcePath = srcDir };
|
||||
var client = factory.CreateClient();
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new ByteArrayContent(Array.Empty<byte>());
|
||||
file.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
||||
form.Add(file, "resolved", "resolved_import.json");
|
||||
|
||||
var resp = await client.PostAsync("/import/ingest-resolved/upload", form);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
|
||||
try { Directory.Delete(srcDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
@@ -257,5 +258,85 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(ContractId, contract.Id);
|
||||
Assert.Single(contract.Animals);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_rederives_status_after_deceased_override()
|
||||
{
|
||||
// Ticket 37ab228a "Gaida": ein verstorbenes Tier blieb nach dem Ingest auf Status
|
||||
// 'Breeding' hängen, weil der Freeze das Todesdatum aus dem Override zurückschrieb,
|
||||
// ohne den Status neu abzuleiten. Der Ingest muss GerbilStatusService.Apply erneut aufrufen.
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
|
||||
.Build();
|
||||
|
||||
db.GerbilOverrides.Add(new GerbilOverride
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = FatherId,
|
||||
EntityName = "Papa",
|
||||
IsVerified = false, // "geschützt" — wird trotzdem angewandt
|
||||
OverrideJson = "{\"dateOfDeath\":\"2025-10-09\"}",
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
|
||||
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.NotNull(father.DateOfDeath); // Todesdatum aus Override angewandt
|
||||
Assert.Equal(GerbilStatus.Deceased, father.Status); // ... und Status daraus abgeleitet
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt bzw. entfernt die Scheckungsart im gestageten Payload (Testdaten werden hier
|
||||
/// selbst aufgebaut — der Test hängt bewusst an keiner echten conflict-decisions.json).
|
||||
/// </summary>
|
||||
private void SetPayloadSpottingType(string? value, bool remove = false)
|
||||
{
|
||||
var root = JsonNode.Parse(File.ReadAllText(_resolvedJsonPath))!;
|
||||
var papa = root["Gerbils"]!.AsArray()
|
||||
.First(g => (string?)g!["Name"] == "Papa")!.AsObject();
|
||||
if (remove) papa.Remove("SpottingType");
|
||||
else papa["SpottingType"] = value;
|
||||
File.WriteAllText(_resolvedJsonPath, root.ToJsonString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IngestResolved_writes_spotting_type_and_keeps_it_when_payload_has_none()
|
||||
{
|
||||
// Ticket a547be62 (Eliza): Die Scheckungsart kennt der Importer nur über
|
||||
// conflict-decisions.json — sie MUSS also durch den Ingest getragen werden.
|
||||
// Umgekehrt darf ein leerer Payload-Wert eine von Hand in der App erfasste
|
||||
// Scheckungsart NICHT nullen (der Importer weiß von ihr nichts).
|
||||
using var db = NewDb();
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", _dir } })
|
||||
.Build();
|
||||
|
||||
// (1) Neues Tier: Wert aus dem Payload landet in der DB.
|
||||
SetPayloadSpottingType("Ansatzschecke");
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
var father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.Equal("Ansatzschecke", father.SpottingType);
|
||||
|
||||
// (2) Re-Ingest, Feld fehlt im Payload → bestehender Wert bleibt stehen.
|
||||
SetPayloadSpottingType(null, remove: true);
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.Equal("Ansatzschecke", father.SpottingType);
|
||||
|
||||
// (3) Re-Ingest mit explizitem null → ebenfalls kein Nullen (Guard im Update-Zweig).
|
||||
SetPayloadSpottingType(null);
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.Equal("Ansatzschecke", father.SpottingType);
|
||||
|
||||
// (4) Ein NEUER Wert aus dem Payload gewinnt weiterhin (Entscheidung korrigiert).
|
||||
SetPayloadSpottingType("Mantelschecke");
|
||||
await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
father = await db.Gerbils.SingleAsync(g => g.Id == FatherId);
|
||||
Assert.Equal("Mantelschecke", father.SpottingType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
GerbilManager.Tests/PushEndpointTests.cs
Normal file
44
GerbilManager.Tests/PushEndpointTests.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace GerbilManager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WEB-PUSH: VAPID-Public-Key abrufen + Abo speichern/entfernen.
|
||||
/// (Das tatsächliche Versenden wird nicht getestet — es geht an externe Push-Dienste.)
|
||||
/// </summary>
|
||||
public class PushEndpointTests : IClassFixture<ApiFactory>
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public PushEndpointTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
[Fact]
|
||||
public async Task Vapid_public_key_is_exposed()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var doc = JsonDocument.Parse(await client.GetStringAsync("/push/vapid-public-key")).RootElement;
|
||||
Assert.True(doc.GetProperty("enabled").GetBoolean());
|
||||
Assert.False(string.IsNullOrWhiteSpace(doc.GetProperty("publicKey").GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_is_idempotent_and_unsubscribe_works()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var endpoint = $"https://push.example.com/{Guid.NewGuid()}";
|
||||
var body = new { endpoint, p256dh = "abc123", auth = "def456" };
|
||||
|
||||
var first = await client.PostAsJsonAsync("/push/subscribe", body);
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
// Erneutes Abo mit derselben Endpoint-URL -> weiterhin OK (Upsert, kein Duplikat).
|
||||
var second = await client.PostAsJsonAsync("/push/subscribe", body);
|
||||
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
|
||||
|
||||
var bad = await client.PostAsJsonAsync("/push/subscribe", new { endpoint = "", p256dh = "", auth = "" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
|
||||
|
||||
var unsub = await client.PostAsJsonAsync("/push/unsubscribe", new { endpoint });
|
||||
Assert.Equal(HttpStatusCode.NoContent, unsub.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,76 @@ namespace GerbilManager.Tests
|
||||
Assert.Equal(2, r.Placeholders.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_force_merges_variants_declared_same_by_breeder()
|
||||
{
|
||||
// Zwei Varianten, die der Auto-Dedup wegen Farbkonflikt getrennt lässt …
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Akiro", new DateOnly(2008, 2, 5), "Polarfuchs", "Privatzucht"), // Variante B
|
||||
Ext("u2", "Akiro", new DateOnly(2008, 2, 6), "Polarfuchs, hell", "unbekannt"), // Variante C
|
||||
};
|
||||
// ohne Entscheidung: getrennt (Farbe + DOB unterschiedlich → Konflikt)
|
||||
Assert.Empty(Rpro3Dedup.Run(animals).MergeClusters);
|
||||
|
||||
// … werden per „Same"-Entscheidung der Züchterin zusammengelegt.
|
||||
var decisions = new Rpro3Decisions
|
||||
{
|
||||
Decisions = { new Rpro3Decision { Name = "Akiro", Same = { new() { "u1", "u2" } } } }
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals, decisions);
|
||||
Assert.Single(r.MergeClusters);
|
||||
Assert.Equal(2, r.MergeClusters.Values.First().Count);
|
||||
Assert.Equal(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dedup_force_splits_variants_declared_different_by_breeder()
|
||||
{
|
||||
// Zwei Datensätze, die der Auto-Dedup zusammenlegen würde (kompatibel) …
|
||||
var animals = new List<Rpro3Animal>
|
||||
{
|
||||
Ext("u1", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||
Ext("u2", "Max", new DateOnly(2013, 2, 1), "Marder", "Clan A"),
|
||||
};
|
||||
Assert.Single(Rpro3Dedup.Run(animals).MergeClusters);
|
||||
|
||||
// … bleiben durch „Different" getrennt.
|
||||
var decisions = new Rpro3Decisions
|
||||
{
|
||||
Decisions = { new Rpro3Decision { Name = "Max", Different = { new() { "u1" }, new() { "u2" } } } }
|
||||
};
|
||||
var r = Rpro3Dedup.Run(animals, decisions);
|
||||
Assert.Empty(r.MergeClusters);
|
||||
Assert.NotEqual(r.RidToRoot["u1"], r.RidToRoot["u2"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rpro3Decisions_load_returns_empty_when_file_missing()
|
||||
{
|
||||
var d = Rpro3Decisions.Load(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Guid.NewGuid().ToString("N") + ".json"));
|
||||
Assert.Empty(d.Decisions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rpro3Decisions_roundtrips_through_json()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "rpro3-dec-" + Guid.NewGuid().ToString("N") + ".json");
|
||||
File.WriteAllText(path,
|
||||
"{\"decisions\":[{\"name\":\"Bura\",\"same\":[[\"228\",\"u2128\"]],\"fields\":{\"228\":{\"origin\":\"Sarah Wörz\"}}}," +
|
||||
"{\"name\":\"MilkyWay\",\"same\":[[\"u1031\",\"u1019\"]],\"fields\":{\"u1031\":{\"genotype\":\"aa Cc[h] DD ee Gg P- spsp\",\"color\":\"Kohlfuchs-Hell\"}}}]}");
|
||||
try
|
||||
{
|
||||
var d = Rpro3Decisions.Load(path);
|
||||
Assert.Equal(2, d.Decisions.Count);
|
||||
Assert.Equal("Sarah Wörz", d.BuildFieldIndex()["228"].Origin);
|
||||
Assert.Equal(new[] { "228", "u2128" }, d.SameGroups().First());
|
||||
// Gencode-Override (z. B. MilkyWay: korrektes C-Locus c[h]) muss durch den JSON-Roundtrip kommen.
|
||||
Assert.Equal("aa Cc[h] DD ee Gg P- spsp", d.BuildFieldIndex()["u1031"].Genotype);
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
// ── Integration: Reader + Execute gegen eine Mini-RPRO3-DB ────
|
||||
|
||||
[Fact]
|
||||
|
||||
339
GerbilManager.Tests/VerifiedGerbilTests.cs
Normal file
339
GerbilManager.Tests/VerifiedGerbilTests.cs
Normal file
@@ -0,0 +1,339 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Regression tests for the "Freeze/Override" mechanism (verified key animals + manual work
|
||||
/// survive the re-ingest). Exercised at the service + ingest level with an InMemory DB, mirroring
|
||||
/// the harness in <see cref="IngestResolvedServiceTests"/>.
|
||||
/// </summary>
|
||||
public class VerifiedGerbilTests : IDisposable
|
||||
{
|
||||
private static readonly Guid Schwarz = new("00000000-0000-0000-0000-000000000006");
|
||||
private readonly List<string> _dirs = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var d in _dirs)
|
||||
try { Directory.Delete(d, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
// ── Harness helpers ──────────────────────────────────────────────────
|
||||
|
||||
private string NewDir()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "verified-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
_dirs.Add(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static ApplicationContext NewDb()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("verified-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
return db;
|
||||
}
|
||||
|
||||
private static IngestResolvedService Service(ApplicationContext db, string dir)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", dir },
|
||||
{ "Photos:RootPath", dir },
|
||||
})
|
||||
.Build();
|
||||
return new IngestResolvedService(db, config, null!);
|
||||
}
|
||||
|
||||
private static void WritePayload(string dir, object[] gerbils, object[]? litters = null, object[]? contacts = null) =>
|
||||
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(new
|
||||
{
|
||||
Contacts = contacts ?? Array.Empty<object>(),
|
||||
Litters = litters ?? Array.Empty<object>(),
|
||||
Gerbils = gerbils,
|
||||
GerbilPhotos = Array.Empty<object>(),
|
||||
SaleContracts = Array.Empty<object>(),
|
||||
}));
|
||||
|
||||
private static object ImportGerbil(Guid id, string name, string genotype, string gender = "female", string dob = "2022-01-01") => new
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Gender = gender,
|
||||
Status = "Breeding",
|
||||
DateOfBirth = dob,
|
||||
Genotype = genotype,
|
||||
ColorVarietyId = Schwarz,
|
||||
IsResident = true,
|
||||
OriginBreeder = "Test Zucht",
|
||||
};
|
||||
|
||||
// ── 1. Manual animal survives the ingest (never wiped) ───────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Manual_animal_survives_ingest_that_omits_it()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
|
||||
var manualId = Guid.NewGuid();
|
||||
db.Gerbils.Add(new Gerbil { Id = manualId, Name = "Handmaus", Gender = Gender.female, IsManual = true });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var importedId = Guid.NewGuid();
|
||||
WritePayload(dir, new[] { ImportGerbil(importedId, "Importiert", "aa CC DD EE GG PP spsp") });
|
||||
|
||||
var result = await Service(db, dir).RunAsync();
|
||||
|
||||
Assert.StartsWith("Ingestion successful!", result);
|
||||
// Manual row is still there, unchanged.
|
||||
var manual = await db.Gerbils.FindAsync(manualId);
|
||||
Assert.NotNull(manual);
|
||||
Assert.True(manual!.IsManual);
|
||||
Assert.Equal("Handmaus", manual.Name);
|
||||
// Imported row was loaded.
|
||||
Assert.NotNull(await db.Gerbils.FindAsync(importedId));
|
||||
Assert.Equal(2, await db.Gerbils.CountAsync());
|
||||
}
|
||||
|
||||
// ── 2. Verified freeze forces the golden values over a drifted import ─
|
||||
|
||||
[Fact]
|
||||
public async Task Verified_override_forces_golden_and_records_drift()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
var id = new Guid("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
// First ingest: import brings X in with the WRONG genotype/name.
|
||||
WritePayload(dir, new[] { ImportGerbil(id, "Falschname", "aa dd DD EE GG PP spsp") });
|
||||
var svc = Service(db, dir);
|
||||
await svc.RunAsync();
|
||||
|
||||
// The breeder corrects X and marks it "vollständig korrekt".
|
||||
var g = await db.Gerbils.FindAsync(id);
|
||||
Assert.NotNull(g);
|
||||
g!.Name = "Korrekt";
|
||||
g.Genotype = "aa CC DD EE GG PP spsp";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id);
|
||||
db.GerbilOverrides.Add(new GerbilOverride
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = id,
|
||||
EntityName = g.Name,
|
||||
IsVerified = true,
|
||||
OverrideJson = GerbilSnapshotService.BuildFreezeJson(g),
|
||||
SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// A later import DRIFTS back to wrong values.
|
||||
WritePayload(dir, new[] { ImportGerbil(id, "Wiederfalsch", "aa dd DD EE GG PP spsp") });
|
||||
await svc.RunAsync();
|
||||
|
||||
// Golden wins (freeze re-applied on top of the fresh import).
|
||||
var after = await db.Gerbils.FindAsync(id);
|
||||
Assert.Equal("Korrekt", after!.Name);
|
||||
Assert.Equal("aa CC DD EE GG PP spsp", after.Genotype);
|
||||
|
||||
// Drift was detected and recorded, naming the offending fields.
|
||||
var ov = await db.GerbilOverrides.SingleAsync();
|
||||
Assert.False(string.IsNullOrEmpty(ov.LastImportDiffJson));
|
||||
Assert.Contains("genotype", ov.LastImportDiffJson);
|
||||
Assert.Contains("name", ov.LastImportDiffJson);
|
||||
Assert.NotNull(ov.LastImportSnapshotJson);
|
||||
}
|
||||
|
||||
// ── 3. No drift → LastImportDiffJson stays null ──────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Verified_override_without_drift_leaves_diff_null()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
var id = new Guid("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
WritePayload(dir, new[] { ImportGerbil(id, "Stabil", "aa CC DD EE GG PP spsp") });
|
||||
var svc = Service(db, dir);
|
||||
await svc.RunAsync();
|
||||
|
||||
var g = await db.Gerbils.FindAsync(id);
|
||||
var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id);
|
||||
db.GerbilOverrides.Add(new GerbilOverride
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = id,
|
||||
EntityName = g!.Name,
|
||||
IsVerified = true,
|
||||
OverrideJson = GerbilSnapshotService.BuildFreezeJson(g),
|
||||
SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!),
|
||||
VerifiedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Re-ingest with the IDENTICAL payload (== golden).
|
||||
await svc.RunAsync();
|
||||
|
||||
var ov = await db.GerbilOverrides.SingleAsync();
|
||||
Assert.Null(ov.LastImportDiffJson);
|
||||
}
|
||||
|
||||
// ── 4. Removing the override → import-driven again ───────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Removing_override_restores_import_driven_values()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
var id = new Guid("33333333-3333-3333-3333-333333333333");
|
||||
|
||||
WritePayload(dir, new[] { ImportGerbil(id, "Import-Wert", "aa CC DD EE GG PP spsp") });
|
||||
var svc = Service(db, dir);
|
||||
await svc.RunAsync();
|
||||
|
||||
var g = await db.Gerbils.FindAsync(id);
|
||||
g!.Genotype = "aa cc DD EE GG PP spsp"; // "golden"
|
||||
await db.SaveChangesAsync();
|
||||
var golden = await GerbilSnapshotService.BuildSnapshotAsync(db, id);
|
||||
var ov = new GerbilOverride
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
GerbilId = id,
|
||||
IsVerified = true,
|
||||
OverrideJson = GerbilSnapshotService.BuildFreezeJson(g),
|
||||
SnapshotJson = GerbilSnapshotService.SerializeSnapshot(golden!),
|
||||
UpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.GerbilOverrides.Add(ov);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Freeze wins while the override exists.
|
||||
await svc.RunAsync();
|
||||
Assert.Equal("aa cc DD EE GG PP spsp", (await db.Gerbils.FindAsync(id))!.Genotype);
|
||||
|
||||
// Remove the override → the animal follows the import again.
|
||||
db.GerbilOverrides.Remove(ov);
|
||||
await db.SaveChangesAsync();
|
||||
await svc.RunAsync();
|
||||
Assert.Equal("aa CC DD EE GG PP spsp", (await db.Gerbils.FindAsync(id))!.Genotype);
|
||||
}
|
||||
|
||||
// ── 5. Per-field protection (pure service level) ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PerField_change_pins_only_the_changed_field()
|
||||
{
|
||||
var g = new Gerbil { Name = "Quelle", Notes = "alte Notiz", Genotype = "aa CC DD EE GG PP spsp" };
|
||||
|
||||
var before = GerbilSnapshotService.BuildFreezeObject(g);
|
||||
g.Notes = "neue Notiz";
|
||||
var after = GerbilSnapshotService.BuildFreezeObject(g);
|
||||
|
||||
var changed = GerbilSnapshotService.ChangedFields(before, after);
|
||||
var only = Assert.Single(changed); // ONLY notes changed
|
||||
Assert.Equal("notes", only.Key);
|
||||
|
||||
var merged = GerbilSnapshotService.MergeOverrideJson("{}", changed);
|
||||
var mergedObj = JsonNode.Parse(merged)!.AsObject();
|
||||
Assert.True(mergedObj.ContainsKey("notes"));
|
||||
Assert.False(mergedObj.ContainsKey("genotype")); // untouched fields not pinned
|
||||
|
||||
// Applying it onto a different animal overwrites ONLY notes, not the genotype.
|
||||
var target = new Gerbil { Name = "Ziel", Notes = "Ziel-Notiz", Genotype = "zz ZZ dd ee gg pp spsp" };
|
||||
GerbilSnapshotService.ApplyOverride(target, merged);
|
||||
Assert.Equal("neue Notiz", target.Notes);
|
||||
Assert.Equal("zz ZZ dd ee gg pp spsp", target.Genotype);
|
||||
}
|
||||
|
||||
// ── 6. Dangling reference from a manual row is nulled + warned ────────
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_parent_reference_from_manual_litter_is_nulled_with_warning()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var dir = NewDir();
|
||||
var fatherId = new Guid("44444444-4444-4444-4444-444444444444");
|
||||
|
||||
// Ingest 1 brings in the (imported) father.
|
||||
WritePayload(dir, new[] { ImportGerbil(fatherId, "Vater", "aa CC DD EE GG PP spsp", gender: "male") });
|
||||
var svc = Service(db, dir);
|
||||
await svc.RunAsync();
|
||||
|
||||
// The breeder wires a MANUAL litter to that imported father.
|
||||
var litterId = Guid.NewGuid();
|
||||
db.Litters.Add(new Litter { Id = litterId, Name = "Manueller Wurf", FatherId = fatherId, IsManual = true });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Ingest 2 no longer contains the father → it becomes stale and is removed.
|
||||
WritePayload(dir, Array.Empty<object>());
|
||||
var result = await svc.RunAsync();
|
||||
|
||||
// The manual litter survives, but its dangling parent link was nulled...
|
||||
var litter = await db.Litters.FindAsync(litterId);
|
||||
Assert.NotNull(litter);
|
||||
Assert.True(litter!.IsManual);
|
||||
Assert.Null(litter.FatherId);
|
||||
// ...and a warning was surfaced.
|
||||
Assert.Contains("warning", result);
|
||||
Assert.Contains("nicht mehr existiert", result);
|
||||
// The stale imported father is gone.
|
||||
Assert.Null(await db.Gerbils.FindAsync(fatherId));
|
||||
}
|
||||
|
||||
// ── 7. Diff detects ancestry / offspring differences ─────────────────
|
||||
|
||||
[Fact]
|
||||
public void Diff_detects_parent_and_children_differences()
|
||||
{
|
||||
var self = new SnapshotSelf(
|
||||
"Proband", "female", "2022-01-01", null, null, null, "aa CC DD EE GG PP spsp",
|
||||
null, "Schwarz", true, null, false, null, null, null, null,
|
||||
new List<string>(), null, null);
|
||||
|
||||
var golden = new GerbilSnapshot(
|
||||
self,
|
||||
new SnapshotParent("Papa Alt", "2020-01-01", "aa CC"),
|
||||
null,
|
||||
new List<SnapshotLitter>
|
||||
{
|
||||
new("Wurf1", "2023-01-01", "mother", 3, true, new List<SnapshotChild>
|
||||
{
|
||||
new("Kind1", "2023-01-01", "female"),
|
||||
}),
|
||||
});
|
||||
|
||||
var other = new GerbilSnapshot(
|
||||
self,
|
||||
new SnapshotParent("Papa Neu", "2020-01-01", "aa CC"), // different father
|
||||
null,
|
||||
new List<SnapshotLitter>
|
||||
{
|
||||
new("Wurf1", "2023-01-01", "mother", 3, true, new List<SnapshotChild>
|
||||
{
|
||||
new("Kind1", "2023-01-01", "female"),
|
||||
new("Kind2", "2023-01-02", "male"), // extra child
|
||||
}),
|
||||
});
|
||||
|
||||
var diffs = GerbilSnapshotService.Diff(golden, other);
|
||||
Assert.NotEmpty(diffs);
|
||||
Assert.Contains(diffs, d => d.Path == "father");
|
||||
Assert.Contains(diffs, d => d.Path.StartsWith("wurf["));
|
||||
}
|
||||
}
|
||||
}
|
||||
151
GerbilManager.Tests/VerifiedGoldenTests.cs
Normal file
151
GerbilManager.Tests/VerifiedGoldenTests.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace GerbilManager.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Golden regression fixture: ingests the REAL <c>tools/import/output/resolved_import.json</c>
|
||||
/// (gitignored, local only) and asserts that every verified key animal's Akte still matches its
|
||||
/// frozen golden snapshot in <c>tools/import/verified-golden.json</c> (committed fixture).
|
||||
///
|
||||
/// Both files are optional: if EITHER is missing the test skips cleanly (no failure), so it is a
|
||||
/// no-op on CI / fresh clones but a real guard on the breeder's machine once she has verified
|
||||
/// animals and exported the fixture.
|
||||
/// </summary>
|
||||
public class VerifiedGoldenTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public VerifiedGoldenTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
private sealed record GoldenEntry(Guid GerbilId, string? Name, DateTimeOffset? VerifiedAt, GerbilSnapshot? Snapshot);
|
||||
|
||||
private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private static string? FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(dir.FullName, "tools", "import")))
|
||||
return dir.FullName;
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verified_animals_still_match_their_golden_snapshot()
|
||||
{
|
||||
var repoRoot = FindRepoRoot();
|
||||
if (repoRoot is null)
|
||||
{
|
||||
_out.WriteLine("SKIP: repo root (tools/import) not found from the test host.");
|
||||
return;
|
||||
}
|
||||
|
||||
var goldenPath = Path.Combine(repoRoot, "tools", "import", "verified-golden.json");
|
||||
var outputDir = Path.Combine(repoRoot, "tools", "import", "output");
|
||||
var resolvedPath = Path.Combine(outputDir, "resolved_import.json");
|
||||
|
||||
if (!File.Exists(goldenPath))
|
||||
{
|
||||
_out.WriteLine($"SKIP: golden fixture not present ({goldenPath}).");
|
||||
return;
|
||||
}
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
_out.WriteLine($"SKIP: resolved_import.json not present ({resolvedPath}) — run the import pipeline.");
|
||||
return;
|
||||
}
|
||||
|
||||
List<GoldenEntry>? golden;
|
||||
try
|
||||
{
|
||||
golden = JsonSerializer.Deserialize<List<GoldenEntry>>(await File.ReadAllTextAsync(goldenPath), Web);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_out.WriteLine($"SKIP: could not parse golden fixture: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
if (golden is null || golden.Count == 0)
|
||||
{
|
||||
_out.WriteLine("SKIP: golden fixture is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ingest the real resolved_import.json into an InMemory DB.
|
||||
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||
.UseInMemoryDatabase("verified-golden-" + Guid.NewGuid().ToString("N"))
|
||||
.Options;
|
||||
using var db = new ApplicationContext(opts);
|
||||
db.Database.EnsureCreated();
|
||||
|
||||
var photoDir = Path.Combine(Path.GetTempPath(), "verified-golden-photos-" + Guid.NewGuid().ToString("N"));
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
{ "Import:SourcePath", outputDir },
|
||||
{ "Photos:RootPath", photoDir },
|
||||
})
|
||||
.Build();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||
Assert.StartsWith("Ingestion successful!", result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_out.WriteLine($"SKIP: ingest of the real resolved_import.json failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(photoDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
var mismatches = new List<string>();
|
||||
int checkedCount = 0;
|
||||
foreach (var entry in golden)
|
||||
{
|
||||
if (entry.Snapshot is null) continue;
|
||||
checkedCount++;
|
||||
GerbilSnapshot? actual;
|
||||
try
|
||||
{
|
||||
actual = await GerbilSnapshotService.BuildSnapshotAsync(db, entry.GerbilId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: Akte konnte nicht gebaut werden — {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (actual is null)
|
||||
{
|
||||
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: fehlt im Import (verifiziertes Tier verschwunden).");
|
||||
continue;
|
||||
}
|
||||
|
||||
var diffs = GerbilSnapshotService.Diff(entry.Snapshot, actual);
|
||||
if (diffs.Count > 0)
|
||||
{
|
||||
var fields = string.Join("; ", diffs.Select(d => $"{d.Path}: golden='{d.GoldenValue}' ≠ import='{d.OtherValue}'"));
|
||||
mismatches.Add($"• {entry.Name ?? entry.GerbilId.ToString()}: {diffs.Count} Abweichung(en) — {fields}");
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"Golden-Fixture geprüft: {checkedCount} verifizierte Tiere, {mismatches.Count} abweichend.");
|
||||
if (mismatches.Count > 0)
|
||||
{
|
||||
var report = $"Golden-Regression fehlgeschlagen — {mismatches.Count} von {checkedCount} verifizierten Tieren weichen vom eingefrorenen Snapshot ab:\n"
|
||||
+ string.Join("\n", mismatches);
|
||||
Assert.Fail(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,14 @@ public class ApplicationContext : DbContext
|
||||
public DbSet<Request> Requests => Set<Request>();
|
||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||
public DbSet<FeedbackAttachment> FeedbackAttachments => Set<FeedbackAttachment>();
|
||||
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
|
||||
public DbSet<AcquisitionRecord> AcquisitionRecords => Set<AcquisitionRecord>();
|
||||
public DbSet<SaleReservation> SaleReservations => Set<SaleReservation>();
|
||||
public DbSet<WaitingListEntry> WaitingListEntries => Set<WaitingListEntry>();
|
||||
public DbSet<ReturnRecord> ReturnRecords => Set<ReturnRecord>();
|
||||
public DbSet<ExhibitionResult> ExhibitionResults => Set<ExhibitionResult>();
|
||||
public DbSet<GerbilOverride> GerbilOverrides => Set<GerbilOverride>();
|
||||
|
||||
// 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.
|
||||
@@ -72,6 +75,10 @@ public class ApplicationContext : DbContext
|
||||
// Residency defaults to true (own stock unless explicitly marked external).
|
||||
e.Property(g => g.IsResident).HasDefaultValue(true);
|
||||
|
||||
// Manual-flag defaults false: every pre-existing / imported row is import-driven.
|
||||
// Only POST /gerbils sets it true, so the ingest wipe never touches manual animals.
|
||||
e.Property(g => g.IsManual).HasDefaultValue(false);
|
||||
|
||||
// DB-4: German collation on searched/sorted name fields (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
@@ -123,6 +130,8 @@ public class ApplicationContext : DbContext
|
||||
e.HasIndex(l => l.ExternalRef)
|
||||
.IsUnique()
|
||||
.HasFilter("\"ExternalRef\" IS NOT NULL");
|
||||
|
||||
e.Property(l => l.IsManual).HasDefaultValue(false);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<HealthRecord>(e =>
|
||||
@@ -266,6 +275,19 @@ public class ApplicationContext : DbContext
|
||||
e.HasIndex(x => x.GerbilId);
|
||||
});
|
||||
|
||||
// VERIFIED/PROTECTED OVERRIDE: deliberately relationship-free (same pattern as Feedback).
|
||||
// GerbilId is a plain Guid column (no navigation property → EF creates NO foreign key), so
|
||||
// the import re-ingest wipe of Gerbils never cascades into — or breaks — override rows. They
|
||||
// survive re-ingest and are re-applied on top of the fresh import (the freeze), which is the
|
||||
// whole point.
|
||||
modelBuilder.Entity<GerbilOverride>(e =>
|
||||
{
|
||||
e.HasIndex(o => o.GerbilId).IsUnique();
|
||||
});
|
||||
|
||||
// Manual-flag default false for Contact (imported/pre-existing rows are import-driven).
|
||||
modelBuilder.Entity<Contact>().Property(c => c.IsManual).HasDefaultValue(false);
|
||||
|
||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||
if (isNpgsql)
|
||||
{
|
||||
@@ -302,9 +324,9 @@ public class ApplicationContext : DbContext
|
||||
(int n, string Slug, string Title, string BodyMd)[] legalPages =
|
||||
{
|
||||
(7, "impressum", "Impressum",
|
||||
"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||
"## Angaben gemäß § 18 Abs. 1 MStV\\n\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nE-Mail: [E-Mail-Adresse]\\nTelefon: [Telefonnummer]\\n\\n---\\n\\nDiese Seite stellt eine private Hobbyzucht mongolischer Rennmäuse ohne Gewinnerzielungsabsicht vor.\\n\\n*Hinweis für den Seitenbetreiber: Name und ladungsfähige Anschrift (kein Postfach) sind Pflicht (§ 18 Abs. 1 MStV). Sollte die Zucht jemals gewerblich bzw. geschäftsmäßig betrieben werden, gelten zusätzlich § 5 DDG (vollständiges Anbieter-Impressum) und ggf. die Erlaubnispflicht nach § 11 TierSchG — dann zuständige Behörde und Aktenzeichen hier ergänzen. Kein Hinweis auf die EU-Streitbeilegungsplattform: diese wurde zum 20.07.2025 abgeschaltet (ein fortbestehender Link wäre irreführend).*"),
|
||||
(8, "datenschutz", "Datenschutz",
|
||||
"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*"),
|
||||
"## Datenschutzerklärung\\n\\n## 1. Verantwortlicher\\n\\nVerantwortlich für die Datenverarbeitung auf dieser Webseite ist:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\nE-Mail: [E-Mail-Adresse]\\n\\n## 2. Hosting und Server-Logfiles\\n\\nDiese Webseite wird auf einem eigenen Server betrieben. Beim Aufruf der Seite verarbeitet der Server automatisch Informationen, die dein Browser übermittelt, in sogenannten Server-Logfiles: IP-Adresse, Datum und Uhrzeit des Zugriffs, die aufgerufene Seite bzw. Datei, die übertragene Datenmenge sowie Browser und Betriebssystem.\\nDiese Verarbeitung ist technisch erforderlich, um die Webseite auszuliefern und ihren sicheren und stabilen Betrieb zu gewährleisten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am störungsfreien Betrieb und an der Sicherheit). Die Logfiles werden nach spätestens [z. B. 7] Tagen gelöscht und nicht mit anderen Daten zusammengeführt.\\n\\n## 3. Cookies, Tracking und externe Dienste\\n\\nDiese Webseite verwendet keine Cookies, kein Tracking und keine Analyse- oder Marketing-Dienste. Es werden keine externen Inhalte Dritter (z. B. Schriftarten, Karten, Videos, CDNs) nachgeladen.\\n\\n## 4. Kontaktaufnahme\\n\\nWenn du uns per E-Mail kontaktierst, verarbeiten wir deine Angaben (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht) ausschließlich zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.\\n\\n## 5. Empfänger der Daten\\n\\nEine Weitergabe deiner Daten an Dritte findet nicht statt. Eine Datenübermittlung in Länder außerhalb der EU bzw. des EWR erfolgt nicht.\\n\\n## 6. Deine Rechte\\n\\nDu hast jederzeit das Recht auf:\\n- Auskunft über die zu deiner Person gespeicherten Daten (Art. 15 DSGVO)\\n- Berichtigung unrichtiger Daten (Art. 16 DSGVO)\\n- Löschung (Art. 17 DSGVO)\\n- Einschränkung der Verarbeitung (Art. 18 DSGVO)\\n- Datenübertragbarkeit (Art. 20 DSGVO)\\n- Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)\\n\\nZur Ausübung genügt eine formlose Nachricht an die oben genannte E-Mail-Adresse.\\n\\n## 7. Beschwerderecht bei der Aufsichtsbehörde\\n\\nDu hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). Zuständig ist die Aufsichtsbehörde deines Bundeslandes bzw. des Seitenbetreibers: [zuständige Landesdatenschutzbehörde eintragen].\\n\\n## 8. Keine automatisierte Entscheidungsfindung\\n\\nEine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Platzhalter in eckigen Klammern ausfüllen. Aufbewahrungsdauer der Logfiles an deinen Server bzw. Reverse-Proxy anpassen. Sobald externe Dienste oder ein Kontaktformular eingebunden werden, diesen Text ergänzen. Stand: [Datum eintragen].*"),
|
||||
};
|
||||
|
||||
var pageRows = new List<Page>();
|
||||
@@ -389,10 +411,12 @@ public class ApplicationContext : DbContext
|
||||
("Elfenbein", "AA CC DD EE gg pp spsp rere", 21),
|
||||
("Kohlfuchs", "aa CC DD ee GG PP spsp rere", 22),
|
||||
("Polarfuchs", "AA CC DD ee gg PP spsp rere", 23),
|
||||
("Saphir", "aa CC DD EE GG pp spsp rere", 24),
|
||||
// GEN-6 (Tickets bde4ec70/f89e95ad): C-Zygotie unterscheidet Platin (CC),
|
||||
// Saphir (Ccchm) und Platin-Hell (Cch) — vorher waren alle drei "aa CC ...".
|
||||
("Saphir", "aa Ccchm DD EE GG pp spsp rere", 24),
|
||||
("Orangeschimmel","AA CC DD efef GG PP spsp rere", 25),
|
||||
("Topas", "AA CC DD EE GG pp spsp rere", 26),
|
||||
("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27),
|
||||
("Platin-Hell", "aa Cch DD EE GG pp spsp rere", 27),
|
||||
// --- Dilute (dd) varieties (IDs 29-32) ---
|
||||
("Dilute Agouti", "AA CC dd EE GG PP spsp rere", 28),
|
||||
("Dilute Silberagouti","AA CC dd EE gg PP spsp rere", 29),
|
||||
@@ -400,25 +424,28 @@ public class ApplicationContext : DbContext
|
||||
("Dilute Anthrazit", "aa CC dd EE gg PP spsp rere", 31),
|
||||
// --- Schimmel / Fuchsschimmel (IDs 33-37) ---
|
||||
("Silberschimmel", "AA CC DD efef gg PP spsp rere", 36),
|
||||
("Polarfuchsschimmel", "AA CC DD efef gg PP spsp rere", 37),
|
||||
("Algierfuchsschimmel", "AA CC DD efef GG PP spsp rere", 38),
|
||||
("Kohlfuchsschimmel", "aa CC DD efef GG PP spsp rere", 39),
|
||||
("Blaufuchsschimmel", "aa CC DD efef gg PP spsp rere", 40),
|
||||
// GEN-5 (ticket 5826e8e2): *Fuchsschimmel = HET ef/e (internal 'efe'),
|
||||
// not hom ef/ef — a Schimmel-modified Fox. Pure *schimmel stay efef.
|
||||
("Polarfuchsschimmel", "AA CC DD efe gg PP spsp rere", 37),
|
||||
("Algierfuchsschimmel", "AA CC DD efe GG PP spsp rere", 38),
|
||||
("Kohlfuchsschimmel", "aa CC DD efe GG PP spsp rere", 39),
|
||||
("Blaufuchsschimmel", "aa CC DD efe gg PP spsp rere", 40),
|
||||
// --- Hell variants (IDs 38-48) ---
|
||||
("Kohlfuchs, hell", "aa CC DD ee GG PP spsp rere", 41),
|
||||
("Goldfuchs, hell", "AA CC DD ee GG pp spsp rere", 42),
|
||||
("Goldfuchsschimmel", "AA CC DD efef GG pp spsp rere", 43),
|
||||
("Goldfuchsschimmel", "AA CC DD efe GG pp spsp rere", 43),
|
||||
("Gold-Hell", "AA CC DD EE GG pp spsp rere", 44),
|
||||
("Blaufuchs, hell", "aa CC DD ee gg PP spsp rere", 45),
|
||||
("Rotfuchsschimmel", "aa CC DD efef GG pp spsp rere", 46),
|
||||
("Rotfuchsschimmel", "aa CC DD efe GG pp spsp rere", 46),
|
||||
("Polarfuchs, hell", "AA CC DD ee gg PP spsp rere", 47),
|
||||
("Kohlfuchsschimmel, hell","aa CC DD efef GG PP spsp rere", 48),
|
||||
("Kohlfuchsschimmel, hell","aa CC DD efe GG PP spsp rere", 48),
|
||||
("Rotfuchs, hell", "aa CC DD ee GG pp spsp rere", 49),
|
||||
("Kohlfuchs-Hell", "aa CC DD ee GG PP spsp rere", 50),
|
||||
("Algierfuchs, hell", "AA CC DD ee GG PP spsp rere", 51),
|
||||
// --- Dilute (dd) renamed variants (IDs 49-50) ---
|
||||
("Dilute Topas", "AA CC dd EE GG pp spsp rere", 52),
|
||||
("Dilute Blaufuchs","aa CC dd ee gg pp spsp rere", 53),
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed; dilution is P-independent.
|
||||
("Dilute Blaufuchs","aa CC dd ee gg PP spsp rere", 53),
|
||||
// --- Marder / Siam / CP- series (IDs 51-66) ---
|
||||
("Marder", "aa cchmcchm DD EE GG PP spsp rere", 54),
|
||||
("Siam", "aa cchmch DD EE GG PP spsp rere", 55),
|
||||
|
||||
@@ -17,6 +17,11 @@ RUN dotnet publish GerbilManagerWebAPI.csproj -c Release -o /app/publish /p:UseA
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
# curl für den Container-Healthcheck (das Basis-Image bringt weder curl noch wget mit;
|
||||
# ohne dieses würde der compose-Healthcheck dauerhaft fehlschlagen und api nie "healthy").
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# .NET 10 containers default to ASPNETCORE_HTTP_PORTS=8080 bound on all interfaces.
|
||||
EXPOSE 8080
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
|
||||
namespace GerbilManagerWebAPI.Dtos
|
||||
{
|
||||
@@ -33,7 +33,8 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
bool IsResident,
|
||||
string? ProfilePhotoUrl,
|
||||
bool IsCastrated,
|
||||
string? Provenance);
|
||||
string? Provenance,
|
||||
int? BirthOrder = null);
|
||||
|
||||
public record LitterDto(
|
||||
Guid Id,
|
||||
@@ -41,11 +42,13 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
int? Stillborn,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes,
|
||||
string? Provenance);
|
||||
string? Provenance,
|
||||
bool ShowInChronicle);
|
||||
|
||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
|
||||
|
||||
@@ -65,8 +68,33 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
|
||||
public record PhotoDto(Guid Id, string FileName, string? Caption, int SortOrder, string Url);
|
||||
|
||||
// VERIFIED/PROTECTED OVERRIDE -------------------------------------------
|
||||
// Status: "unchanged" = raw import agrees with the golden state; "drifted" = the raw import
|
||||
// differs from the golden state (see ImportDiff — a signal for what the importer might still
|
||||
// get wrong); "missing" = the verified animal no longer exists in the imported data.
|
||||
public record VerifiedGerbilDto(
|
||||
Guid GerbilId,
|
||||
string? EntityName,
|
||||
bool IsVerified,
|
||||
string Status,
|
||||
DateTimeOffset? VerifiedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? Note,
|
||||
IReadOnlyList<string> ProtectedFields,
|
||||
IReadOnlyList<SnapshotDiffDto> ImportDiff);
|
||||
|
||||
/// <summary>One field difference between the golden state and the raw import.</summary>
|
||||
public record SnapshotDiffDto(string Path, string? GoldenValue, string? ImportValue);
|
||||
|
||||
/// <summary>Export entry for the committed regression fixture (verified-golden.json).</summary>
|
||||
public record VerifiedGoldenEntry(
|
||||
Guid GerbilId, string? Name, DateTimeOffset? VerifiedAt, Import.GerbilSnapshot? Snapshot);
|
||||
|
||||
// Request DTOs -----------------------------------------------------------
|
||||
|
||||
/// <summary>Body for POST /verified-gerbils/{id} — optional free-text note.</summary>
|
||||
public record VerifyGerbilInput(string? Note);
|
||||
|
||||
public record GerbilInput(
|
||||
string? Name,
|
||||
Gender? Gender,
|
||||
@@ -92,15 +120,28 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
bool? IsResident,
|
||||
bool? IsCastrated);
|
||||
|
||||
/// <summary>QOL: Eltern direkt in der Tier-Akte setzen (PUT /gerbils/{id}/parents).
|
||||
/// Das Datenmodell bleibt unverändert — geschrieben werden die Eltern des GEBURTSWURFS.</summary>
|
||||
public record GerbilParentsInput(Guid? FatherId, Guid? MotherId);
|
||||
|
||||
/// <summary>Ergebnis von PUT /gerbils/{id}/parents: welcher Wurf die Eltern jetzt trägt, ob er
|
||||
/// dafür neu angelegt (LitterCreated) bzw. ein bestehender verknüpft wurde (LitterAttached) und
|
||||
/// wie viele Geschwister im selben Wurf die Änderung mitbetrifft.</summary>
|
||||
public record GerbilParentsResult(
|
||||
Guid? LitterId, string? LitterName, bool LitterCreated, bool LitterAttached,
|
||||
int SiblingCount, Guid? FatherId, Guid? MotherId);
|
||||
|
||||
public record LitterInput(
|
||||
string Name,
|
||||
DateOnly? Date,
|
||||
int? TotalBorn,
|
||||
int? DeathsWithin8Weeks,
|
||||
int? Stillborn,
|
||||
Guid? FatherId,
|
||||
Guid? MotherId,
|
||||
DateOnly? ExpectedGoHomeDate,
|
||||
string? Notes);
|
||||
string? Notes,
|
||||
bool? ShowInChronicle = null);
|
||||
|
||||
public record ContactInput(string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
||||
|
||||
|
||||
@@ -20,6 +20,19 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string Text,
|
||||
DateTimeOffset? At);
|
||||
|
||||
/// <summary>FEEDBACK: lightweight metadata for an attachment (no bytes).</summary>
|
||||
public record FeedbackAttachmentDto(
|
||||
Guid Id,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
int Size);
|
||||
|
||||
/// <summary>FEEDBACK: payload to upload an attachment (base64-encoded bytes).</summary>
|
||||
public record FeedbackAttachmentInput(
|
||||
string FileName,
|
||||
string ContentType,
|
||||
string DataBase64);
|
||||
|
||||
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||
public record FeedbackDto(
|
||||
Guid Id,
|
||||
@@ -43,7 +56,17 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
/// <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);
|
||||
IReadOnlyList<FeedbackThreadEntry> Thread,
|
||||
/// <summary>When a genuinely-resolved ticket (no pending Rückfrage) was reopened; else null.</summary>
|
||||
DateTimeOffset? ReopenedAt = null,
|
||||
/// <summary>Soft-delete marker: when the ticket was deleted via the UI (recoverable); else null.</summary>
|
||||
DateTimeOffset? DeletedAt = null,
|
||||
/// <summary>Optional AI-set category/topic for filtering (e.g. "Genetik", "Import"); null = none.</summary>
|
||||
string? Category = null,
|
||||
/// <summary>Was the resolution helpful? true=👍, false=👎, null=no feedback yet.</summary>
|
||||
bool? Helpful = null,
|
||||
/// <summary>Attachment metadata (no bytes); fetch bytes via /feedback/attachments/{id}.</summary>
|
||||
IReadOnlyList<FeedbackAttachmentDto>? Attachments = null);
|
||||
|
||||
/// <summary>
|
||||
/// FEEDBACK: payload for PUT /feedback/{id}. Edit the message and/or toggle status,
|
||||
@@ -56,5 +79,15 @@ namespace GerbilManagerWebAPI.Dtos
|
||||
string? Question,
|
||||
string? Answer,
|
||||
string? FixNote,
|
||||
string? AgentContext);
|
||||
string? AgentContext,
|
||||
/// <summary>Set/clear the ticket category. Empty/whitespace clears it.</summary>
|
||||
string? Category = null,
|
||||
/// <summary>👍/👎 on a resolved ticket. null leaves it unchanged.</summary>
|
||||
bool? Helpful = null,
|
||||
/// <summary>
|
||||
/// Wenn true, wird nach dem Update eine Push-Benachrichtigung an die Züchterin gesendet.
|
||||
/// Nur die KI/der Betreuer setzt das (z. B. neue Rückfrage / Ticket gelöst); das Frontend
|
||||
/// setzt es NIE — so löst die Züchterin mit eigenen Aktionen keine Selbst-Pushes aus.
|
||||
/// </summary>
|
||||
bool? Notify = null);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
group.MapPost("/", async (ContactInput input, ApplicationContext db) =>
|
||||
{
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix };
|
||||
var c = new Contact { Id = Guid.NewGuid(), Name = input.Name, Email = input.Email, Phone = input.Phone, Address = input.Address, Notes = input.Notes, IsBreeder = input.IsBreeder, IsReceiver = input.IsReceiver, NameSuffix = input.NameSuffix, IsManual = true };
|
||||
db.Contacts.Add(c);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/contacts/{c.Id}", ToDto(c));
|
||||
|
||||
@@ -18,6 +18,12 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
/// </summary>
|
||||
public static class FeedbackEndpoints
|
||||
{
|
||||
/// <summary>Aufbewahrungsfrist im Papierkorb: danach werden Tickets endgültig gelöscht.</summary>
|
||||
private const int TrashRetentionDays = 30;
|
||||
|
||||
/// <summary>Maximale Anhang-Größe (10 MB) — Fotos vom Handy passen locker, schützt aber die DB.</summary>
|
||||
private const int MaxAttachmentBytes = 10 * 1024 * 1024;
|
||||
|
||||
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||
@@ -49,23 +55,77 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
|
||||
});
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
group.MapGet("/", async (ApplicationContext db, string? status) =>
|
||||
{
|
||||
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
||||
var rows = await db.Feedback.AsNoTracking().ToListAsync();
|
||||
// In memory verarbeiten: SQLite (Test-Host) kann weder ORDER BY noch WHERE-Vergleiche
|
||||
// auf DateTimeOffset-Spalten übersetzen.
|
||||
var rows = await db.Feedback.ToListAsync();
|
||||
|
||||
// Optionaler Status-Filter (?status=Open,Answered) — kommagetrennt, case-insensitiv.
|
||||
// Spart der Triage den 2-MB-Volldump; soft-gelöschte Tickets werden dann ausgeblendet.
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
var wanted = status.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
rows = rows.Where(f => f.DeletedAt is null && wanted.Contains(f.Status)).ToList();
|
||||
}
|
||||
|
||||
// Aufräumen: soft-gelöschte Tickets werden nach TrashRetentionDays endgültig entfernt
|
||||
// (lazy beim Abruf — genügt für Single-User, kein Hintergrunddienst nötig).
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-TrashRetentionDays);
|
||||
var expired = rows.Where(f => f.DeletedAt is { } d && d < cutoff).ToList();
|
||||
if (expired.Count > 0)
|
||||
{
|
||||
db.Feedback.RemoveRange(expired);
|
||||
await db.SaveChangesAsync();
|
||||
rows = rows.Except(expired).ToList();
|
||||
}
|
||||
|
||||
// Anhang-Metadaten (OHNE Bytes) laden und je Ticket zuordnen.
|
||||
var attMeta = await db.FeedbackAttachments
|
||||
.Select(a => new { a.Id, a.FeedbackId, a.FileName, a.ContentType, a.Size })
|
||||
.ToListAsync();
|
||||
var byTicket = attMeta
|
||||
.GroupBy(a => a.FeedbackId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (IReadOnlyList<FeedbackAttachmentDto>)g
|
||||
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
|
||||
.ToList());
|
||||
|
||||
return TypedResults.Ok(rows
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(ToDto)
|
||||
.Select(f => ToDto(f, byTicket.GetValueOrDefault(f.Id)))
|
||||
.ToList());
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackUpdate input, ApplicationContext db) =>
|
||||
// Einzelnes Ticket (inkl. Anhang-Metadaten). Praktisch für die Triage, um ein Ticket
|
||||
// gezielt zu laden, statt die ganze Liste zu ziehen. 404 bei unbekannter Id.
|
||||
group.MapGet("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
var atts = await db.FeedbackAttachments
|
||||
.Where(a => a.FeedbackId == id)
|
||||
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
|
||||
.ToListAsync();
|
||||
return TypedResults.Ok(ToDto(entity, atts));
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackUpdate input, ApplicationContext db, Push.PushNotifier push) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
// Zustand VOR den Mutationen merken — für die Wiederöffnen-Erkennung weiter unten.
|
||||
var wasResolved = entity.Status.Equals("Resolved", StringComparison.OrdinalIgnoreCase);
|
||||
var hadOpenRueckfrage = !string.IsNullOrWhiteSpace(entity.Question);
|
||||
|
||||
if (input.Message is not null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Message))
|
||||
@@ -133,6 +193,13 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
entity.ResolvedAt = resolved
|
||||
? (entity.ResolvedAt ?? DateTimeOffset.UtcNow)
|
||||
: null;
|
||||
|
||||
// Wiederöffnen-Zeitstempel: nur, wenn ein ECHT gelöstes Ticket (ohne offene
|
||||
// Rückfrage) wieder geöffnet wird. Tickets, die nur eine offene Rückfrage hatten
|
||||
// und manuell auf gelöst gesetzt wurden, kehren beim Wiederöffnen einfach in
|
||||
// ihre Rückfrage zurück und bekommen KEINEN Zeitstempel.
|
||||
if (!resolved && wasResolved && !hadOpenRueckfrage)
|
||||
entity.ReopenedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
// FIX-NOTE: breeder-friendly changelog (typically set together with status=Resolved).
|
||||
@@ -151,10 +218,46 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
entity.AgentContext = c.Length == 0 ? null : c;
|
||||
}
|
||||
|
||||
// KATEGORIE: frei wählbares Thema für Filter/Übersicht; leer = löschen.
|
||||
if (input.Category is not null)
|
||||
{
|
||||
var cat = input.Category.Trim();
|
||||
entity.Category = cat.Length == 0 ? null : cat;
|
||||
}
|
||||
|
||||
// HILFREICH (👍/👎): nur setzen, wenn übermittelt (null = unverändert).
|
||||
if (input.Helpful is not null)
|
||||
entity.Helpful = input.Helpful;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Push an die Züchterin, wenn die KI das anfordert (notify=true). Nachricht aus dem
|
||||
// resultierenden Status ableiten. Fehler dürfen die Antwort nicht stören.
|
||||
if (input.Notify == true && push.Enabled)
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(entity.EntityName) ? "" : $" ({entity.EntityName})";
|
||||
var (title, body) = entity.Status switch
|
||||
{
|
||||
"NeedsInfo" => ("Neue Rückfrage" + name, Trim(entity.Question) ?? "Bitte schau in deine Tickets."),
|
||||
"Resolved" => ("Ticket gelöst" + name, Trim(entity.FixNote) ?? Trim(entity.Message) ?? "Erledigt."),
|
||||
_ => ("Neues zu deinem Ticket" + name, Trim(entity.Message) ?? ""),
|
||||
};
|
||||
try
|
||||
{
|
||||
await push.NotifyAllAsync(title, body, $"/hilfe/tickets?focus={entity.Id}");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Push ist best-effort — niemals die API-Antwort daran scheitern lassen.
|
||||
}
|
||||
}
|
||||
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
// SOFT-DELETE: das Ticket wird NICHT entfernt, sondern als gelöscht markiert
|
||||
// (DeletedAt = jetzt) und wandert in die „Gelöscht"-Kategorie. Wiederherstellbar
|
||||
// über POST /feedback/{id}/restore.
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
@@ -162,7 +265,88 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
db.Feedback.Remove(entity);
|
||||
entity.DeletedAt ??= DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// WIEDERHERSTELLEN: hebt das Soft-Delete auf (DeletedAt → null); das Ticket kehrt in
|
||||
// seinen vorherigen Status (Offen/Rückfrage/…) zurück.
|
||||
group.MapPost("/{id:guid}/restore", async Task<Results<Ok<FeedbackDto>, NotFound>> (
|
||||
Guid id, ApplicationContext db) =>
|
||||
{
|
||||
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (entity is null)
|
||||
return TypedResults.NotFound();
|
||||
|
||||
entity.DeletedAt = null;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(ToDto(entity));
|
||||
});
|
||||
|
||||
// ANHANG hochladen (base64). Bild/Datei zu einem Ticket. Größenlimit MaxAttachmentBytes.
|
||||
group.MapPost("/{id:guid}/attachments", async Task<Results<Created<FeedbackAttachmentDto>, NotFound, BadRequest<string>>> (
|
||||
Guid id, FeedbackAttachmentInput input, ApplicationContext db) =>
|
||||
{
|
||||
var ticket = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (ticket is null)
|
||||
return TypedResults.NotFound();
|
||||
if (string.IsNullOrWhiteSpace(input.DataBase64) || string.IsNullOrWhiteSpace(input.FileName))
|
||||
return TypedResults.BadRequest("FileName und Daten sind erforderlich.");
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
// erlaubt sowohl reines base64 als auch eine data:-URL
|
||||
var raw = input.DataBase64;
|
||||
var comma = raw.IndexOf(',');
|
||||
if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0)
|
||||
raw = raw[(comma + 1)..];
|
||||
bytes = Convert.FromBase64String(raw);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return TypedResults.BadRequest("Daten sind kein gültiges base64.");
|
||||
}
|
||||
if (bytes.Length == 0)
|
||||
return TypedResults.BadRequest("Datei ist leer.");
|
||||
if (bytes.Length > MaxAttachmentBytes)
|
||||
return TypedResults.BadRequest($"Datei zu groß (max. {MaxAttachmentBytes / (1024 * 1024)} MB).");
|
||||
|
||||
var att = new FeedbackAttachment
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FeedbackId = id,
|
||||
FileName = input.FileName.Trim(),
|
||||
ContentType = string.IsNullOrWhiteSpace(input.ContentType) ? "application/octet-stream" : input.ContentType.Trim(),
|
||||
Size = bytes.Length,
|
||||
Data = bytes,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
db.FeedbackAttachments.Add(att);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Created($"/feedback/attachments/{att.Id}",
|
||||
new FeedbackAttachmentDto(att.Id, att.FileName, att.ContentType, att.Size));
|
||||
});
|
||||
|
||||
// ANHANG-Bytes ausliefern (für <img>/Download).
|
||||
group.MapGet("/attachments/{attId:guid}", async Task<Results<FileContentHttpResult, NotFound>> (
|
||||
Guid attId, ApplicationContext db) =>
|
||||
{
|
||||
var att = await db.FeedbackAttachments.AsNoTracking().FirstOrDefaultAsync(a => a.Id == attId);
|
||||
if (att is null)
|
||||
return TypedResults.NotFound();
|
||||
return TypedResults.File(att.Data, att.ContentType, att.FileName);
|
||||
});
|
||||
|
||||
// ANHANG löschen.
|
||||
group.MapDelete("/attachments/{attId:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid attId, ApplicationContext db) =>
|
||||
{
|
||||
var att = await db.FeedbackAttachments.FirstOrDefaultAsync(a => a.Id == attId);
|
||||
if (att is null)
|
||||
return TypedResults.NotFound();
|
||||
db.FeedbackAttachments.Remove(att);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
@@ -172,6 +356,14 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
|
||||
private static readonly JsonSerializerOptions ThreadJson = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>Für Push-Texte: leeren Wert zu null, sonst auf ~140 Zeichen kürzen.</summary>
|
||||
private static string? Trim(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return null;
|
||||
var t = s.Trim();
|
||||
return t.Length > 140 ? t[..139] + "…" : t;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append the ticket's current (Question, Answer) exchange to the thread/history JSON
|
||||
/// before it gets overwritten by a new round, then clear the current Answer. Only the
|
||||
@@ -216,10 +408,11 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
}
|
||||
}
|
||||
|
||||
private static FeedbackDto ToDto(Feedback f) =>
|
||||
private static FeedbackDto ToDto(Feedback f, IReadOnlyList<FeedbackAttachmentDto>? attachments = null) =>
|
||||
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
||||
f.ClientTimestamp, f.UserAgent, f.CreatedAt, f.Status, f.ResolvedAt,
|
||||
f.Question, f.Answer, f.AnsweredAt, f.FixNote, f.AgentContext,
|
||||
DeserializeThread(f.Thread));
|
||||
DeserializeThread(f.Thread), f.ReopenedAt, f.DeletedAt, f.Category, f.Helpful,
|
||||
attachments ?? Array.Empty<FeedbackAttachmentDto>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Services;
|
||||
using Gridify;
|
||||
@@ -61,7 +62,9 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
if (string.IsNullOrWhiteSpace(input.Name))
|
||||
return TypedResults.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name! };
|
||||
// Manually created in the UI → IsManual=true, so the import re-ingest never wipes or
|
||||
// overwrites this animal (and its sub-records).
|
||||
var g = new Gerbil { Id = Guid.NewGuid(), Name = input.Name!, IsManual = true };
|
||||
Apply(g, input, isCreate: true);
|
||||
db.Gerbils.Add(g);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -73,11 +76,79 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
|
||||
// Capture the own-field values BEFORE the edit so manual changes to an IMPORTED animal
|
||||
// can be pinned per-field (survive the next ingest). Manual animals need no override —
|
||||
// they survive the ingest wholesale.
|
||||
var before = GerbilSnapshotService.BuildFreezeObject(g);
|
||||
Apply(g, input, isCreate: false);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (!g.IsManual)
|
||||
await RecordEditOverrideAsync(db, g, before);
|
||||
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
// PUT /gerbils/{id}/parents — QOL (Wunsch der Züchterin): Eltern DIREKT in der
|
||||
// Tier-Akte pflegen, ohne vorher den Wurf suchen zu müssen. Das Datenmodell bleibt
|
||||
// unverändert — Eltern hängen weiterhin am GEBURTSWURF (Litter.FatherId/MotherId),
|
||||
// dieser Endpoint schreibt nur genau dorthin:
|
||||
// • Tier hat einen Wurf → dessen Eltern werden gesetzt. Das gilt zwangsläufig für
|
||||
// ALLE Jungtiere des Wurfs; die UI warnt vorher mit SiblingCount.
|
||||
// • Tier hat keinen Wurf → gibt es einen Wurf mit genau diesem Elternpaar UND
|
||||
// demselben Datum wie das Geburtsdatum (= dieselbe Geburt), wird das Tier dort
|
||||
// eingehängt (LitterAttached). Sonst wird ein Wurf angelegt (LitterCreated) mit
|
||||
// ShowInChronicle=false (nicht in der Wurfchronik, nur zur Abstammung) und
|
||||
// IsManual=true (überlebt den Re-Ingest).
|
||||
group.MapPut("/{id:guid}/parents", async Task<Results<Ok<GerbilParentsResult>, NotFound, BadRequest<ParentGenderError>, BadRequest<string>>> (
|
||||
Guid id, GerbilParentsInput input, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == id);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
if (input.FatherId == id || input.MotherId == id)
|
||||
return TypedResults.BadRequest("Ein Tier kann nicht sein eigener Elternteil sein.");
|
||||
var genderErr = await LitterEndpoints.ValidateParents(db, input.FatherId, input.MotherId);
|
||||
if (genderErr is not null) return TypedResults.BadRequest(genderErr);
|
||||
|
||||
var litter = g.LitterId is Guid lid
|
||||
? await db.Litters.FirstOrDefaultAsync(l => l.Id == lid)
|
||||
: null;
|
||||
|
||||
// Nichts zu tun: kein Wurf und keine Eltern angegeben.
|
||||
if (litter is null && input.FatherId is null && input.MotherId is null)
|
||||
return TypedResults.Ok(new GerbilParentsResult(null, null, false, false, 0, null, null));
|
||||
|
||||
var created = false;
|
||||
var attached = false;
|
||||
if (litter is null)
|
||||
{
|
||||
// Gleiches Elternpaar + gleiches Datum ⇒ dieselbe Geburt: an den bestehenden
|
||||
// Wurf hängen (macht das Tier korrekt zum Geschwister), statt zu duplizieren.
|
||||
if (g.DateOfBirth is DateOnly dob)
|
||||
{
|
||||
litter = await db.Litters.FirstOrDefaultAsync(l =>
|
||||
l.FatherId == input.FatherId && l.MotherId == input.MotherId && l.Date == dob);
|
||||
attached = litter is not null;
|
||||
}
|
||||
litter ??= NewParentLitter(g, await NameOfAsync(db, input.FatherId), await NameOfAsync(db, input.MotherId));
|
||||
if (!attached)
|
||||
{
|
||||
db.Litters.Add(litter);
|
||||
created = true;
|
||||
}
|
||||
g.LitterId = litter.Id;
|
||||
}
|
||||
|
||||
litter.FatherId = input.FatherId;
|
||||
litter.MotherId = input.MotherId;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var siblingCount = await db.Gerbils.CountAsync(x => x.LitterId == litter.Id && x.Id != id);
|
||||
return TypedResults.Ok(new GerbilParentsResult(
|
||||
litter.Id, litter.Name, created, attached, siblingCount, litter.FatherId, litter.MotherId));
|
||||
});
|
||||
|
||||
// DELETE /gerbils/{id} (409 if referenced as a litter parent)
|
||||
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound, Conflict<string>>> (Guid id, ApplicationContext db) =>
|
||||
{
|
||||
@@ -98,6 +169,24 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<string?> NameOfAsync(ApplicationContext db, Guid? gerbilId) =>
|
||||
gerbilId is Guid gid
|
||||
? await db.Gerbils.AsNoTracking().Where(x => x.Id == gid).Select(x => x.Name).FirstOrDefaultAsync()
|
||||
: null;
|
||||
|
||||
/// <summary>Träger-Wurf für die Eltern eines Tiers ohne Geburtswurf. Namensschema wie die
|
||||
/// vom Import erzeugten Stammbaum-Würfe ("Wurf von X + Y"); ShowInChronicle=false, damit die
|
||||
/// Wurfchronik nicht mit Hilfs-Würfen zuwächst; IsManual=true, damit der Re-Ingest ihn nicht wegräumt.</summary>
|
||||
private static Litter NewParentLitter(Gerbil g, string? fatherName, string? motherName) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = $"Wurf von {fatherName ?? "—"} + {motherName ?? "—"}",
|
||||
Date = g.DateOfBirth,
|
||||
ShowInChronicle = false,
|
||||
IsManual = true,
|
||||
Notes = $"Automatisch angelegt, um die Eltern von {g.Name} zu tragen.",
|
||||
};
|
||||
|
||||
// CR-2 FIX: PATCH semantics — every omitted/null field keeps the stored value.
|
||||
// Prevents silent data loss when the frontend sends partial bodies (ForSale toggle,
|
||||
// Charakterbogen save, any partial updateGerbil call). On create, supply safe defaults
|
||||
@@ -133,11 +222,47 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
GerbilStatusService.Apply(g, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
}
|
||||
|
||||
// EDIT PROTECTION: after a manual edit to an IMPORTED animal, pin the change so the next
|
||||
// ingest can't overwrite it. A VERIFIED animal keeps its certification and its whole golden
|
||||
// is refreshed to the edited state (Frage 5). An unverified animal gets/updates a "geschützt"
|
||||
// override carrying ONLY the changed fields (per-field) — untouched fields keep receiving
|
||||
// import improvements. Called after the edit is already persisted.
|
||||
private static async Task RecordEditOverrideAsync(ApplicationContext db, Gerbil g, System.Text.Json.Nodes.JsonObject before)
|
||||
{
|
||||
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == g.Id);
|
||||
if (ov is { IsVerified: true })
|
||||
{
|
||||
ov.EntityName = g.Name;
|
||||
ov.OverrideJson = GerbilSnapshotService.BuildFreezeJson(g);
|
||||
var snap = await GerbilSnapshotService.BuildSnapshotAsync(db, g.Id);
|
||||
if (snap is not null) ov.SnapshotJson = GerbilSnapshotService.SerializeSnapshot(snap);
|
||||
ov.LastImportSnapshotJson = null;
|
||||
ov.LastImportDiffJson = null;
|
||||
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var after = GerbilSnapshotService.BuildFreezeObject(g);
|
||||
var changed = GerbilSnapshotService.ChangedFields(before, after);
|
||||
if (changed.Count == 0) return;
|
||||
|
||||
if (ov is null)
|
||||
{
|
||||
ov = new GerbilOverride { Id = Guid.NewGuid(), GerbilId = g.Id, IsVerified = false, OverrideJson = "{}" };
|
||||
db.GerbilOverrides.Add(ov);
|
||||
}
|
||||
ov.EntityName = g.Name;
|
||||
ov.OverrideJson = GerbilSnapshotService.MergeOverrideJson(ov.OverrideJson, changed);
|
||||
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
internal static GerbilDto ToDto(Gerbil g, string? profilePhotoUrl = null) => new(
|
||||
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.Provenance);
|
||||
g.Provenance, g.BirthOrder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,39 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return TypedResults.Ok(result);
|
||||
});
|
||||
|
||||
// Upload-Variante: lädt eine resolved_import.json (+ optional Fotos-ZIP) hoch, legt sie in
|
||||
// dem Verzeichnis ab, aus dem der Ingest liest, und ingested direkt. Damit lässt sich der
|
||||
// Import gegen PROD fahren, OHNE die Datei per SSH/docker cp in den Container zu kopieren
|
||||
// (der lokal generierte Payload geht per HTTP an die Prod-API). Abwärtskompatibel — der
|
||||
// bestehende /ingest-resolved-Endpoint bleibt unverändert.
|
||||
group.MapPost("/ingest-resolved/upload", async Task<Results<Ok<string>, BadRequest<string>>> (
|
||||
IFormFile resolved, IFormFile? photos,
|
||||
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
||||
{
|
||||
if (resolved.Length == 0)
|
||||
return TypedResults.BadRequest("resolved_import.json ist leer.");
|
||||
|
||||
var sourceDir = IngestResolvedService.ResolveSourceDir(config, env);
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
// resolved_import.json an den erwarteten Ort schreiben.
|
||||
var destPath = Path.Combine(sourceDir, "resolved_import.json");
|
||||
await using (var fs = File.Create(destPath))
|
||||
await resolved.CopyToAsync(fs);
|
||||
|
||||
// Optionale Fotos-ZIP in den Source-Dir entpacken (Einträge relativ), damit der
|
||||
// Foto-Kopierschritt (Path.Combine(sourceDir, relPath)) neue Fotos findet.
|
||||
if (photos is not null)
|
||||
{
|
||||
await using var zs = photos.OpenReadStream();
|
||||
using var seekable = await ToSeekable(zs);
|
||||
ExtractZipInto(seekable, sourceDir);
|
||||
}
|
||||
|
||||
var result = await new IngestResolvedService(db, config, env).RunAsync();
|
||||
return TypedResults.Ok(result);
|
||||
}).WithTags("Import").DisableAntiforgery();
|
||||
|
||||
// ── RPRO3 ──────────────────────────────────────────────────────────
|
||||
group.MapPost("/rpro3/analyze", async Task<Results<Ok<Rpro3AnalyzeResult>, BadRequest<string>>> (
|
||||
IFormFile backup, IFormFile? images,
|
||||
@@ -143,6 +176,25 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
return map;
|
||||
}
|
||||
|
||||
// Entpackt ein ZIP relativ in destDir (Einträge behalten ihren relativen Pfad). Schützt vor
|
||||
// Zip-Slip (Einträge, die aus destDir ausbrechen wollen) und überspringt Verzeichnis-Einträge.
|
||||
private static void ExtractZipInto(Stream zip, string destDir)
|
||||
{
|
||||
var root = Path.GetFullPath(destDir);
|
||||
using var archive = new ZipArchive(zip, ZipArchiveMode.Read, leaveOpen: true);
|
||||
foreach (var e in archive.Entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(e.Name)) continue; // Verzeichnis-Eintrag
|
||||
var target = Path.GetFullPath(Path.Combine(root, e.FullName));
|
||||
if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
||||
&& !string.Equals(target, root, StringComparison.Ordinal))
|
||||
continue; // Zip-Slip abwehren
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
try { e.ExtractToFile(target, overwrite: true); }
|
||||
catch { /* defektes Archiv-Entry überspringen */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDeleteDir(string dir)
|
||||
{
|
||||
try { if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Common;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Gridify;
|
||||
@@ -30,7 +30,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
var mortalityErr = ValidateMortality(input.DeathsWithin8Weeks, input.TotalBorn);
|
||||
if (mortalityErr is not null) return TypedResults.BadRequest(mortalityErr);
|
||||
|
||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date };
|
||||
var l = new Litter { Id = Guid.NewGuid(), Name = input.Name, Date = input.Date, IsManual = true };
|
||||
Apply(l, input);
|
||||
db.Litters.Add(l);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -65,7 +65,9 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
}
|
||||
|
||||
// father must not be female; mother must not be male (unknown is allowed).
|
||||
private static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
|
||||
// internal: auch von PUT /gerbils/{id}/parents genutzt (QOL-Eltern-Editor), damit die
|
||||
// Geschlechts-Regel für Eltern an genau EINER Stelle lebt.
|
||||
internal static async Task<ParentGenderError?> ValidateParents(ApplicationContext db, Guid? fatherId, Guid? motherId)
|
||||
{
|
||||
var father = fatherId is Guid f ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == f) : null;
|
||||
var mother = motherId is Guid m ? await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == m) : null;
|
||||
@@ -81,10 +83,15 @@ namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
l.TotalBorn = i.TotalBorn;
|
||||
l.DeathsWithin8Weeks = i.DeathsWithin8Weeks;
|
||||
l.Stillborn = i.Stillborn;
|
||||
l.FatherId = i.FatherId;
|
||||
l.MotherId = i.MotherId;
|
||||
l.ExpectedGoHomeDate = i.ExpectedGoHomeDate;
|
||||
l.Notes = i.Notes;
|
||||
// Nur überschreiben, wenn explizit gesetzt — sonst bestehenden Wert / Modell-Default
|
||||
// (true) behalten, damit ein Edit ohne dieses Feld einen versteckten Wurf nicht
|
||||
// versehentlich wieder in die Wurfchronik holt.
|
||||
if (i.ShowInChronicle is bool show) l.ShowInChronicle = show;
|
||||
}
|
||||
|
||||
private static string? ValidateMortality(int? deaths, int? totalBorn)
|
||||
@@ -97,8 +104,8 @@ 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.Provenance);
|
||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks, l.Stillborn,
|
||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance, l.ShowInChronicle);
|
||||
}
|
||||
|
||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||
|
||||
74
GerbilManagerWebAPI/Endpoints/PushEndpoints.cs
Normal file
74
GerbilManagerWebAPI/Endpoints/PushEndpoints.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using GerbilManagerWebAPI.Push;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// WEB-PUSH: PWA-Benachrichtigungen für die Züchterin.
|
||||
/// GET /push/vapid-public-key -> öffentlicher VAPID-Schlüssel (für das Abonnieren im Browser).
|
||||
/// POST /push/subscribe -> Browser-Abo speichern (idempotent über die Endpoint-URL).
|
||||
/// POST /push/unsubscribe -> Abo entfernen.
|
||||
/// Das eigentliche Senden passiert über <see cref="PushNotifier"/> (z. B. wenn die KI eine
|
||||
/// Rückfrage stellt oder ein Ticket löst — gesteuert über das notify-Flag auf PUT /feedback).
|
||||
/// </summary>
|
||||
public static class PushEndpoints
|
||||
{
|
||||
public record PushSubscriptionInput(string Endpoint, string P256dh, string Auth);
|
||||
public record PushUnsubscribeInput(string Endpoint);
|
||||
|
||||
public static IEndpointRouteBuilder MapPushEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/push").WithTags("Push");
|
||||
|
||||
group.MapGet("/vapid-public-key", (PushNotifier push) =>
|
||||
TypedResults.Ok(new { publicKey = push.PublicKey, enabled = push.Enabled }));
|
||||
|
||||
group.MapPost("/subscribe", async Task<Results<Ok, BadRequest<string>>> (
|
||||
PushSubscriptionInput input, ApplicationContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Endpoint)
|
||||
|| string.IsNullOrWhiteSpace(input.P256dh)
|
||||
|| string.IsNullOrWhiteSpace(input.Auth))
|
||||
return TypedResults.BadRequest("Endpoint, P256dh und Auth sind erforderlich.");
|
||||
|
||||
var existing = await db.WebPushSubscriptions
|
||||
.FirstOrDefaultAsync(s => s.Endpoint == input.Endpoint);
|
||||
if (existing is null)
|
||||
{
|
||||
db.WebPushSubscriptions.Add(new WebPushSubscription
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Endpoint = input.Endpoint,
|
||||
P256dh = input.P256dh,
|
||||
Auth = input.Auth,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.P256dh = input.P256dh;
|
||||
existing.Auth = input.Auth;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok();
|
||||
});
|
||||
|
||||
group.MapPost("/unsubscribe", async (PushUnsubscribeInput input, ApplicationContext db) =>
|
||||
{
|
||||
var subs = await db.WebPushSubscriptions
|
||||
.Where(s => s.Endpoint == input.Endpoint)
|
||||
.ToListAsync();
|
||||
if (subs.Count > 0)
|
||||
{
|
||||
db.WebPushSubscriptions.RemoveRange(subs);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
136
GerbilManagerWebAPI/Endpoints/VerifiedGerbilEndpoints.cs
Normal file
136
GerbilManagerWebAPI/Endpoints/VerifiedGerbilEndpoints.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Dtos;
|
||||
using GerbilManagerWebAPI.Import;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// VERIFIED / PROTECTED OVERRIDES — the breeder's hand-curated "this animal is correct" layer.
|
||||
/// POST /verified-gerbils/{gerbilId} -> mark "vollständig korrekt": snapshot the full Akte,
|
||||
/// freeze all own fields, apply the freeze immediately. Toggle-on. Optional {note}.
|
||||
/// DELETE /verified-gerbils/{gerbilId} -> remove the override (un-flag / release protection).
|
||||
/// Non-destructive: current values stay; the next ingest makes the animal import-driven again.
|
||||
/// GET /verified-gerbils -> all overrides (verified + protected) with drift status.
|
||||
/// GET /verified-gerbils/{gerbilId} -> single status (badge on the Rennmausakte).
|
||||
/// GET /verified-gerbils/export -> canonical golden dump for the committed regression fixture.
|
||||
/// Overrides are decoupled from gerbils (loose GerbilId, no FK), so they survive the import
|
||||
/// re-ingest wipe and are re-applied on top of the fresh import (the "freeze").
|
||||
/// </summary>
|
||||
public static class VerifiedGerbilEndpoints
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static IEndpointRouteBuilder MapVerifiedGerbilEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/verified-gerbils").WithTags("VerifiedGerbils");
|
||||
|
||||
group.MapPost("/{gerbilId:guid}", async Task<Results<Ok<VerifiedGerbilDto>, NotFound>> (
|
||||
Guid gerbilId, VerifyGerbilInput? input, ApplicationContext db) =>
|
||||
{
|
||||
var g = await db.Gerbils.FirstOrDefaultAsync(x => x.Id == gerbilId);
|
||||
if (g is null) return TypedResults.NotFound();
|
||||
|
||||
var snapshot = await GerbilSnapshotService.BuildSnapshotAsync(db, gerbilId);
|
||||
|
||||
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == gerbilId);
|
||||
if (ov is null)
|
||||
{
|
||||
ov = new GerbilOverride { Id = Guid.NewGuid(), GerbilId = gerbilId, OverrideJson = "{}" };
|
||||
db.GerbilOverrides.Add(ov);
|
||||
}
|
||||
ov.EntityName = g.Name;
|
||||
ov.IsVerified = true;
|
||||
ov.OverrideJson = GerbilSnapshotService.BuildFreezeJson(g);
|
||||
ov.SnapshotJson = snapshot is null ? null : GerbilSnapshotService.SerializeSnapshot(snapshot);
|
||||
// Reset the drift cache — recomputed on the next ingest against the fresh import.
|
||||
ov.LastImportSnapshotJson = null;
|
||||
ov.LastImportDiffJson = null;
|
||||
ov.Note = string.IsNullOrWhiteSpace(input?.Note) ? ov.Note : input!.Note!.Trim();
|
||||
ov.VerifiedAt ??= DateTimeOffset.UtcNow;
|
||||
ov.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.Ok(await ToDtoAsync(db, ov));
|
||||
});
|
||||
|
||||
group.MapDelete("/{gerbilId:guid}", async Task<Results<NoContent, NotFound>> (
|
||||
Guid gerbilId, ApplicationContext db) =>
|
||||
{
|
||||
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == gerbilId);
|
||||
if (ov is null) return TypedResults.NotFound();
|
||||
db.GerbilOverrides.Remove(ov);
|
||||
await db.SaveChangesAsync();
|
||||
return TypedResults.NoContent();
|
||||
});
|
||||
|
||||
group.MapGet("/", async (ApplicationContext db) =>
|
||||
{
|
||||
var rows = await db.GerbilOverrides.ToListAsync();
|
||||
var existingIds = await db.Gerbils.Select(g => g.Id).ToHashSetAsync();
|
||||
var list = new List<VerifiedGerbilDto>();
|
||||
foreach (var ov in rows.OrderByDescending(o => o.UpdatedAt))
|
||||
list.Add(await ToDtoAsync(db, ov, existingIds));
|
||||
return TypedResults.Ok(list);
|
||||
});
|
||||
|
||||
group.MapGet("/export", async (ApplicationContext db) =>
|
||||
{
|
||||
var rows = await db.GerbilOverrides.Where(o => o.IsVerified).ToListAsync();
|
||||
var export = rows
|
||||
.OrderBy(o => o.EntityName, StringComparer.Ordinal)
|
||||
.ThenBy(o => o.GerbilId)
|
||||
.Select(o => new VerifiedGoldenEntry(
|
||||
o.GerbilId, o.EntityName, o.VerifiedAt,
|
||||
GerbilSnapshotService.DeserializeSnapshot(o.SnapshotJson)))
|
||||
.ToList();
|
||||
return TypedResults.Ok(export);
|
||||
});
|
||||
|
||||
group.MapGet("/{gerbilId:guid}", async Task<Results<Ok<VerifiedGerbilDto>, NotFound>> (
|
||||
Guid gerbilId, ApplicationContext db) =>
|
||||
{
|
||||
var ov = await db.GerbilOverrides.FirstOrDefaultAsync(o => o.GerbilId == gerbilId);
|
||||
if (ov is null) return TypedResults.NotFound();
|
||||
return TypedResults.Ok(await ToDtoAsync(db, ov));
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<VerifiedGerbilDto> ToDtoAsync(
|
||||
ApplicationContext db, GerbilOverride ov, HashSet<Guid>? existingIds = null)
|
||||
{
|
||||
var exists = existingIds?.Contains(ov.GerbilId) ?? await db.Gerbils.AnyAsync(g => g.Id == ov.GerbilId);
|
||||
var diffs = DeserializeDiff(ov.LastImportDiffJson);
|
||||
var status = !exists ? "missing" : (diffs.Count > 0 ? "drifted" : "unchanged");
|
||||
return new VerifiedGerbilDto(
|
||||
ov.GerbilId, ov.EntityName, ov.IsVerified, status, ov.VerifiedAt, ov.UpdatedAt, ov.Note,
|
||||
ExtractFieldKeys(ov.OverrideJson), diffs);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SnapshotDiffDto> DeserializeDiff(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<SnapshotDiffDto>();
|
||||
try
|
||||
{
|
||||
var raw = JsonSerializer.Deserialize<List<SnapshotDiff>>(json, Json) ?? new();
|
||||
return raw.Select(d => new SnapshotDiffDto(d.Path, d.GoldenValue, d.OtherValue)).ToList();
|
||||
}
|
||||
catch (JsonException) { return Array.Empty<SnapshotDiffDto>(); }
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractFieldKeys(string? overrideJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(overrideJson)) return Array.Empty<string>();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(overrideJson);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return Array.Empty<string>();
|
||||
return doc.RootElement.EnumerateObject().Select(p => p.Name).ToList();
|
||||
}
|
||||
catch (JsonException) { return Array.Empty<string>(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
|
||||
<PackageReference Include="QuestPDF" Version="2026.6.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||
<PackageReference Include="WebPush" Version="1.0.13" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -35,6 +36,12 @@
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Manuelle RPRO3-Dubletten-Entscheidungen der Züchterin (Rpro3Decisions) — ins Output kopieren,
|
||||
damit der Importer sie zur Laufzeit findet. -->
|
||||
<Content Update="Import\Rpro3\rpro3-decisions.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||
|
||||
307
GerbilManagerWebAPI/Import/GerbilSnapshotService.cs
Normal file
307
GerbilManagerWebAPI/Import/GerbilSnapshotService.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import
|
||||
{
|
||||
// ── Canonical, human-readable "Akte" snapshot ────────────────────────────
|
||||
// Names (not ids) are stored so the golden state stays human-verifiable and robust to
|
||||
// deterministic-id reseeds. Used for the drift report, the export fixture and the
|
||||
// regression test. Lists are sorted canonically by the builder so equality is stable.
|
||||
|
||||
public sealed record GerbilSnapshot(
|
||||
SnapshotSelf Self,
|
||||
SnapshotParent? Father,
|
||||
SnapshotParent? Mother,
|
||||
List<SnapshotLitter> Litters);
|
||||
|
||||
public sealed record SnapshotSelf(
|
||||
string Name, string Gender, string? DateOfBirth, string? DateOfDeath, string? CauseOfDeath,
|
||||
string? GoHomeDate, string? Genotype, string? SpottingType, string? ColorName,
|
||||
bool IsResident, bool? IsDeaf, bool IsCastrated, string? OriginBreeder,
|
||||
string? OriginContactName, string? ReceiverContactName, string? Notes,
|
||||
List<string> CharacterTraits, string? CharacterNote, string? BirthLitterName);
|
||||
|
||||
public sealed record SnapshotParent(string Name, string? DateOfBirth, string? Genotype);
|
||||
|
||||
public sealed record SnapshotLitter(
|
||||
string Name, string? Date, string Role, int? TotalBorn, bool ShowInChronicle,
|
||||
List<SnapshotChild> Children);
|
||||
|
||||
public sealed record SnapshotChild(string Name, string? DateOfBirth, string Gender);
|
||||
|
||||
/// <summary>One field-level difference between two snapshots (golden ↔ current/raw import).</summary>
|
||||
public sealed record SnapshotDiff(string Path, string? GoldenValue, string? OtherValue);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the canonical Akte snapshot for a gerbil, the machine-readable freeze map (own fields
|
||||
/// that "vollständig korrekt" pins), applies an override onto a gerbil, and diffs two snapshots.
|
||||
/// SHARED by the app (endpoints + ingest freeze) and the regression test so the comparison logic
|
||||
/// can never drift between the two.
|
||||
/// </summary>
|
||||
public static class GerbilSnapshotService
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>Own-field keys that the "vollständig korrekt" freeze pins. Deliberately excludes
|
||||
/// Status (lifecycle, derived) and EnclosureId (current location) and LitterId/lineage
|
||||
/// (abstammung is detect-only). Keys match the JSON serialised in <see cref="BuildFreezeObject"/>.</summary>
|
||||
public static readonly IReadOnlyList<string> FreezeFields = new[]
|
||||
{
|
||||
"name", "gender", "dateOfBirth", "dateOfDeath", "causeOfDeath", "goHomeDate",
|
||||
"genotype", "spottingType", "colorVarietyId", "isResident", "isDeaf", "isCastrated",
|
||||
"originBreeder", "originContactId", "receiverContactId", "notes",
|
||||
"characterTraits", "characterNote",
|
||||
};
|
||||
|
||||
// ── Snapshot (human-readable Akte) ───────────────────────────────────
|
||||
|
||||
public static async Task<GerbilSnapshot?> BuildSnapshotAsync(ApplicationContext db, Guid gerbilId)
|
||||
{
|
||||
var g = await db.Gerbils.AsNoTracking().FirstOrDefaultAsync(x => x.Id == gerbilId);
|
||||
if (g is null) return null;
|
||||
|
||||
var colorName = g.ColorVarietyId is { } cv
|
||||
? await db.ColorVarieties.AsNoTracking().Where(c => c.Id == cv).Select(c => c.Name).FirstOrDefaultAsync()
|
||||
: null;
|
||||
var originName = g.OriginContactId is { } oc
|
||||
? await db.Contacts.AsNoTracking().Where(c => c.Id == oc).Select(c => c.Name).FirstOrDefaultAsync()
|
||||
: null;
|
||||
var receiverName = g.ReceiverContactId is { } rc
|
||||
? await db.Contacts.AsNoTracking().Where(c => c.Id == rc).Select(c => c.Name).FirstOrDefaultAsync()
|
||||
: null;
|
||||
|
||||
// Birth litter → parents.
|
||||
Litter? birthLitter = g.LitterId is { } lid
|
||||
? await db.Litters.AsNoTracking().FirstOrDefaultAsync(l => l.Id == lid)
|
||||
: null;
|
||||
var father = await BuildParentAsync(db, birthLitter?.FatherId);
|
||||
var mother = await BuildParentAsync(db, birthLitter?.MotherId);
|
||||
|
||||
// Own litters (as parent) + children.
|
||||
var ownLitters = await db.Litters.AsNoTracking()
|
||||
.Where(l => l.FatherId == gerbilId || l.MotherId == gerbilId)
|
||||
.ToListAsync();
|
||||
var ownLitterIds = ownLitters.Select(l => l.Id).ToList();
|
||||
var children = ownLitterIds.Count == 0
|
||||
? new List<Gerbil>()
|
||||
: await db.Gerbils.AsNoTracking().Where(c => c.LitterId != null && ownLitterIds.Contains(c.LitterId.Value)).ToListAsync();
|
||||
|
||||
var litterSnaps = ownLitters
|
||||
.Select(l => new SnapshotLitter(
|
||||
l.Name,
|
||||
Iso(l.Date),
|
||||
l.FatherId == gerbilId ? "father" : "mother",
|
||||
l.TotalBorn,
|
||||
l.ShowInChronicle,
|
||||
children.Where(c => c.LitterId == l.Id)
|
||||
.Select(c => new SnapshotChild(c.Name, Iso(c.DateOfBirth), c.Gender.ToString()))
|
||||
.OrderBy(c => c.DateOfBirth ?? "", StringComparer.Ordinal)
|
||||
.ThenBy(c => c.Name, StringComparer.Ordinal)
|
||||
.ThenBy(c => c.Gender, StringComparer.Ordinal)
|
||||
.ToList()))
|
||||
.OrderBy(l => l.Date ?? "", StringComparer.Ordinal)
|
||||
.ThenBy(l => l.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var self = new SnapshotSelf(
|
||||
g.Name, g.Gender.ToString(), Iso(g.DateOfBirth), Iso(g.DateOfDeath), g.CauseOfDeath,
|
||||
Iso(g.GoHomeDate), g.Genotype, g.SpottingType, colorName,
|
||||
g.IsResident, g.IsDeaf, g.IsCastrated, g.OriginBreeder,
|
||||
originName, receiverName, g.Notes,
|
||||
(g.CharacterTraits ?? new List<string>()).OrderBy(t => t, StringComparer.Ordinal).ToList(),
|
||||
g.CharacterNote, birthLitter?.Name);
|
||||
|
||||
return new GerbilSnapshot(self, father, mother, litterSnaps);
|
||||
}
|
||||
|
||||
private static async Task<SnapshotParent?> BuildParentAsync(ApplicationContext db, Guid? parentId)
|
||||
{
|
||||
if (parentId is not { } pid) return null;
|
||||
var p = await db.Gerbils.AsNoTracking().Where(x => x.Id == pid)
|
||||
.Select(x => new { x.Name, x.DateOfBirth, x.Genotype }).FirstOrDefaultAsync();
|
||||
return p is null ? null : new SnapshotParent(p.Name, Iso(p.DateOfBirth), p.Genotype);
|
||||
}
|
||||
|
||||
public static string SerializeSnapshot(GerbilSnapshot snap) => JsonSerializer.Serialize(snap, Json);
|
||||
|
||||
public static GerbilSnapshot? DeserializeSnapshot(string? json) =>
|
||||
string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<GerbilSnapshot>(json, Json);
|
||||
|
||||
// ── Diff (golden ↔ other) ────────────────────────────────────────────
|
||||
|
||||
/// <summary>Flat, human-readable field diff between a golden snapshot and another (current
|
||||
/// DB or raw import) snapshot. Empty = identical.</summary>
|
||||
public static List<SnapshotDiff> Diff(GerbilSnapshot? golden, GerbilSnapshot? other)
|
||||
{
|
||||
var g = golden is null ? new Dictionary<string, string?>() : Flatten(golden);
|
||||
var o = other is null ? new Dictionary<string, string?>() : Flatten(other);
|
||||
var diffs = new List<SnapshotDiff>();
|
||||
foreach (var key in g.Keys.Union(o.Keys).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var gv = g.GetValueOrDefault(key);
|
||||
var ov = o.GetValueOrDefault(key);
|
||||
if (!string.Equals(gv, ov, StringComparison.Ordinal))
|
||||
diffs.Add(new SnapshotDiff(key, gv, ov));
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string?> Flatten(GerbilSnapshot s)
|
||||
{
|
||||
var d = new Dictionary<string, string?>(StringComparer.Ordinal)
|
||||
{
|
||||
["name"] = s.Self.Name,
|
||||
["gender"] = s.Self.Gender,
|
||||
["dateOfBirth"] = s.Self.DateOfBirth,
|
||||
["dateOfDeath"] = s.Self.DateOfDeath,
|
||||
["causeOfDeath"] = s.Self.CauseOfDeath,
|
||||
["goHomeDate"] = s.Self.GoHomeDate,
|
||||
["genotype"] = s.Self.Genotype,
|
||||
["spottingType"] = s.Self.SpottingType,
|
||||
["colorName"] = s.Self.ColorName,
|
||||
["isResident"] = s.Self.IsResident.ToString(),
|
||||
["isDeaf"] = s.Self.IsDeaf?.ToString(),
|
||||
["isCastrated"] = s.Self.IsCastrated.ToString(),
|
||||
["originBreeder"] = s.Self.OriginBreeder,
|
||||
["originContact"] = s.Self.OriginContactName,
|
||||
["receiverContact"] = s.Self.ReceiverContactName,
|
||||
["notes"] = s.Self.Notes,
|
||||
["characterTraits"] = string.Join(", ", s.Self.CharacterTraits),
|
||||
["characterNote"] = s.Self.CharacterNote,
|
||||
["birthLitter"] = s.Self.BirthLitterName,
|
||||
["father"] = FormatParent(s.Father),
|
||||
["mother"] = FormatParent(s.Mother),
|
||||
};
|
||||
foreach (var l in s.Litters)
|
||||
{
|
||||
var lk = $"wurf[{l.Name} {l.Date}]";
|
||||
d[$"{lk}.rolle"] = l.Role;
|
||||
d[$"{lk}.geboren"] = l.TotalBorn?.ToString();
|
||||
d[$"{lk}.inChronik"] = l.ShowInChronicle.ToString();
|
||||
foreach (var c in l.Children)
|
||||
d[$"{lk}.kind[{c.Name} {c.DateOfBirth}]"] = c.Gender;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
private static string? FormatParent(SnapshotParent? p) =>
|
||||
p is null ? null : $"{p.Name} (*{p.DateOfBirth}) {p.Genotype}".Trim();
|
||||
|
||||
// ── Freeze map (machine-readable own-field override) ─────────────────
|
||||
|
||||
/// <summary>Builds the full own-field freeze object (all <see cref="FreezeFields"/>) from a
|
||||
/// gerbil's current values. Used by verify (store as OverrideJson) and by the per-field
|
||||
/// edit-protection diff.</summary>
|
||||
public static JsonObject BuildFreezeObject(Gerbil g)
|
||||
{
|
||||
var o = new JsonObject
|
||||
{
|
||||
["name"] = g.Name,
|
||||
["gender"] = g.Gender.ToString(),
|
||||
["dateOfBirth"] = Iso(g.DateOfBirth),
|
||||
["dateOfDeath"] = Iso(g.DateOfDeath),
|
||||
["causeOfDeath"] = g.CauseOfDeath,
|
||||
["goHomeDate"] = Iso(g.GoHomeDate),
|
||||
["genotype"] = g.Genotype,
|
||||
["spottingType"] = g.SpottingType,
|
||||
["colorVarietyId"] = g.ColorVarietyId?.ToString(),
|
||||
["isResident"] = g.IsResident,
|
||||
["isDeaf"] = g.IsDeaf,
|
||||
["isCastrated"] = g.IsCastrated,
|
||||
["originBreeder"] = g.OriginBreeder,
|
||||
["originContactId"] = g.OriginContactId?.ToString(),
|
||||
["receiverContactId"] = g.ReceiverContactId?.ToString(),
|
||||
["notes"] = g.Notes,
|
||||
["characterTraits"] = new JsonArray((g.CharacterTraits ?? new List<string>()).Select(t => (JsonNode?)JsonValue.Create(t)).ToArray()),
|
||||
["characterNote"] = g.CharacterNote,
|
||||
};
|
||||
return o;
|
||||
}
|
||||
|
||||
public static string BuildFreezeJson(Gerbil g) => BuildFreezeObject(g).ToJsonString(Json);
|
||||
|
||||
/// <summary>Per-field change detection for the PUT edit-protection path. Returns the subset of
|
||||
/// freeze keys whose value differs between <paramref name="before"/> and <paramref name="after"/>,
|
||||
/// as a JsonObject carrying the AFTER values (the ones to pin). Empty if nothing relevant changed.</summary>
|
||||
public static JsonObject ChangedFields(JsonObject before, JsonObject after)
|
||||
{
|
||||
var changed = new JsonObject();
|
||||
foreach (var key in FreezeFields)
|
||||
{
|
||||
var b = before[key]?.ToJsonString() ?? "null";
|
||||
var a = after[key]?.ToJsonString() ?? "null";
|
||||
if (!string.Equals(b, a, StringComparison.Ordinal))
|
||||
changed[key] = after[key] is { } node ? node.DeepClone() : null;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/// <summary>Merge <paramref name="incoming"/> field values into an existing override JSON
|
||||
/// object (incoming wins). Returns the merged JSON string.</summary>
|
||||
public static string MergeOverrideJson(string? existingJson, JsonObject incoming)
|
||||
{
|
||||
var baseObj = ParseObject(existingJson) ?? new JsonObject();
|
||||
foreach (var kv in incoming)
|
||||
baseObj[kv.Key] = kv.Value is { } n ? n.DeepClone() : null;
|
||||
return baseObj.ToJsonString(Json);
|
||||
}
|
||||
|
||||
/// <summary>Applies a stored override JSON object onto a gerbil, writing only the keys present
|
||||
/// (the "freeze"). Unknown keys are ignored.</summary>
|
||||
public static void ApplyOverride(Gerbil g, string? overrideJson)
|
||||
{
|
||||
var o = ParseObject(overrideJson);
|
||||
if (o is null) return;
|
||||
foreach (var kv in o)
|
||||
{
|
||||
var v = kv.Value;
|
||||
switch (kv.Key)
|
||||
{
|
||||
case "name": if (Str(v) is { Length: > 0 } nm) g.Name = nm; break;
|
||||
case "gender": if (Str(v) is { } gd && Enum.TryParse<Gender>(gd, out var gender)) g.Gender = gender; break;
|
||||
case "dateOfBirth": g.DateOfBirth = Date(v); break;
|
||||
case "dateOfDeath": g.DateOfDeath = Date(v); break;
|
||||
case "causeOfDeath": g.CauseOfDeath = Str(v); break;
|
||||
case "goHomeDate": g.GoHomeDate = Date(v); break;
|
||||
case "genotype": g.Genotype = Str(v); break;
|
||||
case "spottingType": g.SpottingType = Str(v); break;
|
||||
case "colorVarietyId": g.ColorVarietyId = Guid_(v); break;
|
||||
case "isResident": g.IsResident = Bool(v) ?? g.IsResident; break;
|
||||
case "isDeaf": g.IsDeaf = Bool(v); break;
|
||||
case "isCastrated": g.IsCastrated = Bool(v) ?? g.IsCastrated; break;
|
||||
case "originBreeder": g.OriginBreeder = Str(v); break;
|
||||
case "originContactId": g.OriginContactId = Guid_(v); break;
|
||||
case "receiverContactId": g.ReceiverContactId = Guid_(v); break;
|
||||
case "notes": g.Notes = Str(v); break;
|
||||
case "characterTraits": g.CharacterTraits = StrList(v); break;
|
||||
case "characterNote": g.CharacterNote = Str(v); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private static JsonObject? ParseObject(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return JsonNode.Parse(json) as JsonObject; }
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
private static string? Str(JsonNode? n) => n?.GetValueKind() == JsonValueKind.String ? n.GetValue<string>() : null;
|
||||
private static bool? Bool(JsonNode? n) => n?.GetValueKind() is JsonValueKind.True or JsonValueKind.False ? n!.GetValue<bool>() : null;
|
||||
private static Guid? Guid_(JsonNode? n) => Guid.TryParse(Str(n), out var id) ? id : null;
|
||||
private static DateOnly? Date(JsonNode? n) => DateOnly.TryParse(Str(n), out var d) ? d : null;
|
||||
|
||||
private static List<string> StrList(JsonNode? n)
|
||||
{
|
||||
if (n is JsonArray arr)
|
||||
return arr.Where(x => x is not null).Select(x => x!.GetValue<string>()).ToList();
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
private static string? Iso(DateOnly? d) => d?.ToString("yyyy-MM-dd");
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,28 @@ namespace GerbilManagerWebAPI.Import
|
||||
private readonly string _sourceDir;
|
||||
private readonly string _photoRoot;
|
||||
|
||||
private static readonly JsonSerializerOptions DiffJson = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public IngestResolvedService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
_db = db;
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
_sourceDir = config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
||||
_sourceDir = ResolveSourceDir(config, env);
|
||||
_resolvedJsonPath = Path.Combine(_sourceDir, "resolved_import.json");
|
||||
_photoRoot = config["Photos:RootPath"]
|
||||
?? Path.Combine(contentRoot, "photo-storage");
|
||||
?? Path.Combine(env?.ContentRootPath ?? Directory.GetCurrentDirectory(), "photo-storage");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verzeichnis, in dem der Ingest <c>resolved_import.json</c> (+ referenzierte Fotos) erwartet.
|
||||
/// Öffentlich, damit der Upload-Endpoint die hochgeladene Datei an genau denselben Ort stagen kann
|
||||
/// (siehe <c>POST /import/ingest-resolved/upload</c>) — so lässt sich der Ingest gegen Prod fahren,
|
||||
/// ohne die Datei per SSH/docker cp in den Container zu kopieren.
|
||||
/// </summary>
|
||||
public static string ResolveSourceDir(IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
return config["Import:SourcePath"]
|
||||
?? Path.GetFullPath(Path.Combine(contentRoot, "..", "tools", "import", "output"));
|
||||
}
|
||||
|
||||
public async Task<string> RunAsync()
|
||||
@@ -32,11 +45,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
}
|
||||
|
||||
string jsonContent = await File.ReadAllTextAsync(_resolvedJsonPath);
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
jsonOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
jsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
var data = JsonSerializer.Deserialize<ResolvedImportData>(jsonContent, jsonOptions);
|
||||
|
||||
if (data == null)
|
||||
@@ -44,163 +54,191 @@ namespace GerbilManagerWebAPI.Import
|
||||
return "Error: Failed to deserialize resolved_import.json.";
|
||||
}
|
||||
|
||||
// 1. Provider-agnostic clean wipe of dependent tables to prevent duplicate keys
|
||||
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
|
||||
{
|
||||
// Break circular references first
|
||||
foreach (var l in _db.Litters)
|
||||
{
|
||||
l.FatherId = null;
|
||||
l.MotherId = null;
|
||||
}
|
||||
foreach (var g in _db.Gerbils)
|
||||
{
|
||||
g.LitterId = null;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
// ── UPSERT-based ingest (no total wipe) ──────────────────────────────────────
|
||||
// The ingest NO LONGER wipes everything. Imported rows are matched by their
|
||||
// (deterministic) id and updated in place; manually-created rows (IsManual=true) and
|
||||
// user-entered sub-records (weights/health/photos) are never deleted, so the breeder's
|
||||
// work survives the re-import. Only STALE imported rows (IsManual=false, absent from the
|
||||
// new payload) are removed — after nulling any manual references to them (with a warning).
|
||||
// Finally, hand-curated GerbilOverrides are re-applied on top (the "freeze").
|
||||
var payloadContactIds = data.Contacts.Select(c => c.Id).ToHashSet();
|
||||
var payloadLitterIds = data.Litters.Select(l => l.Id).ToHashSet();
|
||||
var payloadGerbilIds = data.Gerbils.Select(g => g.Id).ToHashSet();
|
||||
var warnings = new List<string>();
|
||||
|
||||
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
|
||||
_db.WeightRecords.RemoveRange(_db.WeightRecords);
|
||||
_db.HealthRecords.RemoveRange(_db.HealthRecords);
|
||||
_db.SaleContracts.RemoveRange(_db.SaleContracts);
|
||||
_db.Gerbils.RemoveRange(_db.Gerbils);
|
||||
_db.Litters.RemoveRange(_db.Litters);
|
||||
_db.Contacts.RemoveRange(_db.Contacts);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Break circular references first using ExecuteUpdateAsync
|
||||
await _db.Litters.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(l => l.FatherId, (Guid?)null)
|
||||
.SetProperty(l => l.MotherId, (Guid?)null));
|
||||
|
||||
await _db.Gerbils.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(g => g.LitterId, (Guid?)null));
|
||||
|
||||
await _db.GerbilPhotos.ExecuteDeleteAsync();
|
||||
await _db.WeightRecords.ExecuteDeleteAsync();
|
||||
await _db.HealthRecords.ExecuteDeleteAsync();
|
||||
await _db.SaleContracts.ExecuteDeleteAsync();
|
||||
await _db.Gerbils.ExecuteDeleteAsync();
|
||||
await _db.Litters.ExecuteDeleteAsync();
|
||||
await _db.Contacts.ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
// 2. Import Contacts (Add new ones, and update roles/details of existing ones)
|
||||
// 1. Upsert contacts (preserve manual rows + their IsManual flag).
|
||||
var existingContacts = await _db.Contacts.ToDictionaryAsync(c => c.Id);
|
||||
var addedContactIds = new HashSet<Guid>();
|
||||
int contactsAdded = 0;
|
||||
int contactsUpdated = 0;
|
||||
int contactsAdded = 0, contactsUpdated = 0;
|
||||
foreach (var c in data.Contacts)
|
||||
{
|
||||
if (existingContacts.TryGetValue(c.Id, out var existingContact))
|
||||
if (existingContacts.TryGetValue(c.Id, out var ec))
|
||||
{
|
||||
existingContact.Name = c.Name;
|
||||
existingContact.Email = c.Email;
|
||||
existingContact.Phone = c.Phone;
|
||||
existingContact.Address = c.Address;
|
||||
existingContact.Notes = c.Notes;
|
||||
existingContact.IsBreeder = c.IsBreeder;
|
||||
existingContact.IsReceiver = c.IsReceiver;
|
||||
existingContact.NameSuffix = c.NameSuffix;
|
||||
existingContact.Provenance = c.Provenance;
|
||||
ec.Name = c.Name; ec.Email = c.Email; ec.Phone = c.Phone; ec.Address = c.Address;
|
||||
ec.Notes = c.Notes; ec.IsBreeder = c.IsBreeder; ec.IsReceiver = c.IsReceiver;
|
||||
ec.NameSuffix = c.NameSuffix; ec.Provenance = c.Provenance;
|
||||
contactsUpdated++;
|
||||
}
|
||||
else if (addedContactIds.Add(c.Id))
|
||||
else
|
||||
{
|
||||
c.IsManual = false;
|
||||
_db.Contacts.Add(c);
|
||||
contactsAdded++;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 3. Pass 1: Insert all Litters with parent references set to null to avoid FK errors
|
||||
var littersToInsert = new List<Litter>();
|
||||
// 2. Upsert litters (parent FKs reset to null; set in pass 2 to avoid ordering issues).
|
||||
var existingLitters = await _db.Litters.ToDictionaryAsync(l => l.Id);
|
||||
foreach (var l in data.Litters)
|
||||
{
|
||||
littersToInsert.Add(new Litter
|
||||
if (existingLitters.TryGetValue(l.Id, out var el))
|
||||
{
|
||||
Id = l.Id,
|
||||
Name = l.Name,
|
||||
Date = l.Date,
|
||||
TotalBorn = l.TotalBorn,
|
||||
DeathsWithin8Weeks = l.DeathsWithin8Weeks,
|
||||
FatherId = null, // Set in Pass 2
|
||||
MotherId = null, // Set in Pass 2
|
||||
ExpectedGoHomeDate = l.ExpectedGoHomeDate,
|
||||
Notes = l.Notes,
|
||||
PairingCode = l.PairingCode,
|
||||
ExternalRef = l.ExternalRef,
|
||||
LitterLetter = l.LitterLetter,
|
||||
Provenance = l.Provenance
|
||||
});
|
||||
el.Name = l.Name; el.Date = l.Date; el.TotalBorn = l.TotalBorn;
|
||||
el.DeathsWithin8Weeks = l.DeathsWithin8Weeks; el.Stillborn = l.Stillborn;
|
||||
el.ExpectedGoHomeDate = l.ExpectedGoHomeDate; el.Notes = l.Notes;
|
||||
el.PairingCode = l.PairingCode; el.ExternalRef = l.ExternalRef;
|
||||
el.LitterLetter = l.LitterLetter; el.Provenance = l.Provenance;
|
||||
el.ShowInChronicle = l.ShowInChronicle;
|
||||
el.FatherId = null; el.MotherId = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.Litters.Add(new Litter
|
||||
{
|
||||
Id = l.Id, Name = l.Name, Date = l.Date, TotalBorn = l.TotalBorn,
|
||||
DeathsWithin8Weeks = l.DeathsWithin8Weeks, Stillborn = l.Stillborn, FatherId = null, MotherId = null,
|
||||
ExpectedGoHomeDate = l.ExpectedGoHomeDate, Notes = l.Notes, PairingCode = l.PairingCode,
|
||||
ExternalRef = l.ExternalRef, LitterLetter = l.LitterLetter, Provenance = l.Provenance,
|
||||
ShowInChronicle = l.ShowInChronicle, IsManual = false,
|
||||
});
|
||||
}
|
||||
}
|
||||
_db.Litters.AddRange(littersToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 4. Pass 1: Insert all Gerbils with LitterId set to null to avoid FK errors
|
||||
var gerbilsToInsert = new List<Gerbil>();
|
||||
// 3. Upsert gerbils (LitterId reset to null; set in pass 2). Manual rows keep IsManual=true
|
||||
// and are never matched here (their random ids never collide with deterministic import ids).
|
||||
var existingGerbils = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||
foreach (var g in data.Gerbils)
|
||||
{
|
||||
var importedGerbil = new Gerbil
|
||||
Gerbil target;
|
||||
if (existingGerbils.TryGetValue(g.Id, out var eg))
|
||||
{
|
||||
Id = g.Id,
|
||||
Name = g.Name,
|
||||
Gender = g.Gender,
|
||||
Status = g.Status,
|
||||
LitterId = null, // Set in Pass 2
|
||||
OriginContactId = g.OriginContactId,
|
||||
ReceiverContactId = g.ReceiverContactId,
|
||||
EnclosureId = g.EnclosureId,
|
||||
ColorVarietyId = g.ColorVarietyId,
|
||||
DateOfBirth = g.DateOfBirth,
|
||||
DateOfDeath = g.DateOfDeath,
|
||||
CauseOfDeath = g.CauseOfDeath,
|
||||
GoHomeDate = g.GoHomeDate,
|
||||
Genotype = g.Genotype,
|
||||
Notes = g.Notes,
|
||||
ImportSource = g.ImportSource,
|
||||
ExternalRef = g.ExternalRef,
|
||||
RawImportData = g.RawImportData,
|
||||
Provenance = g.Provenance,
|
||||
OriginBreeder = g.OriginBreeder,
|
||||
NameSearch = g.NameSearch,
|
||||
CharacterTraits = g.CharacterTraits,
|
||||
CharacterNote = g.CharacterNote,
|
||||
IsDeaf = g.IsDeaf,
|
||||
IsResident = g.IsResident
|
||||
};
|
||||
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
gerbilsToInsert.Add(importedGerbil);
|
||||
eg.Name = g.Name; eg.Gender = g.Gender; eg.Status = g.Status;
|
||||
eg.OriginContactId = g.OriginContactId; eg.ReceiverContactId = g.ReceiverContactId;
|
||||
eg.EnclosureId = g.EnclosureId; eg.ColorVarietyId = g.ColorVarietyId;
|
||||
eg.DateOfBirth = g.DateOfBirth; eg.DateOfDeath = g.DateOfDeath; eg.CauseOfDeath = g.CauseOfDeath;
|
||||
eg.GoHomeDate = g.GoHomeDate; eg.Genotype = g.Genotype; eg.Notes = g.Notes;
|
||||
// SpottingType nur übernehmen, wenn der Payload einen Wert liefert: die
|
||||
// Scheckungsart kennt der Importer ausschließlich über conflict-decisions
|
||||
// (Ticket a547be62). Ohne diesen Guard würde eine von Hand in der App
|
||||
// erfasste Scheckungsart bei jedem Ingest genullt.
|
||||
if (g.SpottingType is not null) eg.SpottingType = g.SpottingType;
|
||||
eg.ImportSource = g.ImportSource; eg.ExternalRef = g.ExternalRef; eg.RawImportData = g.RawImportData;
|
||||
eg.Provenance = g.Provenance; eg.OriginBreeder = g.OriginBreeder;
|
||||
eg.CharacterTraits = g.CharacterTraits; eg.CharacterNote = g.CharacterNote;
|
||||
eg.IsDeaf = g.IsDeaf; eg.IsResident = g.IsResident; eg.LitterId = null;
|
||||
eg.BirthOrder = g.BirthOrder;
|
||||
target = eg;
|
||||
}
|
||||
else
|
||||
{
|
||||
var ng = new Gerbil
|
||||
{
|
||||
Id = g.Id, Name = g.Name, Gender = g.Gender, Status = g.Status, LitterId = null,
|
||||
OriginContactId = g.OriginContactId, ReceiverContactId = g.ReceiverContactId,
|
||||
EnclosureId = g.EnclosureId, ColorVarietyId = g.ColorVarietyId,
|
||||
DateOfBirth = g.DateOfBirth, DateOfDeath = g.DateOfDeath, CauseOfDeath = g.CauseOfDeath,
|
||||
GoHomeDate = g.GoHomeDate, Genotype = g.Genotype, SpottingType = g.SpottingType,
|
||||
Notes = g.Notes,
|
||||
ImportSource = g.ImportSource, ExternalRef = g.ExternalRef, RawImportData = g.RawImportData,
|
||||
Provenance = g.Provenance, OriginBreeder = g.OriginBreeder, NameSearch = g.NameSearch,
|
||||
CharacterTraits = g.CharacterTraits, CharacterNote = g.CharacterNote,
|
||||
IsDeaf = g.IsDeaf, IsResident = g.IsResident, IsManual = false,
|
||||
BirthOrder = g.BirthOrder,
|
||||
};
|
||||
_db.Gerbils.Add(ng);
|
||||
target = ng;
|
||||
}
|
||||
GerbilStatusService.Apply(target, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
}
|
||||
_db.Gerbils.AddRange(gerbilsToInsert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 5. Pass 2: Set the actual foreign key relationships
|
||||
// Update Litters with their parent animal IDs
|
||||
// 4. Pass 2: set the payload FK relationships (only for payload rows; manual rows untouched).
|
||||
var littersInDb = await _db.Litters.ToDictionaryAsync(l => l.Id);
|
||||
foreach (var l in data.Litters)
|
||||
{
|
||||
if (littersInDb.TryGetValue(l.Id, out var dbLitter))
|
||||
{
|
||||
dbLitter.FatherId = l.FatherId;
|
||||
dbLitter.MotherId = l.MotherId;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Gerbils with their actual LitterId
|
||||
if (littersInDb.TryGetValue(l.Id, out var dl)) { dl.FatherId = l.FatherId; dl.MotherId = l.MotherId; }
|
||||
var gerbilsInDb = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||
foreach (var g in data.Gerbils)
|
||||
if (gerbilsInDb.TryGetValue(g.Id, out var dg)) dg.LitterId = g.LitterId;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 5. Remove STALE imported rows (IsManual=false, no longer in the payload). Null any
|
||||
// dangling references from surviving rows first; warn when a MANUAL row is affected.
|
||||
var allGerbils = await _db.Gerbils.ToListAsync();
|
||||
var allLitters = await _db.Litters.ToListAsync();
|
||||
var allContacts = await _db.Contacts.ToListAsync();
|
||||
var gerbilNameById = allGerbils.ToDictionary(x => x.Id, x => x.Name);
|
||||
var litterNameById = allLitters.ToDictionary(x => x.Id, x => x.Name);
|
||||
var contactNameById = allContacts.ToDictionary(x => x.Id, x => x.Name);
|
||||
|
||||
var staleGerbilIds = allGerbils.Where(g => !g.IsManual && !payloadGerbilIds.Contains(g.Id)).Select(g => g.Id).ToHashSet();
|
||||
var staleLitterIds = allLitters.Where(l => !l.IsManual && !payloadLitterIds.Contains(l.Id)).Select(l => l.Id).ToHashSet();
|
||||
var staleContactIds = allContacts.Where(c => !c.IsManual && !payloadContactIds.Contains(c.Id)).Select(c => c.Id).ToHashSet();
|
||||
|
||||
if (staleGerbilIds.Count > 0)
|
||||
{
|
||||
if (gerbilsInDb.TryGetValue(g.Id, out var dbGerbil))
|
||||
foreach (var l in allLitters)
|
||||
{
|
||||
dbGerbil.LitterId = g.LitterId;
|
||||
if (l.FatherId is { } f && staleGerbilIds.Contains(f))
|
||||
{
|
||||
if (l.IsManual) warnings.Add($"Manueller Wurf „{l.Name}\" verwies auf importiertes Elterntier „{gerbilNameById.GetValueOrDefault(f, "?")}\" (Vater), das im Import nicht mehr existiert — Elternteil entfernt.");
|
||||
l.FatherId = null;
|
||||
}
|
||||
if (l.MotherId is { } m && staleGerbilIds.Contains(m))
|
||||
{
|
||||
if (l.IsManual) warnings.Add($"Manueller Wurf „{l.Name}\" verwies auf importiertes Elterntier „{gerbilNameById.GetValueOrDefault(m, "?")}\" (Mutter), das im Import nicht mehr existiert — Elternteil entfernt.");
|
||||
l.MotherId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (staleContactIds.Count > 0)
|
||||
{
|
||||
foreach (var g in allGerbils)
|
||||
{
|
||||
if (g.OriginContactId is { } o && staleContactIds.Contains(o))
|
||||
{
|
||||
if (g.IsManual) warnings.Add($"Manuelles Tier „{g.Name}\" verwies auf Kontakt „{contactNameById.GetValueOrDefault(o, "?")}\" (Herkunft), der im Import nicht mehr existiert — Zuordnung entfernt.");
|
||||
g.OriginContactId = null;
|
||||
}
|
||||
if (g.ReceiverContactId is { } r && staleContactIds.Contains(r))
|
||||
{
|
||||
if (g.IsManual) warnings.Add($"Manuelles Tier „{g.Name}\" verwies auf Kontakt „{contactNameById.GetValueOrDefault(r, "?")}\" (Abnehmer), der im Import nicht mehr existiert — Zuordnung entfernt.");
|
||||
g.ReceiverContactId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (staleLitterIds.Count > 0)
|
||||
{
|
||||
// Gerbil.LitterId FK is SetNull on delete → nulled automatically when the litter is
|
||||
// removed; we only surface a warning for affected MANUAL animals.
|
||||
foreach (var g in allGerbils)
|
||||
if (g.IsManual && g.LitterId is { } lid && staleLitterIds.Contains(lid))
|
||||
warnings.Add($"Manuelles Tier „{g.Name}\" war im Wurf „{litterNameById.GetValueOrDefault(lid, "?")}\" geboren, der im Import nicht mehr existiert — Wurf-Zuordnung entfernt.");
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
int gerbilsDeleted = staleGerbilIds.Count, littersDeleted = staleLitterIds.Count, contactsDeleted = staleContactIds.Count;
|
||||
if (staleGerbilIds.Count > 0) _db.Gerbils.RemoveRange(allGerbils.Where(g => staleGerbilIds.Contains(g.Id)));
|
||||
if (staleLitterIds.Count > 0) _db.Litters.RemoveRange(allLitters.Where(l => staleLitterIds.Contains(l.Id)));
|
||||
if (staleContactIds.Count > 0) _db.Contacts.RemoveRange(allContacts.Where(c => staleContactIds.Contains(c.Id)));
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 6. Photos: replace photos of IMPORTED gerbils (manual gerbils' photos survive). Copy files.
|
||||
var manualGerbilIds = allGerbils.Where(g => g.IsManual).Select(g => g.Id).ToHashSet();
|
||||
var allPhotos = await _db.GerbilPhotos.ToListAsync();
|
||||
_db.GerbilPhotos.RemoveRange(allPhotos.Where(p => !manualGerbilIds.Contains(p.GerbilId)));
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 6. Insert all Photos & copy physical files
|
||||
Directory.CreateDirectory(_photoRoot);
|
||||
foreach (var p in data.GerbilPhotos)
|
||||
{
|
||||
@@ -211,14 +249,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
if (File.Exists(src))
|
||||
{
|
||||
var dest = Path.Combine(_photoRoot, p.FileName);
|
||||
try
|
||||
{
|
||||
File.Copy(src, dest, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}");
|
||||
}
|
||||
try { File.Copy(src, dest, overwrite: true); }
|
||||
catch (Exception ex) { Console.WriteLine($"Warning: Failed to copy photo {src} to {dest}: {ex.Message}"); }
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -233,24 +265,18 @@ namespace GerbilManagerWebAPI.Import
|
||||
FileName = p.FileName,
|
||||
Caption = p.Caption,
|
||||
SortOrder = p.SortOrder,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// 7. Insert imported Abgabeverträge (SaleContract + join rows).
|
||||
// The table was wiped above, so these are recreated on every ingest
|
||||
// (they survive only by being part of the payload). FK-safe: contacts
|
||||
// and gerbils are already inserted. We skip rows whose buyer contact
|
||||
// is unknown and skip individual animal links to unknown gerbils
|
||||
// (defensive — the resolver should never emit those).
|
||||
// No .docx is copied: the source files live on the network share and
|
||||
// are not staged into Contracts:RootPath, so the Word download 404s
|
||||
// gracefully (the endpoint already returns NotFound) while the PDF is
|
||||
// regenerated from the data. The DTO exposes HasFile=false for these
|
||||
// so the frontend hides the Word button.
|
||||
int contractsAdded = 0;
|
||||
int contractAnimalsSkipped = 0;
|
||||
// 7. Imported Abgabeverträge: recreate from payload (unchanged behaviour — the imported set
|
||||
// is part of the payload). Manual sale-contract protection is a separate follow-up.
|
||||
var scToRemove = await _db.SaleContracts.ToListAsync();
|
||||
_db.SaleContracts.RemoveRange(scToRemove);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
int contractsAdded = 0, contractAnimalsSkipped = 0;
|
||||
if (data.SaleContracts.Count > 0)
|
||||
{
|
||||
var contactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
|
||||
@@ -258,8 +284,8 @@ namespace GerbilManagerWebAPI.Import
|
||||
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
|
||||
if (!seenContractIds.Add(sc.Id)) continue;
|
||||
if (!contactIds.Contains(sc.ContactId)) continue;
|
||||
|
||||
var animals = new List<SaleContractAnimal>();
|
||||
foreach (var gid in (sc.Animals ?? new List<Guid>()).Distinct())
|
||||
@@ -284,7 +310,40 @@ namespace GerbilManagerWebAPI.Import
|
||||
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).";
|
||||
// 8. FREEZE: capture the raw-import snapshot for verified overrides (BEFORE applying the
|
||||
// freeze), then re-apply every override on top of the fresh import so the breeder's
|
||||
// curated values win. Overrides survive re-ingest by being their own (FK-free) table.
|
||||
var overrides = await _db.GerbilOverrides.ToListAsync();
|
||||
foreach (var ov in overrides.Where(o => o.IsVerified))
|
||||
{
|
||||
var raw = await GerbilSnapshotService.BuildSnapshotAsync(_db, ov.GerbilId);
|
||||
if (raw is null) continue; // verified animal vanished from import → surfaced as "missing" in the UI
|
||||
ov.LastImportSnapshotJson = GerbilSnapshotService.SerializeSnapshot(raw);
|
||||
var golden = GerbilSnapshotService.DeserializeSnapshot(ov.SnapshotJson);
|
||||
var diff = GerbilSnapshotService.Diff(golden, raw);
|
||||
ov.LastImportDiffJson = diff.Count == 0 ? null : JsonSerializer.Serialize(diff, DiffJson);
|
||||
}
|
||||
var gerbilsById = await _db.Gerbils.ToDictionaryAsync(g => g.Id);
|
||||
int overridesApplied = 0;
|
||||
foreach (var ov in overrides)
|
||||
{
|
||||
if (gerbilsById.TryGetValue(ov.GerbilId, out var gg))
|
||||
{
|
||||
GerbilSnapshotService.ApplyOverride(gg, ov.OverrideJson);
|
||||
// Der Override kann Todesdatum/Abgabe/Geburtsdatum setzen; Status neu ableiten,
|
||||
// damit ein verstorbenes/abgegebenes Tier nicht als 'Zucht' hängen bleibt
|
||||
// (Ticket 37ab228a "Gaida"). Status/Gehege sind selbst NICHT eingefroren.
|
||||
GerbilStatusService.Apply(gg, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
overridesApplied++;
|
||||
}
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var warnPart = warnings.Count > 0 ? $" {warnings.Count} warning(s): {string.Join(" | ", warnings)}" : "";
|
||||
return $"Ingestion successful! Contacts +{contactsAdded}/~{contactsUpdated} (−{contactsDeleted} stale), " +
|
||||
$"{data.Litters.Count} litters (−{littersDeleted} stale), {data.Gerbils.Count} gerbils (−{gerbilsDeleted} stale), " +
|
||||
$"{data.GerbilPhotos.Count} photos, {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links skipped), " +
|
||||
$"{overridesApplied} override(s) applied (freeze).{warnPart}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,7 +378,7 @@ namespace GerbilManagerWebAPI.Import
|
||||
public required string FileName { get; set; }
|
||||
public string? Caption { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("_source_path")]
|
||||
public string? SourcePath { get; set; }
|
||||
}
|
||||
|
||||
117
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal file
117
GerbilManagerWebAPI/Import/Rpro3/Rpro3Decisions.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
/// <summary>
|
||||
/// Manuelle Entscheidungen der Züchterin zu mehrdeutigen RPRO3-Namensdubletten
|
||||
/// (aus den „Fehler melden"-Tickets, Kontext „rpro3-import"). Der automatische
|
||||
/// <see cref="Rpro3Dedup"/> ist konservativ und legt nur bei positiver Evidenz zusammen;
|
||||
/// diese Overrides erlauben der Züchterin, gezielt zu korrigieren:
|
||||
///
|
||||
/// • <c>Same</c> — Gruppen von RPRO3-Nummern (rids), die DASSELBE Tier sind
|
||||
/// (zwingt ein Merge über Varianten hinweg; ein rid je Variante genügt,
|
||||
/// der ganze Cluster wird mitgezogen).
|
||||
/// • <c>Different</c>— Gruppen, die VERSCHIEDENE Tiere sind (verhindert ein automatisches
|
||||
/// Zusammenlegen über Gruppengrenzen).
|
||||
/// • <c>Fields</c> — Feld-Korrekturen am resultierenden Tier (Farbe/Geburtsdatum/Herkunft/
|
||||
/// Bestands-Flag/Zusatznotiz), adressiert über IRGENDEINE rid des Clusters.
|
||||
///
|
||||
/// Datenquelle: <c>Import/Rpro3/rpro3-decisions.json</c> (siehe <see cref="Load"/>).
|
||||
/// Stabiler Schlüssel ist die RPRO3-Nummer (rid), nicht der Variantenbuchstabe — Buchstaben
|
||||
/// verschieben sich, sobald sich die Clusterbildung ändert.
|
||||
/// </summary>
|
||||
public sealed class Rpro3Decisions
|
||||
{
|
||||
public List<Rpro3Decision> Decisions { get; set; } = new();
|
||||
|
||||
public static Rpro3Decisions Empty { get; } = new();
|
||||
|
||||
/// <summary>Lädt die Entscheidungen aus JSON; fehlt die Datei oder ist sie leer/kaputt,
|
||||
/// kommt eine leere Menge zurück (Import läuft dann mit reinem Auto-Dedup).</summary>
|
||||
public static Rpro3Decisions Load(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return Empty;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var d = JsonSerializer.Deserialize<Rpro3Decisions>(json, JsonOpts);
|
||||
return d ?? Empty;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
/// <summary>Alle „Same"-rid-Gruppen (für Force-Merge).</summary>
|
||||
public IEnumerable<IReadOnlyList<string>> SameGroups()
|
||||
{
|
||||
foreach (var d in Decisions)
|
||||
foreach (var g in d.Same)
|
||||
if (g.Count >= 2) yield return g;
|
||||
}
|
||||
|
||||
/// <summary>rid → numerische Split-Gruppe je Entscheidung. Zwei rids mit unterschiedlicher
|
||||
/// Split-Gruppe (gleiche Entscheidung) dürfen NICHT automatisch zusammengelegt werden.</summary>
|
||||
public Dictionary<string, int> BuildSplitGroups()
|
||||
{
|
||||
var map = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
int gid = 0;
|
||||
foreach (var d in Decisions)
|
||||
{
|
||||
if (d.Different.Count < 2) continue;
|
||||
foreach (var grp in d.Different)
|
||||
{
|
||||
gid++;
|
||||
foreach (var rid in grp)
|
||||
map[rid] = gid;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>rid → Feld-Override (jede rid eines Clusters zeigt auf denselben Override).</summary>
|
||||
public Dictionary<string, Rpro3FieldOverride> BuildFieldIndex()
|
||||
{
|
||||
var map = new Dictionary<string, Rpro3FieldOverride>(StringComparer.Ordinal);
|
||||
foreach (var d in Decisions)
|
||||
foreach (var (rid, ov) in d.Fields)
|
||||
map[rid] = ov;
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Rpro3Decision
|
||||
{
|
||||
/// <summary>Anzeigename (nur zur Lesbarkeit der JSON; Logik nutzt rids).</summary>
|
||||
public string? Name { get; set; }
|
||||
/// <summary>Ticket-Id (Rückverfolgbarkeit).</summary>
|
||||
public string? Ticket { get; set; }
|
||||
public List<List<string>> Same { get; set; } = new();
|
||||
public List<List<string>> Different { get; set; } = new();
|
||||
public Dictionary<string, Rpro3FieldOverride> Fields { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class Rpro3FieldOverride
|
||||
{
|
||||
public string? Color { get; set; }
|
||||
public string? Dob { get; set; } // ISO yyyy-MM-dd
|
||||
public string? Origin { get; set; }
|
||||
/// <summary>Roher Gencode (Fcode, z. B. „aa Cc[h] DD ee Gg P- spsp"). Überschreibt den
|
||||
/// automatisch aus den Cluster-Mitgliedern gewählten Gencode — nötig, wenn sich die
|
||||
/// Varianten im Gencode unterscheiden (z. B. C-Locus c[h] vs. c[chm]) und die Züchterin
|
||||
/// den korrekten Genotyp vorgibt.</summary>
|
||||
public string? Genotype { get; set; }
|
||||
public bool? Resident { get; set; }
|
||||
/// <summary>Zusatz, der an die Notizen des Tiers angehängt wird (z. B. abweichendes DOB).</summary>
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,19 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
return UnknownValues.Contains(v) ? "" : v;
|
||||
}
|
||||
|
||||
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals)
|
||||
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals) => Run(animals, null);
|
||||
|
||||
/// <summary>
|
||||
/// Wie <see cref="Run(IReadOnlyList{Rpro3Animal})"/>, berücksichtigt aber manuelle
|
||||
/// Entscheidungen der Züchterin (<paramref name="decisions"/>): „Different"-Gruppen
|
||||
/// verhindern automatisches Zusammenlegen, „Same"-Gruppen erzwingen ein Merge über
|
||||
/// Varianten hinweg (jede rid zieht ihren ganzen Auto-Cluster mit).
|
||||
/// </summary>
|
||||
public static DedupResult Run(IReadOnlyList<Rpro3Animal> animals, Rpro3Decisions? decisions)
|
||||
{
|
||||
var result = new DedupResult();
|
||||
decisions ??= Rpro3Decisions.Empty;
|
||||
var splitGroup = decisions.BuildSplitGroups();
|
||||
|
||||
foreach (var a in animals)
|
||||
{
|
||||
@@ -123,12 +133,31 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
for (int j = i + 1; j < group.Count; j++)
|
||||
{
|
||||
var a = group[i]; var b = group[j];
|
||||
// Force-Split: von der Züchterin als verschieden markierte Tiere nie zusammenlegen.
|
||||
if (splitGroup.TryGetValue(a.Rid, out var ga) && splitGroup.TryGetValue(b.Rid, out var gb) && ga != gb)
|
||||
continue;
|
||||
bool comp = CompatDob(a.Dob, b.Dob) && Compat(a.FarbeKey, b.FarbeKey) && Compat(a.OriginKey, b.OriginKey);
|
||||
if (comp && Positive(a, b) >= 1 && Conflict(a, b) == 0)
|
||||
Union(a.Rid, b.Rid);
|
||||
}
|
||||
}
|
||||
|
||||
// Force-Merge: von der Züchterin als dasselbe Tier bestätigte Varianten zusammenlegen
|
||||
// (eine rid je Variante genügt — Find/Union zieht den ganzen Auto-Cluster mit). Nur rids,
|
||||
// die es im Datensatz auch gibt, werden berücksichtigt.
|
||||
var knownRids = new HashSet<string>(animals.Select(a => a.Rid), StringComparer.Ordinal);
|
||||
foreach (var grp in decisions.SameGroups())
|
||||
{
|
||||
string? anchor = null;
|
||||
foreach (var rid in grp)
|
||||
{
|
||||
if (!knownRids.Contains(rid)) continue;
|
||||
parent.TryAdd(rid, rid);
|
||||
if (anchor is null) anchor = rid;
|
||||
else Union(anchor, rid);
|
||||
}
|
||||
}
|
||||
|
||||
// Cluster sammeln
|
||||
var clusters = new Dictionary<string, List<Rpro3Animal>>();
|
||||
foreach (var a in animals)
|
||||
|
||||
@@ -20,20 +20,30 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
{
|
||||
private readonly ApplicationContext _db;
|
||||
private readonly string _photoRoot;
|
||||
private readonly Rpro3Decisions _decisions;
|
||||
private Dictionary<string, Rpro3FieldOverride>? _fieldIndex;
|
||||
|
||||
public Rpro3ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment? env)
|
||||
{
|
||||
_db = db;
|
||||
var contentRoot = env?.ContentRootPath ?? Directory.GetCurrentDirectory();
|
||||
_photoRoot = config["Photos:RootPath"] ?? Path.Combine(contentRoot, "photo-storage");
|
||||
// Manuelle Dubletten-Entscheidungen der Züchterin (siehe Rpro3Decisions). Pfad
|
||||
// überschreibbar per Config; Default: neben dem Importer-Code (wird mit ins Output kopiert).
|
||||
var decPath = config["Rpro3:DecisionsPath"]
|
||||
?? Path.Combine(contentRoot, "Import", "Rpro3", "rpro3-decisions.json");
|
||||
_decisions = Rpro3Decisions.Load(decPath);
|
||||
}
|
||||
|
||||
private Dictionary<string, Rpro3FieldOverride> FieldIndex =>
|
||||
_fieldIndex ??= _decisions.BuildFieldIndex();
|
||||
|
||||
// ───────────────────────── ANALYZE ─────────────────────────
|
||||
|
||||
public async Task<Rpro3AnalyzeResult> AnalyzeAsync(
|
||||
Rpro3Data data, bool photosProvided, IReadOnlySet<string>? availablePhotoFiles)
|
||||
{
|
||||
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||
var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
|
||||
var plan = BuildPlan(data, dedup);
|
||||
|
||||
// Abgleich gegen Bestand: Match über separator-insensitiven NameSearch + DOB-Toleranz.
|
||||
@@ -122,7 +132,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
public async Task<Rpro3ExecuteResult> ExecuteAsync(
|
||||
Rpro3Data data, string workDir, bool photosProvided, IReadOnlyDictionary<string, string>? photoSourcePaths)
|
||||
{
|
||||
var dedup = Rpro3Dedup.Run(data.Animals);
|
||||
var dedup = Rpro3Dedup.Run(data.Animals, _decisions);
|
||||
var plan = BuildPlan(data, dedup);
|
||||
|
||||
// Change-Tracker leeren: ExecuteDelete/Update umgehen den Tracker; bei wiederholtem
|
||||
@@ -407,6 +417,20 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
var origin = members.Select(m => m.Origin).FirstOrDefault(o => Rpro3Dedup.NormValue(o).Length > 0) ?? rep.Origin;
|
||||
var herkId = members.Select(m => m.OriginHerkId).FirstOrDefault(h => h is not null and not 1);
|
||||
|
||||
// Manuelle Feld-Korrektur der Züchterin (Rpro3Decisions): adressiert über IRGENDEINE
|
||||
// rid des Clusters. Überschreibt das automatisch gewählte Feld; "Note" wird angehängt.
|
||||
Rpro3FieldOverride? ov = null;
|
||||
foreach (var m in members)
|
||||
if (FieldIndex.TryGetValue(m.Rid, out ov)) break;
|
||||
bool? residentOverride = ov?.Resident;
|
||||
if (ov is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ov.Color)) farbe = ov.Color;
|
||||
if (!string.IsNullOrWhiteSpace(ov.Origin)) origin = ov.Origin;
|
||||
if (!string.IsNullOrWhiteSpace(ov.Genotype)) fcode = ov.Genotype;
|
||||
if (!string.IsNullOrWhiteSpace(ov.Dob) && DateOnly.TryParse(ov.Dob, out var od)) dob = od;
|
||||
}
|
||||
|
||||
var gp = new GerbilPlan
|
||||
{
|
||||
Id = id,
|
||||
@@ -419,7 +443,7 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
Genotype = CleanGenotype(fcode),
|
||||
OriginBreeder = herkId == 1 ? "eigene Zucht" : (Rpro3Dedup.NormValue(origin).Length > 0 ? origin : null),
|
||||
OriginContactId = HerkContactId(herkId),
|
||||
IsResident = resident,
|
||||
IsResident = residentOverride ?? resident,
|
||||
IsCastrated = members.Any(m => m.IsCastrated),
|
||||
MotherRid = rep.MidRaw,
|
||||
FatherRid = rep.PidRaw,
|
||||
@@ -440,6 +464,8 @@ namespace GerbilManagerWebAPI.Import.Rpro3
|
||||
}
|
||||
|
||||
gp.Notes = BuildNotes(members);
|
||||
if (!string.IsNullOrWhiteSpace(ov?.Note))
|
||||
gp.Notes = string.IsNullOrWhiteSpace(gp.Notes) ? ov!.Note : $"{gp.Notes}\n{ov!.Note}";
|
||||
gp.Provenance = BuildProvenance(data, members, dedup);
|
||||
plan.Gerbils[id] = gp;
|
||||
}
|
||||
|
||||
1378
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal file
1378
GerbilManagerWebAPI/Import/Rpro3/rpro3-decisions.json
Normal file
File diff suppressed because it is too large
Load Diff
1548
GerbilManagerWebAPI/Migrations/20260623065520_Gen5FuchsschimmelHetSeedFix.Designer.cs
generated
Normal file
1548
GerbilManagerWebAPI/Migrations/20260623065520_Gen5FuchsschimmelHetSeedFix.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Gen5FuchsschimmelHetSeedFix : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efe gg PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efe GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efe GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efe gg PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efe GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efe GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efe GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC dd ee gg PP spsp rere");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efef gg PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efef GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efef GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efef gg PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "AA CC DD efef GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efef GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD efef GG PP spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC dd ee gg pp spsp rere");
|
||||
}
|
||||
}
|
||||
}
|
||||
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
1794
GerbilManagerWebAPI/Migrations/20260623085942_FeedbackReopenedAndSoftDelete.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeedbackReopenedAndSoftDelete : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "DeletedAt",
|
||||
table: "Feedback",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "ReopenedAt",
|
||||
table: "Feedback",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DeletedAt",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReopenedAt",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
1800
GerbilManagerWebAPI/Migrations/20260623091325_FeedbackCategoryAndHelpful.Designer.cs
generated
Normal file
1800
GerbilManagerWebAPI/Migrations/20260623091325_FeedbackCategoryAndHelpful.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeedbackCategoryAndHelpful : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Category",
|
||||
table: "Feedback",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Helpful",
|
||||
table: "Feedback",
|
||||
type: "boolean",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Category",
|
||||
table: "Feedback");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Helpful",
|
||||
table: "Feedback");
|
||||
}
|
||||
}
|
||||
}
|
||||
1832
GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs
generated
Normal file
1832
GerbilManagerWebAPI/Migrations/20260623092227_FeedbackAttachments.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FeedbackAttachments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FeedbackAttachments",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FeedbackId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FileName = table.Column<string>(type: "text", nullable: false),
|
||||
ContentType = table.Column<string>(type: "text", nullable: false),
|
||||
Size = table.Column<int>(type: "integer", nullable: false),
|
||||
Data = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_FeedbackAttachments", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FeedbackAttachments");
|
||||
}
|
||||
}
|
||||
}
|
||||
1858
GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs
generated
Normal file
1858
GerbilManagerWebAPI/Migrations/20260623093055_WebPushSubscriptions.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class WebPushSubscriptions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WebPushSubscriptions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Endpoint = table.Column<string>(type: "text", nullable: false),
|
||||
P256dh = table.Column<string>(type: "text", nullable: false),
|
||||
Auth = table.Column<string>(type: "text", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WebPushSubscriptions", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "WebPushSubscriptions");
|
||||
}
|
||||
}
|
||||
}
|
||||
1861
GerbilManagerWebAPI/Migrations/20260624071249_AddLitterShowInChronicle.Designer.cs
generated
Normal file
1861
GerbilManagerWebAPI/Migrations/20260624071249_AddLitterShowInChronicle.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterShowInChronicle : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ShowInChronicle",
|
||||
table: "Litters",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
// Bestehende Würfe bleiben in der Wurfchronik sichtbar; nur explizit als
|
||||
// „nicht in Chronik" importierte Würfe (z. B. Akanes extern entstandener Wurf)
|
||||
// werden beim Ingest auf false gesetzt.
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShowInChronicle",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
1861
GerbilManagerWebAPI/Migrations/20260624082334_UpdateLegalPagesDsgvo.Designer.cs
generated
Normal file
1861
GerbilManagerWebAPI/Migrations/20260624082334_UpdateLegalPagesDsgvo.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateLegalPagesDsgvo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"## Angaben gemäß § 5 DDG (Digitale-Dienste-Gesetz)\\n\\nSeitenbetreiber:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nTelefon: [Telefonnummer]\\nE-Mail: [E-Mail-Adresse]\\n\\n## Verantwortlich für den Inhalt gemäß § 18 Abs. 2 MStV\\n\\n[Vor- und Nachname], Anschrift wie oben\\n\\n## Verbraucherstreitbeilegung\\n\\nWir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Bitte alle Platzhalter in eckigen Klammern durch deine echten Angaben ersetzen. Pflicht ist eine ladungsfähige Anschrift (kein Postfach). Wird die Zucht gewerblich betrieben oder besteht eine Erlaubnis nach § 11 TierSchG, ergänze hier die zuständige Behörde und das Aktenzeichen.*\"}");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000080"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"## Datenschutzerklärung\\n\\n## 1. Verantwortlicher\\n\\nVerantwortlich für die Datenverarbeitung auf dieser Webseite ist:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\nE-Mail: [E-Mail-Adresse]\\n\\n## 2. Hosting und Server-Logfiles\\n\\nDiese Webseite wird auf einem eigenen Server betrieben. Beim Aufruf der Seite verarbeitet der Server automatisch Informationen, die dein Browser übermittelt, in sogenannten Server-Logfiles: IP-Adresse, Datum und Uhrzeit des Zugriffs, die aufgerufene Seite bzw. Datei, die übertragene Datenmenge sowie Browser und Betriebssystem.\\nDiese Verarbeitung ist technisch erforderlich, um die Webseite auszuliefern und ihren sicheren und stabilen Betrieb zu gewährleisten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am störungsfreien Betrieb und an der Sicherheit). Die Logfiles werden nach spätestens [z. B. 7] Tagen gelöscht und nicht mit anderen Daten zusammengeführt.\\n\\n## 3. Cookies, Tracking und externe Dienste\\n\\nDiese Webseite verwendet keine Cookies, kein Tracking und keine Analyse- oder Marketing-Dienste. Es werden keine externen Inhalte Dritter (z. B. Schriftarten, Karten, Videos, CDNs) nachgeladen.\\n\\n## 4. Kontaktaufnahme\\n\\nWenn du uns per E-Mail kontaktierst, verarbeiten wir deine Angaben (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht) ausschließlich zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.\\n\\n## 5. Empfänger der Daten\\n\\nEine Weitergabe deiner Daten an Dritte findet nicht statt. Eine Datenübermittlung in Länder außerhalb der EU bzw. des EWR erfolgt nicht.\\n\\n## 6. Deine Rechte\\n\\nDu hast jederzeit das Recht auf:\\n- Auskunft über die zu deiner Person gespeicherten Daten (Art. 15 DSGVO)\\n- Berichtigung unrichtiger Daten (Art. 16 DSGVO)\\n- Löschung (Art. 17 DSGVO)\\n- Einschränkung der Verarbeitung (Art. 18 DSGVO)\\n- Datenübertragbarkeit (Art. 20 DSGVO)\\n- Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)\\n\\nZur Ausübung genügt eine formlose Nachricht an die oben genannte E-Mail-Adresse.\\n\\n## 7. Beschwerderecht bei der Aufsichtsbehörde\\n\\nDu hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). Zuständig ist die Aufsichtsbehörde deines Bundeslandes bzw. des Seitenbetreibers: [zuständige Landesdatenschutzbehörde eintragen].\\n\\n## 8. Keine automatisierte Entscheidungsfindung\\n\\nEine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Platzhalter in eckigen Klammern ausfüllen. Aufbewahrungsdauer der Logfiles an deinen Server bzw. Reverse-Proxy anpassen. Sobald externe Dienste oder ein Kontaktformular eingebunden werden, diesen Text ergänzen. Stand: [Datum eintragen].*\"}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000080"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
1861
GerbilManagerWebAPI/Migrations/20260624084747_UpdateImpressumHobbyMStV.Designer.cs
generated
Normal file
1861
GerbilManagerWebAPI/Migrations/20260624084747_UpdateImpressumHobbyMStV.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateImpressumHobbyMStV : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"## Angaben gemäß § 18 Abs. 1 MStV\\n\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nE-Mail: [E-Mail-Adresse]\\nTelefon: [Telefonnummer]\\n\\n---\\n\\nDiese Seite stellt eine private Hobbyzucht mongolischer Rennmäuse ohne Gewinnerzielungsabsicht vor.\\n\\n*Hinweis für den Seitenbetreiber: Name und ladungsfähige Anschrift (kein Postfach) sind Pflicht (§ 18 Abs. 1 MStV). Sollte die Zucht jemals gewerblich bzw. geschäftsmäßig betrieben werden, gelten zusätzlich § 5 DDG (vollständiges Anbieter-Impressum) und ggf. die Erlaubnispflicht nach § 11 TierSchG — dann zuständige Behörde und Aktenzeichen hier ergänzen. Kein Hinweis auf die EU-Streitbeilegungsplattform: diese wurde zum 20.07.2025 abgeschaltet (ein fortbestehender Link wäre irreführend).*\"}");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "Blocks",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
column: "Data",
|
||||
value: "{\"markdown\":\"## Angaben gemäß § 5 DDG (Digitale-Dienste-Gesetz)\\n\\nSeitenbetreiber:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nTelefon: [Telefonnummer]\\nE-Mail: [E-Mail-Adresse]\\n\\n## Verantwortlich für den Inhalt gemäß § 18 Abs. 2 MStV\\n\\n[Vor- und Nachname], Anschrift wie oben\\n\\n## Verbraucherstreitbeilegung\\n\\nWir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Bitte alle Platzhalter in eckigen Klammern durch deine echten Angaben ersetzen. Pflicht ist eine ladungsfähige Anschrift (kein Postfach). Wird die Zucht gewerblich betrieben oder besteht eine Erlaubnis nach § 11 TierSchG, ergänze hier die zuständige Behörde und das Aktenzeichen.*\"}");
|
||||
}
|
||||
}
|
||||
}
|
||||
1921
GerbilManagerWebAPI/Migrations/20260712142631_AddVerifiedGerbilAndManualFlag.Designer.cs
generated
Normal file
1921
GerbilManagerWebAPI/Migrations/20260712142631_AddVerifiedGerbilAndManualFlag.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVerifiedGerbilAndManualFlag : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsManual",
|
||||
table: "Litters",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsManual",
|
||||
table: "Gerbils",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsManual",
|
||||
table: "Contacts",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "GerbilOverrides",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
GerbilId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
EntityName = table.Column<string>(type: "text", nullable: true),
|
||||
IsVerified = table.Column<bool>(type: "boolean", nullable: false),
|
||||
OverrideJson = table.Column<string>(type: "text", nullable: false),
|
||||
SnapshotJson = table.Column<string>(type: "text", nullable: true),
|
||||
LastImportSnapshotJson = table.Column<string>(type: "text", nullable: true),
|
||||
LastImportDiffJson = table.Column<string>(type: "text", nullable: true),
|
||||
Note = table.Column<string>(type: "text", nullable: true),
|
||||
VerifiedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_GerbilOverrides", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GerbilOverrides_GerbilId",
|
||||
table: "GerbilOverrides",
|
||||
column: "GerbilId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "GerbilOverrides");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsManual",
|
||||
table: "Litters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsManual",
|
||||
table: "Gerbils");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsManual",
|
||||
table: "Contacts");
|
||||
}
|
||||
}
|
||||
}
|
||||
1924
GerbilManagerWebAPI/Migrations/20260713173625_AddLitterStillborn.Designer.cs
generated
Normal file
1924
GerbilManagerWebAPI/Migrations/20260713173625_AddLitterStillborn.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterStillborn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Stillborn",
|
||||
table: "Litters",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Stillborn",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
1927
GerbilManagerWebAPI/Migrations/20260714055648_AddGerbilBirthOrder.Designer.cs
generated
Normal file
1927
GerbilManagerWebAPI/Migrations/20260714055648_AddGerbilBirthOrder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGerbilBirthOrder : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "BirthOrder",
|
||||
table: "Gerbils",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BirthOrder",
|
||||
table: "Gerbils");
|
||||
}
|
||||
}
|
||||
}
|
||||
1927
GerbilManagerWebAPI/Migrations/20260819212610_ReseedColorVarietiesGen6Saphir.Designer.cs
generated
Normal file
1927
GerbilManagerWebAPI/Migrations/20260819212610_ReseedColorVarietiesGen6Saphir.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReseedColorVarietiesGen6Saphir : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa Ccchm DD EE GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa Cch DD EE GG pp spsp rere");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD EE GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD EE GG pp spsp rere");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("51720002-0000-0000-0000-000000000070"),
|
||||
Data = "{\"markdown\":\"**Angaben gemäß § 5 TMG**\\n\\nSeitenbetreiber: [Name und vollständige Adresse eintragen]\\n\\nE-Mail: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
||||
Data = "{\"markdown\":\"## Angaben gemäß § 18 Abs. 1 MStV\\n\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\n\\n## Kontakt\\n\\nE-Mail: [E-Mail-Adresse]\\nTelefon: [Telefonnummer]\\n\\n---\\n\\nDiese Seite stellt eine private Hobbyzucht mongolischer Rennmäuse ohne Gewinnerzielungsabsicht vor.\\n\\n*Hinweis für den Seitenbetreiber: Name und ladungsfähige Anschrift (kein Postfach) sind Pflicht (§ 18 Abs. 1 MStV). Sollte die Zucht jemals gewerblich bzw. geschäftsmäßig betrieben werden, gelten zusätzlich § 5 DDG (vollständiges Anbieter-Impressum) und ggf. die Erlaubnispflicht nach § 11 TierSchG — dann zuständige Behörde und Aktenzeichen hier ergänzen. Kein Hinweis auf die EU-Streitbeilegungsplattform: diese wurde zum 20.07.2025 abgeschaltet (ein fortbestehender Link wäre irreführend).*\"}",
|
||||
Order = 1,
|
||||
PageId = new Guid("51720001-0000-0000-0000-000000000007"),
|
||||
Type = "RichText"
|
||||
@@ -163,7 +163,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("51720002-0000-0000-0000-000000000080"),
|
||||
Data = "{\"markdown\":\"**Datenschutzerklärung**\\n\\nDiese Webseite dient der Vorstellung unserer Rennmauszucht. Es werden keine personenbezogenen Daten gespeichert oder weitergegeben.\\n\\nBei datenschutzbezogenen Fragen: [E-Mail-Adresse eintragen]\\n\\n---\\n\\n*Diese Seite wird vom Seitenbetreiber noch vervollständigt.*\"}",
|
||||
Data = "{\"markdown\":\"## Datenschutzerklärung\\n\\n## 1. Verantwortlicher\\n\\nVerantwortlich für die Datenverarbeitung auf dieser Webseite ist:\\n[Vor- und Nachname]\\n[Straße und Hausnummer]\\n[PLZ und Ort]\\nE-Mail: [E-Mail-Adresse]\\n\\n## 2. Hosting und Server-Logfiles\\n\\nDiese Webseite wird auf einem eigenen Server betrieben. Beim Aufruf der Seite verarbeitet der Server automatisch Informationen, die dein Browser übermittelt, in sogenannten Server-Logfiles: IP-Adresse, Datum und Uhrzeit des Zugriffs, die aufgerufene Seite bzw. Datei, die übertragene Datenmenge sowie Browser und Betriebssystem.\\nDiese Verarbeitung ist technisch erforderlich, um die Webseite auszuliefern und ihren sicheren und stabilen Betrieb zu gewährleisten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am störungsfreien Betrieb und an der Sicherheit). Die Logfiles werden nach spätestens [z. B. 7] Tagen gelöscht und nicht mit anderen Daten zusammengeführt.\\n\\n## 3. Cookies, Tracking und externe Dienste\\n\\nDiese Webseite verwendet keine Cookies, kein Tracking und keine Analyse- oder Marketing-Dienste. Es werden keine externen Inhalte Dritter (z. B. Schriftarten, Karten, Videos, CDNs) nachgeladen.\\n\\n## 4. Kontaktaufnahme\\n\\nWenn du uns per E-Mail kontaktierst, verarbeiten wir deine Angaben (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht) ausschließlich zur Bearbeitung deiner Anfrage. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO bzw. Art. 6 Abs. 1 lit. f DSGVO. Die Daten werden gelöscht, sobald sie nicht mehr benötigt werden und keine gesetzlichen Aufbewahrungspflichten entgegenstehen.\\n\\n## 5. Empfänger der Daten\\n\\nEine Weitergabe deiner Daten an Dritte findet nicht statt. Eine Datenübermittlung in Länder außerhalb der EU bzw. des EWR erfolgt nicht.\\n\\n## 6. Deine Rechte\\n\\nDu hast jederzeit das Recht auf:\\n- Auskunft über die zu deiner Person gespeicherten Daten (Art. 15 DSGVO)\\n- Berichtigung unrichtiger Daten (Art. 16 DSGVO)\\n- Löschung (Art. 17 DSGVO)\\n- Einschränkung der Verarbeitung (Art. 18 DSGVO)\\n- Datenübertragbarkeit (Art. 20 DSGVO)\\n- Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)\\n\\nZur Ausübung genügt eine formlose Nachricht an die oben genannte E-Mail-Adresse.\\n\\n## 7. Beschwerderecht bei der Aufsichtsbehörde\\n\\nDu hast das Recht, dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO). Zuständig ist die Aufsichtsbehörde deines Bundeslandes bzw. des Seitenbetreibers: [zuständige Landesdatenschutzbehörde eintragen].\\n\\n## 8. Keine automatisierte Entscheidungsfindung\\n\\nEine automatisierte Entscheidungsfindung einschließlich Profiling nach Art. 22 DSGVO findet nicht statt.\\n\\n---\\n\\n*Hinweis für den Seitenbetreiber: Platzhalter in eckigen Klammern ausfüllen. Aufbewahrungsdauer der Logfiles an deinen Server bzw. Reverse-Proxy anpassen. Sobald externe Dienste oder ein Kontaktformular eingebunden werden, diesen Text ergänzen. Stand: [Datum eintragen].*\"}",
|
||||
Order = 1,
|
||||
PageId = new Guid("51720001-0000-0000-0000-000000000008"),
|
||||
Type = "RichText"
|
||||
@@ -420,7 +420,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
CanonicalGenotype = "aa Ccchm DD EE GG pp spsp rere",
|
||||
Name = "Saphir",
|
||||
SortOrder = 24
|
||||
},
|
||||
@@ -441,7 +441,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
CanonicalGenotype = "aa CC DD EE GG pp spsp rere",
|
||||
CanonicalGenotype = "aa Cch DD EE GG pp spsp rere",
|
||||
Name = "Platin-Hell",
|
||||
SortOrder = 27
|
||||
},
|
||||
@@ -483,28 +483,28 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000034"),
|
||||
CanonicalGenotype = "AA CC DD efef gg PP spsp rere",
|
||||
CanonicalGenotype = "AA CC DD efe gg PP spsp rere",
|
||||
Name = "Polarfuchsschimmel",
|
||||
SortOrder = 37
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000035"),
|
||||
CanonicalGenotype = "AA CC DD efef GG PP spsp rere",
|
||||
CanonicalGenotype = "AA CC DD efe GG PP spsp rere",
|
||||
Name = "Algierfuchsschimmel",
|
||||
SortOrder = 38
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000036"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel",
|
||||
SortOrder = 39
|
||||
},
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000037"),
|
||||
CanonicalGenotype = "aa CC DD efef gg PP spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe gg PP spsp rere",
|
||||
Name = "Blaufuchsschimmel",
|
||||
SortOrder = 40
|
||||
},
|
||||
@@ -525,7 +525,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000040"),
|
||||
CanonicalGenotype = "AA CC DD efef GG pp spsp rere",
|
||||
CanonicalGenotype = "AA CC DD efe GG pp spsp rere",
|
||||
Name = "Goldfuchsschimmel",
|
||||
SortOrder = 43
|
||||
},
|
||||
@@ -546,7 +546,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000043"),
|
||||
CanonicalGenotype = "aa CC DD efef GG pp spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe GG pp spsp rere",
|
||||
Name = "Rotfuchsschimmel",
|
||||
SortOrder = 46
|
||||
},
|
||||
@@ -560,7 +560,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000045"),
|
||||
CanonicalGenotype = "aa CC DD efef GG PP spsp rere",
|
||||
CanonicalGenotype = "aa CC DD efe GG PP spsp rere",
|
||||
Name = "Kohlfuchsschimmel, hell",
|
||||
SortOrder = 48
|
||||
},
|
||||
@@ -595,7 +595,7 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
new
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000050"),
|
||||
CanonicalGenotype = "aa CC dd ee gg pp spsp rere",
|
||||
CanonicalGenotype = "aa CC dd ee gg PP spsp rere",
|
||||
Name = "Dilute Blaufuchs",
|
||||
SortOrder = 53
|
||||
},
|
||||
@@ -756,6 +756,11 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<bool>("IsBreeder")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsReceiver")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -893,6 +898,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<DateTimeOffset?>("AnsweredAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ClientTimestamp")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -906,6 +914,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -915,6 +926,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid?>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool?>("Helpful")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid?>("LitterId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -925,6 +939,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("Question")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReopenedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("ResolvedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -948,12 +965,47 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Feedback");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.FeedbackAttachment", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<byte[]>("Data")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<Guid>("FeedbackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Size")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("FeedbackAttachments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int?>("BirthOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CauseOfDeath")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -998,6 +1050,11 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<bool?>("IsDeaf")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsResident")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
@@ -1060,6 +1117,51 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("Gerbils");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilOverride", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntityName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("GerbilId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LastImportDiffJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LastImportSnapshotJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("OverrideJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SnapshotJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("VerifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("GerbilId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("GerbilOverrides");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1143,6 +1245,11 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid?>("FatherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("LitterLetter")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -1162,6 +1269,12 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<string>("Provenance")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("ShowInChronicle")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int?>("Stillborn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("TotalBorn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -1602,6 +1715,32 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.ToTable("WaitingListEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WebPushSubscription", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Auth")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Endpoint")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("P256dh")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WebPushSubscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -30,5 +30,10 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// 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; }
|
||||
|
||||
/// <summary>true = manually created in the UI; false = produced by an import path. The ingest
|
||||
/// re-import wipe deletes ONLY IsManual=false contacts, so a manually-added contact (e.g. the
|
||||
/// Herkunft/Abnehmer of a manual animal) is never wiped. All import paths leave this false.</summary>
|
||||
public bool IsManual { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,33 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// <summary>When the ticket was marked resolved; null while open.</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the ticket was reopened FROM a resolved state that had no pending Rückfrage —
|
||||
/// i.e. a genuinely-fixed ticket the breeder wants reworked. Stays null for tickets that
|
||||
/// merely had an open question, got marked resolved, and were reopened (those just return
|
||||
/// to their existing Rückfrage). Shown as "Wieder geöffnet am" in the UI.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReopenedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SOFT-DELETE: when the breeder deleted the ticket via the UI. Soft-deleted tickets are
|
||||
/// NOT removed from the DB — they move into the "Gelöscht" category and can be restored
|
||||
/// (DeletedAt → null) from there. null = not deleted.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optionale Kategorie/Thema (von der KI gesetzt) für Filter + Übersicht, z. B.
|
||||
/// "Genetik", "Import", "Stammbaum", "Daten", "Foto". Frei wählbar (kein FK), null = ohne.
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// War die Lösung hilfreich? (Daumen hoch/runter auf gelösten Tickets.) true = 👍,
|
||||
/// false = 👎 (löst i. d. R. ein Wiederöffnen aus), null = noch keine Rückmeldung.
|
||||
/// </summary>
|
||||
public bool? Helpful { get; set; }
|
||||
|
||||
/// <summary>A clarifying question (Rückfrage) a maintainer attaches to the ticket; null if none.</summary>
|
||||
public string? Question { get; set; }
|
||||
|
||||
|
||||
31
GerbilManagerWebAPI/Models/FeedbackAttachment.cs
Normal file
31
GerbilManagerWebAPI/Models/FeedbackAttachment.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// An image/file attached to a feedback ticket (z. B. ein Foto vom Tier/Fellschlag/Stammbaum).
|
||||
/// Like <see cref="Feedback"/> it is decoupled (loose FeedbackId, no FK) so it survives the
|
||||
/// import re-ingest wipe. The bytes live in the DB (single-user app, gelegentliche Fotos) —
|
||||
/// die Liste GET /feedback liefert nur Metadaten, die Bytes kommen über einen eigenen Endpoint.
|
||||
/// </summary>
|
||||
public class FeedbackAttachment
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the feedback ticket this belongs to.</summary>
|
||||
public Guid FeedbackId { get; set; }
|
||||
|
||||
public required string FileName { get; set; }
|
||||
|
||||
public required string ContentType { get; set; }
|
||||
|
||||
/// <summary>Größe in Bytes (separat gespeichert, damit Listen-Abfragen die Bytes nicht laden).</summary>
|
||||
public int Size { get; set; }
|
||||
|
||||
/// <summary>The raw file bytes.</summary>
|
||||
public required byte[] Data { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,16 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// imported animals. See hive/agents/god/OWNERSHIP-residency.md. Gridify-filterable.</summary>
|
||||
public bool IsResident { get; set; } = true;
|
||||
public bool IsCastrated { get; set; }
|
||||
|
||||
/// <summary>true = manually created in the UI (POST /gerbils); false = produced by an import
|
||||
/// path. The ingest re-import wipe deletes ONLY IsManual=false rows, so manually-added animals
|
||||
/// (and their sub-records) are never wiped or overwritten. All import paths leave this false.</summary>
|
||||
public bool IsManual { get; set; }
|
||||
|
||||
/// <summary>Reihenfolge der Jungtiere innerhalb ihres Wurfs (aus der Wurfchronik, sortiert nach
|
||||
/// Geburtsgewicht). null = unbekannt → alphabetische Sortierung als Fallback. Wird im Import aus
|
||||
/// conflict-decisions.json (`litterOrder`) gesetzt; von der Wurf- und Akten-Anzeige genutzt.</summary>
|
||||
public int? BirthOrder { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
|
||||
|
||||
62
GerbilManagerWebAPI/Models/GerbilOverride.cs
Normal file
62
GerbilManagerWebAPI/Models/GerbilOverride.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// VERIFIED/PROTECTED OVERRIDE: authoritative, hand-curated per-gerbil field values. Decoupled
|
||||
/// from the rest of the model on purpose — <see cref="GerbilId"/> is a plain Guid column (NOT an
|
||||
/// enforced foreign key, no navigation property), so the import re-ingest wipe
|
||||
/// (<c>IngestResolvedService</c>) can delete and reload gerbils without deleting or breaking
|
||||
/// override rows. After every ingest the override is re-applied on top of the fresh import (the
|
||||
/// "freeze"), so the breeder's curated values are never lost. Matched to the (deterministic)
|
||||
/// gerbil id. Same ingest-surviving pattern as <see cref="Feedback"/>.
|
||||
///
|
||||
/// Two flavours, both stored here:
|
||||
/// - <see cref="IsVerified"/> = true → "vollständig korrekt": the whole own-field freeze set is
|
||||
/// pinned, and a full Akte snapshot (<see cref="SnapshotJson"/>) is kept for the drift report,
|
||||
/// export and regression test.
|
||||
/// - <see cref="IsVerified"/> = false → "geschützt": created by a manual edit; only the changed
|
||||
/// fields are pinned (per-field) so untouched fields keep receiving import improvements.
|
||||
/// </summary>
|
||||
public class GerbilOverride
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Loose reference (no FK) to the gerbil this override curates.</summary>
|
||||
public Guid GerbilId { get; set; }
|
||||
|
||||
/// <summary>Captured gerbil name — keeps the row human-readable and survives an ingest wipe.</summary>
|
||||
public string? EntityName { get; set; }
|
||||
|
||||
/// <summary>true = "vollständig korrekt" (full freeze + certified); false = "geschützt" (per-field edit).</summary>
|
||||
public bool IsVerified { get; set; }
|
||||
|
||||
/// <summary>Frozen own-field values as a JSON object (field name → value). Verified rows carry
|
||||
/// the full freeze set; protected rows carry only the manually-changed fields. Re-applied on
|
||||
/// top of the fresh import after every ingest.</summary>
|
||||
public required string OverrideJson { get; set; }
|
||||
|
||||
/// <summary>Full human-readable Akte snapshot (self + parents + own litters/children) captured
|
||||
/// at verify time — feeds the drift report, export and regression test. null for protected-only rows.</summary>
|
||||
public string? SnapshotJson { get; set; }
|
||||
|
||||
/// <summary>Raw-import Akte snapshot captured during the LAST ingest, BEFORE the freeze was
|
||||
/// applied — lets the UI show where the raw import disagrees with the golden state. null until
|
||||
/// the first ingest after verifying.</summary>
|
||||
public string? LastImportSnapshotJson { get; set; }
|
||||
|
||||
/// <summary>Cached diff (golden ↔ last raw import) as a JSON array, computed at ingest time.
|
||||
/// null/empty = no drift / not yet computed.</summary>
|
||||
public string? LastImportDiffJson { get; set; }
|
||||
|
||||
/// <summary>Optional free-text note the breeder attached when verifying.</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>When the row was marked "vollständig korrekt"; null for protected-only rows.</summary>
|
||||
public DateTimeOffset? VerifiedAt { get; set; }
|
||||
|
||||
/// <summary>When the override was last created/updated.</summary>
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,11 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// Null = unbekannt (Altdaten). Must be ≤ TotalBorn if both are set.</summary>
|
||||
public int? DeathsWithin8Weeks { get; set; }
|
||||
|
||||
/// <summary>Totgeburten (TG): tot geborene Jungtiere dieses Wurfs. Getrennt von
|
||||
/// DeathsWithin8Weeks (in den ersten 8 Wochen verstorben). Aus der xlsx-Wurfchronik
|
||||
/// (litters.json breakdown) importiert. Null = unbekannt/nicht erfasst.</summary>
|
||||
public int? Stillborn { get; set; }
|
||||
|
||||
public Guid? FatherId { get; set; }
|
||||
public Gerbil? Father { get; set; }
|
||||
public Guid? MotherId { get; set; }
|
||||
@@ -45,5 +50,17 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// 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; }
|
||||
|
||||
/// <summary>Steuert, ob dieser Wurf in der Wurfchronik (Würfe-Liste) erscheint.
|
||||
/// true = normal sichtbar. false = aus der Wurfchronik ausgeblendet, aber weiterhin
|
||||
/// auf der Tier-Detailseite des Elterntiers sichtbar — z. B. ein extern (bei einer
|
||||
/// anderen Zucht) entstandener Wurf eines nicht-residenten Tieres, der nur zur
|
||||
/// Dokumentation der Abstammung gehört. Default true (alle bestehenden Würfe).</summary>
|
||||
public bool ShowInChronicle { get; set; } = true;
|
||||
|
||||
/// <summary>true = manually created in the UI; false = produced by an import path. The ingest
|
||||
/// re-import wipe deletes ONLY IsManual=false litters, so a manually-added litter (e.g. the
|
||||
/// birth litter of a manual animal) is never wiped. All import paths leave this false.</summary>
|
||||
public bool IsManual { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
26
GerbilManagerWebAPI/Models/WebPushSubscription.cs
Normal file
26
GerbilManagerWebAPI/Models/WebPushSubscription.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace GerbilManagerWebAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// A browser's Web-Push subscription (PWA-Benachrichtigungen). Single-user app, daher i. d. R.
|
||||
/// nur wenige Einträge (ein Gerät der Züchterin). Endpoint ist eindeutig; veraltete Abos werden
|
||||
/// beim Senden (404/410) automatisch entfernt.
|
||||
/// </summary>
|
||||
public class WebPushSubscription
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Die vom Browser vergebene Push-Endpoint-URL (eindeutig).</summary>
|
||||
public required string Endpoint { get; set; }
|
||||
|
||||
/// <summary>Öffentlicher Client-Schlüssel (keys.p256dh).</summary>
|
||||
public required string P256dh { get; set; }
|
||||
|
||||
/// <summary>Auth-Secret des Clients (keys.auth).</summary>
|
||||
public required string Auth { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailReader, GerbilMan
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.RequestSyncService>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.IGmailMailSender, GerbilManagerWebAPI.Inbox.GmailMailSender>();
|
||||
builder.Services.AddScoped<GerbilManagerWebAPI.Inbox.SendReplyService>();
|
||||
builder.Services.AddSingleton<GerbilManagerWebAPI.Push.PushNotifier>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -89,20 +90,24 @@ if (!app.Environment.IsEnvironment("Testing"))
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
db.Database.Migrate();
|
||||
|
||||
// Startup sweep: derive status for animals that silently crossed the 7-year threshold
|
||||
// since the last write. No-op if all statuses are already current.
|
||||
// Startup sweep: heal any stored status that disagrees with the derived status — animals
|
||||
// that silently crossed the 7-year threshold (→ Deceased), or whose death date / Abgabe was
|
||||
// set on a path that didn't re-derive the status (e.g. a frozen GerbilOverride re-applied after
|
||||
// ingest — Ticket 37ab228a "Gaida"). Only currently-active animals (Breeding/Pet/ForSale) are
|
||||
// considered, so nobody gets un-deceased. No-op if all statuses are already current.
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var candidates = await db.Gerbils
|
||||
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway
|
||||
&& g.DateOfDeath == null && g.ReceiverContactId == null
|
||||
&& g.DateOfBirth != null
|
||||
&& g.DateOfBirth < today.AddYears(-GerbilStatusService.MaxAgeYears))
|
||||
.Where(g => g.Status != GerbilStatus.Deceased && g.Status != GerbilStatus.GivenAway)
|
||||
.ToListAsync();
|
||||
if (candidates.Count > 0)
|
||||
var healed = 0;
|
||||
foreach (var g in candidates)
|
||||
{
|
||||
foreach (var g in candidates) g.Status = GerbilStatus.Deceased;
|
||||
await db.SaveChangesAsync();
|
||||
var derived = GerbilStatusService.Derive(g.Status, g.DateOfBirth, g.DateOfDeath,
|
||||
isAbgegeben: g.ReceiverContactId is not null, today);
|
||||
if (derived != g.Status) { g.Status = derived; healed++; }
|
||||
}
|
||||
if (healed > 0)
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
app.UseCors(LanCorsPolicy);
|
||||
@@ -127,6 +132,8 @@ app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
app.MapFeedbackEndpoints();
|
||||
app.MapVerifiedGerbilEndpoints();
|
||||
app.MapPushEndpoints();
|
||||
app.MapAcquisitionEndpoints();
|
||||
app.MapSaleReservationEndpoints();
|
||||
app.MapWaitingListEndpoints();
|
||||
|
||||
76
GerbilManagerWebAPI/Push/PushNotifier.cs
Normal file
76
GerbilManagerWebAPI/Push/PushNotifier.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using System.Text.Json;
|
||||
using GerbilManagerWebAPI.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WebPush;
|
||||
|
||||
namespace GerbilManagerWebAPI.Push
|
||||
{
|
||||
/// <summary>
|
||||
/// Versendet Web-Push-Benachrichtigungen an alle gespeicherten Abos (PWA der Züchterin).
|
||||
/// Ist kein VAPID-Schlüsselpaar konfiguriert, sind die Methoden No-Ops (Push deaktiviert).
|
||||
/// Veraltete Abos (404/410) werden beim Senden entfernt.
|
||||
/// </summary>
|
||||
public class PushNotifier
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<PushNotifier> _log;
|
||||
private readonly VapidDetails? _vapid;
|
||||
|
||||
public PushNotifier(IConfiguration config, IServiceScopeFactory scopeFactory, ILogger<PushNotifier> log)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_log = log;
|
||||
var subject = config["WebPush:Subject"];
|
||||
var publicKey = config["WebPush:PublicKey"];
|
||||
var privateKey = config["WebPush:PrivateKey"];
|
||||
if (!string.IsNullOrWhiteSpace(subject)
|
||||
&& !string.IsNullOrWhiteSpace(publicKey)
|
||||
&& !string.IsNullOrWhiteSpace(privateKey))
|
||||
{
|
||||
_vapid = new VapidDetails(subject, publicKey, privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>true, wenn Push konfiguriert ist (VAPID-Schlüssel vorhanden).</summary>
|
||||
public bool Enabled => _vapid is not null;
|
||||
|
||||
public string? PublicKey => _vapid?.PublicKey;
|
||||
|
||||
/// <summary>Eine Benachrichtigung an ALLE Abos senden. Fehler einzelner Abos werden geschluckt.</summary>
|
||||
public async Task NotifyAllAsync(string title, string body, string url, CancellationToken ct = default)
|
||||
{
|
||||
if (_vapid is null) return;
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
|
||||
var subs = await db.WebPushSubscriptions.ToListAsync(ct);
|
||||
if (subs.Count == 0) return;
|
||||
|
||||
var client = new WebPushClient();
|
||||
var payload = JsonSerializer.Serialize(new { title, body, url });
|
||||
var stale = new List<WebPushSubscription>();
|
||||
foreach (var s in subs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pushSub = new WebPush.PushSubscription(s.Endpoint, s.P256dh, s.Auth);
|
||||
await client.SendNotificationAsync(pushSub, payload, _vapid);
|
||||
}
|
||||
catch (WebPushException ex) when (ex.StatusCode is System.Net.HttpStatusCode.NotFound
|
||||
or System.Net.HttpStatusCode.Gone)
|
||||
{
|
||||
stale.Add(s); // Abo abgelaufen/abgemeldet -> aufräumen
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex, "Push an {Endpoint} fehlgeschlagen", s.Endpoint);
|
||||
}
|
||||
}
|
||||
if (stale.Count > 0)
|
||||
{
|
||||
db.WebPushSubscriptions.RemoveRange(stale);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,10 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"WebPush": {
|
||||
"Subject": "mailto:zucht-kleine-chaoten@example.com",
|
||||
"PublicKey": "BGRqOfadYjRZFJaFYjPq2cThD7MpMHJHiRPrv9ZGSHJAl5eeqeCCZHj4I4_h-RcrjCF2MXhN34dI_RcACz36qnk",
|
||||
"PrivateKey": "_HJTA1qtsJ-azLAaRFyxJqmJCym2qBQaSbYFCLjVSAA"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
# Sicheres Datenbankpasswort (mind. 20 Zeichen, keine Anführungszeichen)
|
||||
POSTGRES_PASSWORD=aendere_mich_bitte
|
||||
|
||||
# Externer Port fuer das Frontend (Standard: 80)
|
||||
PORT=80
|
||||
# Externer Port fuer das Frontend. NAS-Port 80 ist durch den nginx-Reverse-Proxy
|
||||
# belegt -> Standard 8090 (Zugriff: http://truenas:8090/).
|
||||
PORT=8090
|
||||
|
||||
# Container Registry (git.rismer.de/gulum)
|
||||
REGISTRY=git.rismer.de/gulum
|
||||
|
||||
@@ -11,15 +11,18 @@
|
||||
services:
|
||||
|
||||
# --- PostgreSQL-Datenbank ---
|
||||
# postgres:18 (Debian): Locale en_US.utf8 == Quell-DB (Aspire) -> sauberer Dump-Restore.
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
image: postgres:18
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: gerbilmanager
|
||||
POSTGRES_USER: postgres
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
# postgres:18: Volume auf /var/lib/postgresql (PGDATA landet in .../18/docker).
|
||||
# Mount auf .../data laesst v18 nicht starten -> db unhealthy.
|
||||
- pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d gerbilmanager"]
|
||||
interval: 10s
|
||||
@@ -58,7 +61,7 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -72,7 +75,8 @@ services:
|
||||
dockerfile: gerbil-manager-web/Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-80}:80"
|
||||
# NAS-Port 80 ist durch den nginx-Reverse-Proxy belegt -> Default 8090.
|
||||
- "${PORT:-8090}:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
@@ -94,7 +98,7 @@ services:
|
||||
|
||||
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
||||
backup:
|
||||
image: postgres:17-alpine
|
||||
image: postgres:18
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGPASSWORD: "${POSTGRES_PASSWORD}"
|
||||
|
||||
116
deploy/truenas/custom-app.compose.yaml
Normal file
116
deploy/truenas/custom-app.compose.yaml
Normal file
@@ -0,0 +1,116 @@
|
||||
# GerbilManager — TrueNAS SCALE Custom App (Goldeye 25.10)
|
||||
# =========================================================
|
||||
# Diese Datei ist die VORLAGE fuer die TrueNAS-"Custom App" (taucht unter Apps auf).
|
||||
# Sie wird NICHT direkt mit `docker compose` gestartet, sondern von
|
||||
# deploy/truenas/scripts/truenas-deploy.sh
|
||||
# gerendert (Platzhalter aus deploy/truenas/.env eingesetzt) und via
|
||||
# midclt call app.create {custom_app:true, app_name:gerbilmanager, custom_compose_config_string:<yaml>}
|
||||
# an die TrueNAS-Middleware uebergeben. Redeploys laufen ueber `midclt call app.redeploy`.
|
||||
#
|
||||
# Unterschiede zur lokalen deploy/truenas/compose.yaml:
|
||||
# * ABSOLUTE Host-Bind-Pfade (Custom Apps kennen keine relativen ./-Mounts) —
|
||||
# gleiche Konvention wie die anderen Apps dieser NAS (/mnt/JailStorage/DockerVolumes/...).
|
||||
# * pull_policy: always -> `app.redeploy` zieht das frische :latest aus der Registry.
|
||||
# * kein publicsite-Container (oeffentliche Seite ist ein separater Schritt mit Domain/Proxy).
|
||||
#
|
||||
# Platzhalter (werden von truenas-deploy.sh aus .env ersetzt):
|
||||
# __POSTGRES_PASSWORD__ __REGISTRY__ __TAG__ __PORT__ __AI_BASEURL__ __AI_APIKEY__ __AI_MODEL__
|
||||
|
||||
services:
|
||||
|
||||
# --- PostgreSQL 18 (Debian-Image: Locale en_US.utf8 == Quell-DB) ---
|
||||
db:
|
||||
image: postgres:18
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_PASSWORD: "__POSTGRES_PASSWORD__"
|
||||
POSTGRES_DB: gerbilmanager
|
||||
POSTGRES_USER: postgres
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
# postgres:18 legt PGDATA unter /var/lib/postgresql/18/docker ab und deklariert
|
||||
# das Volume als /var/lib/postgresql (NICHT .../data). Mount an .../data -> Start
|
||||
# scheitert ("data in unused mount/volume") -> db unhealthy.
|
||||
- /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d gerbilmanager"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# --- .NET API ---
|
||||
api:
|
||||
image: "__REGISTRY__/gerbilmanager-api:__TAG__"
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
ConnectionStrings__gerbilmanager: "Host=db;Port=5432;Database=gerbilmanager;Username=postgres;Password=__POSTGRES_PASSWORD__"
|
||||
Photos__RootPath: /data/photos
|
||||
DataProtection__KeyRingPath: /data/keys
|
||||
PublicSite__RootPath: /data/publicsite
|
||||
AI__BaseUrl: "__AI_BASEURL__"
|
||||
AI__ApiKey: "__AI_APIKEY__"
|
||||
AI__Model: "__AI_MODEL__"
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
- /mnt/JailStorage/DockerVolumes/gerbilmanager/photos:/data/photos
|
||||
- /mnt/JailStorage/DockerVolumes/gerbilmanager/keys:/data/keys
|
||||
- /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite:/data/publicsite
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 90s
|
||||
|
||||
# --- Frontend (nginx: React-SPA + /api-Proxy) ---
|
||||
frontend:
|
||||
image: "__REGISTRY__/gerbilmanager-frontend:__TAG__"
|
||||
pull_policy: always
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "__PORT__:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
# --- Oeffentliche Seite (WEB-2), vorerst nur INTERN gehostet ---
|
||||
# Serviert NUR statisches HTML aus dem publicsite-Volume (live/), das die API via
|
||||
# POST /api/publish schreibt. Kein Proxy auf api/frontend. Solange nichts publiziert
|
||||
# wurde, ist das Volume leer -> 404 (erwartet). Extern (Domain/TLS) ist ein spaeterer
|
||||
# Schritt ueber Julians Reverse-Proxy (siehe deploy/truenas/vhost-snippet.conf).
|
||||
publicsite:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "__PUBLICSITE_PORT__:80"
|
||||
volumes:
|
||||
- /mnt/JailStorage/DockerVolumes/gerbilmanager/publicsite:/usr/share/nginx/html:ro
|
||||
- /mnt/JailStorage/DockerVolumes/gerbilmanager/deploy/truenas/nginx/publicsite.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
|
||||
# --- Backup-Sidecar (taeglicher pg_dump + Foto-Archiv + Rotation) ---
|
||||
backup:
|
||||
image: postgres:18
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGPASSWORD: "__POSTGRES_PASSWORD__"
|
||||
POSTGRES_HOST: db
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: gerbilmanager
|
||||
BACKUP_KEEP_DAYS: "7"
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
- /mnt/JailStorage/DockerVolumes/gerbilmanager/photos:/data/photos:ro
|
||||
- /mnt/JailStorage/DockerVolumes/gerbilmanager/backups:/backups
|
||||
- /mnt/JailStorage/DockerVolumes/gerbilmanager/scripts:/scripts:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
entrypoint: ["/bin/sh", "/scripts/entrypoint.sh"]
|
||||
@@ -1,6 +1,11 @@
|
||||
# GerbilManager — publicsite nginx (WEB-2)
|
||||
# Serviert die statische oeffentliche Seite aus dem live/-Verzeichnis des Shared Volumes.
|
||||
# SICHERHEIT: Kein Proxy auf die API, kein Zugriff auf den Manager.
|
||||
#
|
||||
# Security-Header (Art. 32 DSGVO — technische Massnahmen): MUESSEN pro location wiederholt
|
||||
# werden, da nginx server-weite add_header verwirft, sobald eine location eigene add_header hat.
|
||||
# HSTS wird NICHT hier gesetzt (HTTP-only-Container) — sondern am TLS-terminierenden
|
||||
# Reverse-Proxy (siehe deploy/truenas/vhost-snippet.conf).
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html/live;
|
||||
@@ -11,15 +16,23 @@ server {
|
||||
location / {
|
||||
try_files $uri $uri/index.html =404;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; form-action 'self'" always;
|
||||
}
|
||||
|
||||
# CSS/Bilder: kurze TTL (1 Tag)
|
||||
# CSS/Bilder: kurze TTL (1 Tag) — Security-Header wiederholen (s. o.)
|
||||
location ~* \.(css|png|jpg|jpeg|gif|ico|webp|svg)$ {
|
||||
try_files $uri =404;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; form-action 'self'" always;
|
||||
}
|
||||
|
||||
# Kein Zugriff auf Staging-Verzeichnisse
|
||||
|
||||
91
deploy/truenas/scripts/deploy.sh
Normal file
91
deploy/truenas/scripts/deploy.sh
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/bin/sh
|
||||
# GerbilManager Deploy-Skript (laeuft auf der TrueNAS Goldeye)
|
||||
# ===========================================================
|
||||
# Holt die neuen Images aus der Gitea-Registry und rollt den Compose-Stack neu aus.
|
||||
# Wird vom Gitea-Actions 'deploy'-Job (self-hosted Runner auf der NAS) aufgerufen
|
||||
# UND kann jederzeit manuell auf der NAS ausgefuehrt werden:
|
||||
#
|
||||
# sh /opt/gerbilmanager/deploy/truenas/scripts/deploy.sh
|
||||
# TAG=<git-sha> sh .../deploy.sh # Rollback auf ein bestimmtes Image
|
||||
# GIT_PULL=0 sh .../deploy.sh # ohne 'git pull' (nur Images neu ziehen)
|
||||
#
|
||||
# Konfiguration ueber Umgebungsvariablen (alle optional, sinnvolle Defaults):
|
||||
# COMPOSE_DIR Installationsverzeichnis (Default: /opt/gerbilmanager)
|
||||
# TAG Image-Tag (Default: latest; fuer Rollback z. B. ein git-SHA)
|
||||
# GIT_PULL 1 = vorher 'git pull' (Default), 0 = ueberspringen
|
||||
# HEALTH_TIMEOUT Sekunden auf api-Healthcheck warten (Default: 180)
|
||||
# REGISTRY / REGISTRY_USER / REGISTRY_TOKEN optionaler docker login vor dem Pull
|
||||
set -eu
|
||||
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/opt/gerbilmanager}"
|
||||
COMPOSE_FILE="$COMPOSE_DIR/deploy/truenas/compose.yaml"
|
||||
TAG="${TAG:-latest}"
|
||||
GIT_PULL="${GIT_PULL:-1}"
|
||||
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-180}"
|
||||
REGISTRY="${REGISTRY:-git.rismer.de}"
|
||||
|
||||
log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1"; }
|
||||
fail() { log "FEHLER: $1"; exit 1; }
|
||||
|
||||
export TAG # compose interpoliert ${TAG} in den Image-Referenzen
|
||||
|
||||
log "=== Deploy gestartet (TAG=$TAG, dir=$COMPOSE_DIR) ==="
|
||||
|
||||
[ -f "$COMPOSE_FILE" ] || fail "compose.yaml nicht gefunden: $COMPOSE_FILE"
|
||||
command -v docker >/dev/null 2>&1 || fail "docker nicht im PATH des Runners"
|
||||
docker compose version >/dev/null 2>&1 || fail "'docker compose' (v2) nicht verfuegbar"
|
||||
|
||||
cd "$COMPOSE_DIR"
|
||||
|
||||
# --- 1. Repo aktualisieren (compose.yaml / Skripte / Docs) ---
|
||||
if [ "$GIT_PULL" = "1" ] && [ -d "$COMPOSE_DIR/.git" ]; then
|
||||
log "git pull --ff-only ..."
|
||||
git -C "$COMPOSE_DIR" pull --ff-only || log "WARNUNG: git pull fehlgeschlagen — fahre mit vorhandenem Stand fort"
|
||||
fi
|
||||
|
||||
# --- 2. Registry-Login (nur wenn Credentials uebergeben wurden) ---
|
||||
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_TOKEN:-}" ]; then
|
||||
log "docker login $REGISTRY ..."
|
||||
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin >/dev/null \
|
||||
|| fail "docker login fehlgeschlagen"
|
||||
fi
|
||||
|
||||
# --- 3. Neue Images ziehen ---
|
||||
log "Ziehe Images (Tag: $TAG) ..."
|
||||
docker compose -f "$COMPOSE_FILE" pull || fail "docker compose pull fehlgeschlagen"
|
||||
|
||||
# --- 4. Stack neu ausrollen (nur geaenderte Container werden neu erstellt) ---
|
||||
log "Rolle Stack aus (up -d) ..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d --remove-orphans || fail "docker compose up fehlgeschlagen"
|
||||
|
||||
# --- 5. Auf api-Healthcheck warten (impliziert db gesund) ---
|
||||
log "Warte auf api-Healthcheck (max ${HEALTH_TIMEOUT}s) ..."
|
||||
API_CID="$(docker compose -f "$COMPOSE_FILE" ps -q api)"
|
||||
[ -n "$API_CID" ] || fail "api-Container nicht gefunden"
|
||||
|
||||
elapsed=0
|
||||
while :; do
|
||||
state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$API_CID" 2>/dev/null || echo unknown)"
|
||||
case "$state" in
|
||||
healthy) log "api ist gesund."; break ;;
|
||||
none|unknown)
|
||||
# Kein Healthcheck definiert/lesbar -> auf laufenden Zustand zurueckfallen.
|
||||
running="$(docker inspect --format '{{.State.Running}}' "$API_CID" 2>/dev/null || echo false)"
|
||||
[ "$running" = "true" ] && { log "api laeuft (kein Healthcheck-Status lesbar)."; break; }
|
||||
;;
|
||||
esac
|
||||
if [ "$elapsed" -ge "$HEALTH_TIMEOUT" ]; then
|
||||
log "Letzte api-Logs:"; docker compose -f "$COMPOSE_FILE" logs --tail 40 api || true
|
||||
fail "api wurde nicht innerhalb ${HEALTH_TIMEOUT}s gesund (Status: $state)"
|
||||
fi
|
||||
sleep 5; elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
# --- 6. Alte/ungenutzte Images aufraeumen ---
|
||||
log "Raeume verwaiste Images auf ..."
|
||||
docker image prune -f >/dev/null 2>&1 || true
|
||||
|
||||
log "Laufende Dienste:"
|
||||
docker compose -f "$COMPOSE_FILE" ps --format 'table {{.Service}}\t{{.Status}}' 2>/dev/null \
|
||||
|| docker compose -f "$COMPOSE_FILE" ps
|
||||
log "=== Deploy erfolgreich abgeschlossen (TAG=$TAG) ==="
|
||||
@@ -1,16 +1,28 @@
|
||||
#!/bin/sh
|
||||
# Backup-Sidecar Entrypoint
|
||||
# Installiert den Cron-Job und startet crond im Vordergrund.
|
||||
# ==========================
|
||||
# Image-unabhaengiger Shell-Scheduler (kein cron noetig): laeuft im Vordergrund und
|
||||
# stoesst backup.sh einmal taeglich zur konfigurierten Stunde an. Funktioniert auf
|
||||
# Debian (postgres:18, GNU date) wie auf Alpine — der fruehere crond-Ansatz brauchte
|
||||
# /etc/crontabs (Alpine-only) und crashte im Debian-Image.
|
||||
set -e
|
||||
|
||||
# Cron-Job: taeglich um 03:00 Uhr
|
||||
CRON_SCHEDULE="${BACKUP_CRON:-0 3 * * *}"
|
||||
echo "$CRON_SCHEDULE /bin/sh /scripts/backup.sh >> /backups/backup.log 2>&1" > /etc/crontabs/root
|
||||
BACKUP_HOUR="${BACKUP_HOUR:-3}" # Stunde (0-23) fuer das taegliche Backup
|
||||
HHMM=$(printf '%02d:00' "$BACKUP_HOUR")
|
||||
|
||||
echo "[$(date)] Backup-Sidecar gestartet. Naechste Sicherung: $CRON_SCHEDULE"
|
||||
echo "[$(date)] Backup-Sidecar gestartet (Shell-Scheduler, taeglich $HHMM)."
|
||||
echo "[$(date)] Backup-Verzeichnis: /backups Fotos: /data/photos"
|
||||
|
||||
# Ersten Backup-Lauf direkt beim Start ausfuehren (optional, auskommentieren wenn unerwuenscht)
|
||||
# /bin/sh /scripts/backup.sh
|
||||
|
||||
exec crond -f -l 6
|
||||
while :; do
|
||||
now=$(date +%s)
|
||||
target=$(date -d "$HHMM" +%s 2>/dev/null || echo "")
|
||||
if [ -z "$target" ]; then
|
||||
# Fallback ohne GNU 'date -d': einfach 24h schlafen.
|
||||
sleep 86400
|
||||
else
|
||||
[ "$target" -le "$now" ] && target=$(date -d "$HHMM tomorrow" +%s)
|
||||
sleep "$((target - now))"
|
||||
fi
|
||||
echo "[$(date)] Starte Backup ..."
|
||||
/bin/sh /scripts/backup.sh >> /backups/backup.log 2>&1 || echo "[$(date)] WARNUNG: backup.sh Exit $?"
|
||||
done
|
||||
|
||||
118
deploy/truenas/scripts/truenas-deploy.sh
Normal file
118
deploy/truenas/scripts/truenas-deploy.sh
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/bin/sh
|
||||
# GerbilManager — TrueNAS Custom-App Deploy/Redeploy (laeuft auf dem NAS-HOST)
|
||||
# ===========================================================================
|
||||
# Legt die App als TrueNAS "Custom App" an (sichtbar unter Apps) bzw. rollt sie
|
||||
# neu aus. Wird vom Gitea-Actions-Deploy-Job per SSH aufgerufen UND kann jederzeit
|
||||
# manuell auf dem NAS-Host ausgefuehrt werden:
|
||||
#
|
||||
# sh /opt/gerbilmanager/deploy/truenas/scripts/truenas-deploy.sh
|
||||
#
|
||||
# Voraussetzungen auf dem Host:
|
||||
# * /opt/gerbilmanager/deploy/truenas/custom-app.compose.yaml (Vorlage)
|
||||
# * /opt/gerbilmanager/deploy/truenas/.env (mit POSTGRES_PASSWORD etc. — NICHT im Repo)
|
||||
# * midclt + docker im PATH (auf TrueNAS SCALE gegeben)
|
||||
#
|
||||
# Env-Overrides: COMPOSE_DIR, ENV_FILE, HEALTH_TIMEOUT
|
||||
set -eu
|
||||
|
||||
APP_NAME=gerbilmanager
|
||||
# TrueNAS Goldeye: / (inkl. /opt) ist read-only -> App-Home liegt auf dem Pool.
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/mnt/JailStorage/DockerVolumes/gerbilmanager}"
|
||||
DEPLOY_DIR="$COMPOSE_DIR/deploy/truenas"
|
||||
ENV_FILE="${ENV_FILE:-$DEPLOY_DIR/.env}"
|
||||
TEMPLATE="$DEPLOY_DIR/custom-app.compose.yaml"
|
||||
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-300}"
|
||||
BASE=/mnt/JailStorage/DockerVolumes/gerbilmanager
|
||||
|
||||
log() { echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*"; }
|
||||
fail() { log "FEHLER: $*"; exit 1; }
|
||||
|
||||
[ -f "$TEMPLATE" ] || fail "Vorlage fehlt: $TEMPLATE"
|
||||
[ -f "$ENV_FILE" ] || fail ".env fehlt: $ENV_FILE (aus .env.example erstellen)"
|
||||
command -v midclt >/dev/null 2>&1 || fail "midclt nicht im PATH"
|
||||
command -v docker >/dev/null 2>&1 || fail "docker nicht im PATH"
|
||||
|
||||
# --- .env laden ---
|
||||
set -a; . "$ENV_FILE"; set +a
|
||||
: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env}"
|
||||
export REGISTRY="${REGISTRY:-git.rismer.de/gulum}"
|
||||
export TAG="${TAG:-latest}"
|
||||
export PORT="${PORT:-8090}"
|
||||
export PUBLICSITE_PORT="${PUBLICSITE_PORT:-8081}"
|
||||
|
||||
log "=== Deploy $APP_NAME (TAG=$TAG, PORT=$PORT) ==="
|
||||
|
||||
# --- Host-Verzeichnisse sicherstellen ---
|
||||
for d in pgdata photos keys backups publicsite scripts; do mkdir -p "$BASE/$d"; done
|
||||
chown -R 999:999 "$BASE/pgdata" 2>/dev/null || true
|
||||
chmod 700 "$BASE/pgdata" 2>/dev/null || true
|
||||
# Backup-Sidecar-Skripte an den absoluten Mount-Pfad kopieren
|
||||
cp "$DEPLOY_DIR/scripts/backup.sh" "$DEPLOY_DIR/scripts/entrypoint.sh" \
|
||||
"$DEPLOY_DIR/scripts/restore.sh" "$BASE/scripts/" 2>/dev/null || true
|
||||
|
||||
# --- Frische Images ziehen (garantiert :latest, unabhaengig vom pull_policy) ---
|
||||
log "Ziehe Images ..."
|
||||
docker pull "$REGISTRY/gerbilmanager-api:$TAG" || fail "docker pull api fehlgeschlagen"
|
||||
docker pull "$REGISTRY/gerbilmanager-frontend:$TAG" || fail "docker pull frontend fehlgeschlagen"
|
||||
|
||||
# --- Vorlage rendern (python: sichere Substitution) ---
|
||||
RENDERED="$(mktemp)"
|
||||
trap 'rm -f "$RENDERED"' EXIT
|
||||
python3 - "$TEMPLATE" > "$RENDERED" <<'PY'
|
||||
import sys, os
|
||||
tmpl = open(sys.argv[1], encoding="utf-8").read()
|
||||
repl = {
|
||||
"__POSTGRES_PASSWORD__": os.environ["POSTGRES_PASSWORD"],
|
||||
"__REGISTRY__": os.environ.get("REGISTRY", "git.rismer.de/gulum"),
|
||||
"__TAG__": os.environ.get("TAG", "latest"),
|
||||
"__PORT__": os.environ.get("PORT", "8090"),
|
||||
"__PUBLICSITE_PORT__": os.environ.get("PUBLICSITE_PORT", "8081"),
|
||||
"__AI_BASEURL__": os.environ.get("AI__BaseUrl", ""),
|
||||
"__AI_APIKEY__": os.environ.get("AI__ApiKey", ""),
|
||||
"__AI_MODEL__": os.environ.get("AI__Model", "gemini-flash-latest"),
|
||||
}
|
||||
for k, v in repl.items():
|
||||
tmpl = tmpl.replace(k, v)
|
||||
sys.stdout.write(tmpl)
|
||||
PY
|
||||
|
||||
# midclt-Payload bauen: create braucht app_name+custom_app, update nur die Compose.
|
||||
build_payload() { # $1 = create|update
|
||||
python3 - "$RENDERED" "$1" <<'PY'
|
||||
import sys, json
|
||||
compose = open(sys.argv[1], encoding="utf-8").read()
|
||||
if sys.argv[2] == "create":
|
||||
print(json.dumps({"custom_app": True, "app_name": "gerbilmanager",
|
||||
"custom_compose_config_string": compose}))
|
||||
else:
|
||||
print(json.dumps({"custom_compose_config_string": compose}))
|
||||
PY
|
||||
}
|
||||
|
||||
# --- Existiert die App schon? ---
|
||||
EXISTS="$(midclt call app.query "[[\"name\",\"=\",\"$APP_NAME\"]]" 2>/dev/null | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)"
|
||||
|
||||
if [ "$EXISTS" -ge 1 ]; then
|
||||
log "App existiert -> app.update (Compose-Aenderungen uebernehmen, frische Images gezogen)"
|
||||
midclt call app.update "$APP_NAME" "$(build_payload update)" >/dev/null || fail "app.update fehlgeschlagen"
|
||||
else
|
||||
log "App fehlt -> app.create (Custom App)"
|
||||
midclt call app.create "$(build_payload create)" >/dev/null || fail "app.create fehlgeschlagen"
|
||||
fi
|
||||
|
||||
# --- Auf Gesundheit warten (Frontend proxyt /api/health -> API) ---
|
||||
log "Warte auf /api/health (max ${HEALTH_TIMEOUT}s) ..."
|
||||
elapsed=0
|
||||
while :; do
|
||||
if curl -fsS "http://localhost:$PORT/api/health" 2>/dev/null | grep -qi 'healthy'; then
|
||||
log "App ist gesund."; break
|
||||
fi
|
||||
[ "$elapsed" -ge "$HEALTH_TIMEOUT" ] && { \
|
||||
log "Letzte App-Logs:"; midclt call app.query "[[\"name\",\"=\",\"$APP_NAME\"]]" >/dev/null 2>&1 || true; \
|
||||
fail "App wurde nicht innerhalb ${HEALTH_TIMEOUT}s gesund"; }
|
||||
sleep 5; elapsed=$((elapsed + 5))
|
||||
done
|
||||
|
||||
log "Aufraeumen ungenutzter Images ..."
|
||||
docker image prune -f >/dev/null 2>&1 || true
|
||||
log "=== Deploy erfolgreich ($APP_NAME, TAG=$TAG) ==="
|
||||
@@ -1,37 +1,63 @@
|
||||
# GerbilManager — Externer nginx-Vhost fuer die oeffentliche Webseite (WEB-2)
|
||||
# In Julians bestehenden nginx-Reverse-Proxy einfuegen.
|
||||
# <DOMAIN> ersetzen sobald der Hostname feststeht (Julian liefert ihn).
|
||||
# GerbilManager — Vhost fuer die oeffentliche Webseite (WEB-2)
|
||||
# ===========================================================
|
||||
# Einzufuegen in den bestehenden nginx-Reverse-Proxy (TrueNAS-App "ix-webserver"):
|
||||
# Datei ablegen unter: /mnt/JailStorage/DockerVolumes/nginx/config/gerbil-public.conf
|
||||
# (das ist der conf.d-Mount des Proxys; dort liegen auch git/cloud/rismer.de)
|
||||
# Danach: docker exec ix-webserver-webserver-1 nginx -s reload
|
||||
#
|
||||
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (Port 8081).
|
||||
# Der Manager (API + Frontend, Port 80) ist NICHT erreichbar von aussen —
|
||||
# er hat keine Authentifizierung und muss LAN-only bleiben.
|
||||
# WICHTIG (containerisierter Proxy): das Backend MUSS ueber die Host-LAN-IP
|
||||
# angesprochen werden (192.168.2.115), NICHT 127.0.0.1 — 127.0.0.1 waere im
|
||||
# Proxy-Container er selbst. Der publicsite-Container veroeffentlicht Port 8081
|
||||
# auf dem Host (PUBLICSITE_PORT in deploy/truenas/.env).
|
||||
#
|
||||
# SICHERHEIT: Dieser Vhost zeigt NUR auf den publicsite-Container (statische
|
||||
# oeffentliche Seite). Der Manager (API + Frontend) bleibt LAN-only und ist von
|
||||
# aussen NICHT erreichbar (keine Authentifizierung).
|
||||
#
|
||||
# <DOMAIN> ersetzen (z. B. rennmaus.rismer.de). DNS-Eintrag auf die oeffentliche
|
||||
# IP + Router-Weiterleitung 80/443 -> 11080/11443 muss wie bei den anderen
|
||||
# rismer.de-Subdomains vorhanden sein.
|
||||
|
||||
# HTTP: ACME-Challenge zulassen + auf HTTPS umleiten (Muster der anderen Vhosts)
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name <DOMAIN>;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/letsencrypt;
|
||||
}
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS: Reverse-Proxy auf den publicsite-Container (Host-Port 8081)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name <DOMAIN>;
|
||||
|
||||
# Let's Encrypt (gleicher Cert-Store wie die uebrigen rismer.de-Vhosts).
|
||||
# Zertifikat anfordern, z. B.:
|
||||
# docker exec ix-webserver-webserver-1 certbot --nginx -d <DOMAIN>
|
||||
# oder webroot: certbot certonly --webroot -w /usr/share/nginx/letsencrypt -d <DOMAIN>
|
||||
ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||
|
||||
# HSTS gehoert auf die TLS-terminierende Ebene (hier), nicht in den HTTP-only
|
||||
# publicsite-Container. Erst aktivieren, wenn HTTPS sicher steht (max-age dann ggf. erhoehen).
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
# Die uebrigen Security-Header (CSP, X-Content-Type-Options, X-Frame-Options,
|
||||
# Referrer-Policy, Permissions-Policy) liefert der publicsite-nginx bereits mit und
|
||||
# werden vom Proxy durchgereicht (siehe nginx/publicsite.conf).
|
||||
|
||||
location / {
|
||||
proxy_pass http://192.168.2.115:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Kein Buffering fuer kleine statische HTML-Seiten
|
||||
proxy_buffering off;
|
||||
proxy_buffering off; # kleine statische HTML-Seiten
|
||||
}
|
||||
}
|
||||
|
||||
# Fuer HTTPS (empfohlen, z.B. per Let's Encrypt via certbot):
|
||||
# server {
|
||||
# listen 443 ssl;
|
||||
# server_name <DOMAIN>;
|
||||
# ssl_certificate /etc/letsencrypt/live/<DOMAIN>/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/<DOMAIN>/privkey.pem;
|
||||
# location / {
|
||||
# proxy_pass http://127.0.0.1:8081;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# }
|
||||
# }
|
||||
|
||||
615
docs/active-triage-summary.md
Normal file
615
docs/active-triage-summary.md
Normal file
@@ -0,0 +1,615 @@
|
||||
# Active Tickets Triage Summary
|
||||
*Generated automatically on 2026-06-28. Total active: 20*
|
||||
|
||||
This file aggregates all active feedback tickets with their full database context and source provenance to allow quick resolution.
|
||||
|
||||
---
|
||||
|
||||
## 1. [NeedsInfo] Ticket `21c8ff24` (ID: `21c8ff24-cc03-4508-b6c6-849ab36ba5b2`)
|
||||
- **Breeder's Message**: "Warum ist bei Krümel kein Todesdatum?"
|
||||
- **Category**: `General` | **Target Name**: `F-Wurf 2010`
|
||||
|
||||
### Target Litter Context:
|
||||
- **Litter Name**: `F-Wurf 2010`
|
||||
- **Date**: `2010-09-21` | **Total Born**: `4`
|
||||
- **Father ID**: `9b40b9e5-a9d3-5d63-ae34-8105297a8b18` | **Mother ID**: `e4898ccc-1896-5258-b1a9-4c89d30cf428`
|
||||
- **Notes**: `Blacky + Kruke v. 21.09.2010; WS=4`
|
||||
- **ExternalRef**: `None`
|
||||
|
||||
### AI Question Asked:
|
||||
> Hallo! Ich habe die handschriftliche Wurfchronik noch einmal ganz genau geprüft. Auf Seite 13 (F-Wurf 2010) ist Krümmel als abgegeben an 'Jana Beikert' (02.01.11) und dann 'Clan of little longnoses' (06.03.11) vermerkt, jedoch ohne Todesdatum (im Gegensatz zu seinen Wurfgeschwistern Fiona, Keks und Flori). Auf Seite 5 ist er als Auszug am 18.02.2015 an 'Lisa Christ' eingetragen, ebenfalls ohne Todesdatum (während für seine Partnerin Ina das Todesdatum ca. Feb 2017 vermerkt ist). Gibt es vielleicht noch eine andere Stelle in deinen Unterlagen, oder weißt du noch, wann er verstorben ist?
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 2. [NeedsInfo] Ticket `3a4fd3c8` (ID: `3a4fd3c8-ab45-4bcf-b7bb-118f44f2529a`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Donny" kommt mehrfach vor (2 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Donny`
|
||||
|
||||
### AI Question Asked:
|
||||
> Hier die Farben der beiden Partner von Donny:
|
||||
|
||||
• Aimee – Polarfuchs-Schecke (geboren 28.04.2009)
|
||||
• Bluebelle – Agouti-Schecke (geboren 09.11.2007)
|
||||
|
||||
Beide sind in deiner alten Software nur als Vorfahren bzw. Fremdtiere hinterlegt, keines stammt aus deiner eigenen Zucht.
|
||||
|
||||
Magst du mir jetzt noch sagen, ob die beiden Donny-Einträge – einmal „Schwarz-Schecke“, einmal „Schwarz“, beide geboren am 11.02.2008, Eltern Chango und Paula – dasselbe Tier sind oder zwei verschiedene? Dann kann ich die Einträge richtig zusammenführen oder getrennt lassen.
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
rpro3 Donny: 2 Varianten (A=Schwarz-Schecke u1240 2x, B=Schwarz u990 1x), beide DOB 2008-02-11, Eltern Chango+Paula, Herkunft Clan of Happy Mice. Gencode beider identisch (aa CC DD Ee Gg Pp Spsp). Geliefert: Partner+Nachzucht beider Varianten; Herkunft aller verwandten Tiere (kein Schenkungs-Feld in RPRO3); jetzt Farben der Partner: Aimee=Polarfuchs-Schecke (DOB 2009-04-28), Bluebelle=Agouti-Schecke (DOB 2007-11-09). Alle externe Ahnen. Erneut um Merge-/Getrennt-Entscheidung A vs B gebeten. Offen: A=B?
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 3. [NeedsInfo] Ticket `422f5c3f` (ID: `422f5c3f-b6f2-40e8-9b0e-a396cbe4a5f1`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Lukas" kommt mehrfach vor (2 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Lukas`
|
||||
|
||||
### AI Question Asked:
|
||||
> Hier die beiden Tessy-Linien mit Nachzucht und Partner – jeweils Farbe, Gencode und Geburtstag.
|
||||
|
||||
— Tessy aus der Lukas-Linie A (Lukas Anthrazit) —
|
||||
• Tessy: Colourpoint-Agouti-Hell, geb. 24.05.2006 · Gencode Aa c[chm]c[h] D- E- Gg Pp spsp
|
||||
• Partner Pünktchen: Anthrazit, geb. 12.05.2006 · Gencode aa Cc[chm] DD E- gg P- spsp
|
||||
• Kind Maxx: Marder-Schecke, geb. 29.09.2007 · Gencode aa c[chm]c[chm] DD Ee Gg PP Spsp
|
||||
|
||||
— Tessy aus der Lukas-Linie B (Lukas Marder-Hell-Schecke) —
|
||||
• Tessy: Marder-Hell-Schecke, geb. 24.05.2006 · Gencode aa c[chm]c[h] DD E- G- PP Spsp
|
||||
• Partner Pünktchen: Marder-Schecke, geb. 12.05.2006 · Gencode aa c[chm]c[chm] DD EE Gg PP Spsp
|
||||
• Kind Baileys: Marder-Hell-Schecke, geb. 19.06.2007 · Gencode aa c[chm]c[h] DD E- G- PP Spsp
|
||||
|
||||
Beide Tessy sind am selben Tag geboren und haben einen Partner namens Pünktchen, aber Farben, Gencodes und sogar die Kinder (Maxx auf der einen, Baileys auf der anderen Seite) sind durchweg verschieden – genau wie schon bei Lukas selbst. Das sieht nach zwei kompletten, getrennten Familien aus.
|
||||
|
||||
Sag mir damit bitte abschließend: Sind die beiden Lukas (und damit auch die beiden Tessy) für dich dasselbe Tier – dann lege ich sie zusammen, sag mir nur, welche Farbe und welche Herkunft stehen bleiben sollen – oder sind es zwei verschiedene Tiere mit zufällig gleichem Namen?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
rpro3 Lukas (2 Varianten, beide externer Ahn). A=u557 (DOB 2005-11-14, Anthrazit, Herkunft unbekannt), B=u1441 (DOB 2005-11-14, Marder-Hell-Schecke, Herkunft Laura Gerber).
|
||||
Runde 4 (2026-06-27): Zuechterin bat um Gencode/Nachzucht/Partner BEIDER Tessy (Lukas-Kinder). Aus _rpro3.db (rpro3_lookup.py) geliefert, gruppiert nach Lukas-Linie:
|
||||
Linie A: Tessy Colourpoint-Agouti-Hell *2006-05-24 [Aa c[chm]c[h] D- E- Gg Pp spsp]; Partner Puenktchen(Variante C) Anthrazit *2006-05-12 [aa Cc[chm] DD E- gg P- spsp]; Kind Maxx Marder-Schecke *2007-09-29 [aa c[chm]c[chm] DD Ee Gg PP Spsp].
|
||||
Linie B: Tessy Marder-Hell-Schecke *2006-05-24 [aa c[chm]c[h] DD E- G- PP Spsp]; Partner Puenktchen(Variante D) Marder-Schecke *2006-05-12 [aa c[chm]c[chm] DD EE Gg PP Spsp]; Kind Baileys Marder-Hell-Schecke *2007-06-19 [aa c[chm]c[h] DD E- G- PP Spsp].
|
||||
Hinweis: zwei parallele, durchweg verschiedene Familien (verschiedene Farben/Gencodes/Kinder Maxx vs Baileys) trotz gleicher Namen+DOB -> starkes Indiz fuer zwei getrennte Tiere. Puenktchen hat in RPRO3 5 Varianten (A/B Rotaugen-Weiss/unbek, C Anthrazit, D Marder-Schecke, E Anthrazit-Starkschecke); nur C+D gehoeren zu den Tessy-Linien.
|
||||
Warte weiter auf finale Merge-/Getrennt-Entscheidung der Zuechterin (+ bei Merge welche Farbe/Herkunft bleiben soll).
|
||||
Geplante Umsetzung je nach Antwort: rpro3-decisions.json Eintrag fuer Lukas-rids -> 'different' [u557, u1441] bei 'verschieden' ODER 'same' mit Farb/Herkunft-fields bei 'dasselbe'. Keine Live-Materialisierung noetig solange kein App-Kind als Anker (alle reine externe Ahnen).
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 4. [NeedsInfo] Ticket `035ee655` (ID: `035ee655-d354-4e90-949a-16921c77ab64`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Dunny" kommt mehrfach vor (2 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Dunny`
|
||||
|
||||
### AI Question Asked:
|
||||
> Dunny taucht aktuell in keinem Stammbaum deiner App auf: Weder Dunny selbst noch sein Kind Dana (noch die Tiere drumherum) sind als Tiere in der App vorhanden — sie stehen bislang nur in den RennmausPro-Ahnendaten. Sobald der große RennmausPro-Ahnen-Import läuft, kommt Dunny mit dazu.
|
||||
|
||||
Damit ich Dunny richtig anlege: Sind die beiden Dunny-Einträge (beide geboren am 18.06.2005) dasselbe Tier oder zwei verschiedene? Und falls dasselbe — welche Farbe stimmt: Zobel-Hell oder Marder-Hell-Schecke?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
rpro3 Dunny (2 Var, beide *2005-06-18): A=u556 Zobel-Hell (Eltern unbek.), B=u1445 Marder-Hell-Schecke (Eltern Suntie+Sissy). R3: Frage 'in welchem Stammbaum' beantwortet -> Dunny/Dana/Umfeld NICHT in Live-App (0 Treffer, nur unverwandte Vandana/Danako). Warte weiter auf same/different + Farbe (Zobel-Hell vs Marder-Hell-Schecke). Bei 'same' spaeter rpro3-decisions.json same [[u556,u1445]] + color.
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 5. [NeedsInfo] Ticket `4b38b3d6` (ID: `4b38b3d6-3597-4f3e-9aa6-ae32babc0934`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Nunny" kommt mehrfach vor (2 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Nunny`
|
||||
|
||||
### AI Question Asked:
|
||||
> Hier die Gencodes zu Nunny und ihren Eltern:
|
||||
|
||||
Nunny selbst (beide geboren am 06.09.2005):
|
||||
• Variante A — Marder-Schecke: aa c[chm]c[chm] DD E- Gg PP Spsp · Eltern unbekannt
|
||||
• Variante B — Zobel-Schecke: aa c[chm]c[chm] DD E- gg PP Spsp · Eltern Hannes & Hanny
|
||||
|
||||
Die Eltern (nur bei Variante B hinterlegt):
|
||||
• Vater Hannes — Agouti-Schecke: Aa Cc[chm] DD Ee Gg PP Spsp
|
||||
• Mutter Hanny — Zobel: aa c[chm]c[chm] DD EE gg PP spsp
|
||||
|
||||
Sag mir bitte: Sind die beiden Nunny-Einträge dasselbe Tier oder zwei verschiedene? Und falls dasselbe — welche Farbe stimmt: Marder-Schecke oder Zobel-Schecke?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
rpro3 Nunny (2 Var, beide *2005-09-06, Partner Dunny, Kind Dana): A=u555 Marder-Schecke (aa c[chm]c[chm] DD E- Gg PP Spsp, Eltern unbek.), B=u1442 Zobel-Schecke (aa c[chm]c[chm] DD E- gg PP Spsp, Eltern Hannes=Agouti-Schecke Aa Cc[chm] DD Ee Gg PP Spsp / Hanny=Zobel aa c[chm]c[chm] DD EE gg PP spsp). R3: Gencodes Nunny+Eltern geliefert. Warte auf same/different + Farbe (Marder-Schecke vs Zobel-Schecke). Bei 'same' spaeter rpro3-decisions.json same [[u555,u1442]] + color.
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 6. [NeedsInfo] Ticket `2530bf41` (ID: `2530bf41-bf4d-42a3-a291-bc8f8100e051`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Tessy" kommt mehrfach vor (2 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Tessy`
|
||||
|
||||
### AI Question Asked:
|
||||
> Gerne! Hier die Farben rund um beide „Tessy"-Varianten:
|
||||
|
||||
**A** (geboren 24.05.2006, Marder-Hell-Schecke):
|
||||
• Vater Lukas: Marder-Hell-Schecke
|
||||
• Mutter Dana: Marder-Schecke
|
||||
• Partner Pünktchen: Marder-Schecke
|
||||
• Nachzucht: Baileys (geb. 19.06.2007), Marder-Hell-Schecke
|
||||
|
||||
**B** (geboren 24.05.2006, Colourpoint-Agouti-Hell):
|
||||
• Vater Lukas: Anthrazit
|
||||
• Mutter Dana: Colourpoint-Silberagouti-Schecke
|
||||
• Partner Pünktchen: Anthrazit
|
||||
• Nachzucht: Maxx (geb. 29.09.2007), Marder-Schecke
|
||||
|
||||
Beide haben dasselbe Geburtsdatum und dieselben Elternnamen (Lukas und Dana), aber die Farben passen nicht zusammen: A gehört in eine ganz marderfarbene Familie, B in eine deutlich hellere/colourpoint-farbene. Auch die Nachzucht und der Partner unterscheiden sich farblich klar.
|
||||
|
||||
Sind A und B für dich dasselbe Tier oder zwei verschiedene? Antworte gern einfach mit „dasselbe" oder „verschieden".
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source": "rpro3-ambiguity", "name": "Tessy", "variants": [{"label": "A", "rids": ["u1439", "u1455"], "count": 2, "dob": ["2006-05-24"], "farbe": ["Marder-Hell-Schecke"], "origin": ["Baru of Lienehma"], "is_own": false, "matched_gerbil_id": null, "vater": "Lukas (Marder-Hell-Schecke)", "mutter": "Dana (Marder-Schecke)", "partner": "Pünktchen (Marder-Schecke)", "nachzucht": "Baileys 2007-06-19 Marder-Hell-Schecke"}, {"label": "B", "rids": ["u553"], "count": 1, "dob": ["2006-05-24"], "farbe": ["Colourpoint-Agouti-Hell"], "origin": [], "is_own": false, "matched_gerbil_id": null, "vater": "Lukas (Anthrazit)", "mutter": "Dana (Colourpoint-Silberagouti-Schecke)", "partner": "Pünktchen (Anthrazit)", "nachzucht": "Maxx 2007-09-29 Marder-Schecke"}], "status": "Farben von Eltern/Partner/Nachzucht je Variante geliefert (Runde 2). Warte auf gleich/verschieden-Entscheidung. Hinweis: gleiche DOB+Elternnamen, aber Farben klar abweichend -> spricht fuer verschieden."}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 7. [NeedsInfo] Ticket `945143e4` (ID: `945143e4-d219-4bf0-8a8f-91d04b1ad94e`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Bubi" kommt mehrfach vor (2 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Bubi`
|
||||
|
||||
### AI Question Asked:
|
||||
> Nein, es gibt nur eine einzige Tasmania in RennmausPro. Hier ihre Angaben:
|
||||
|
||||
Tasmania - Farbe Anthrazit, Geburtsdatum nicht hinterlegt, Herkunft Privatzucht Mettmann
|
||||
- Eltern: Vater Bubi, Mutter Meiky
|
||||
- Nachzucht: Samira (geboren 07.08.2008, Anthrazit-Schecke), mit Partner Garfield
|
||||
|
||||
Sie taucht also nur einmal auf - es sind nicht mehrere Tasmania hinterlegt. Tasmania ist damit das Kind von dem Bubi mit der Zobel-Schecken-Faerbung (Variante A).
|
||||
|
||||
Damit zurueck zur eigentlichen Frage: Die beiden „Bubi“-Eintraege haben dasselbe Geburtsdatum (18.04.2007), aber unterschiedliche Farben (Zobel-Schecke gegen Marder-Schecke), und nur einer (A) hat Nachzucht (Dora und Tasmania). Sind die beiden Bubi dasselbe Tier oder zwei verschiedene?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source": "rpro3-ambiguity", "name": "Bubi", "variants": [{"label": "A", "rids": ["u115"], "count": 1, "dob": ["2007-04-18"], "farbe": ["Zobel-Schecke"], "origin": ["Clan of Tamyna"], "is_own": false, "matched_gerbil_id": null, "gencode": "aa c[chm]c[chm] DD Ee gg P- Spsp", "parents": {"vater": "unbekannt", "mutter": "unbekannt"}, "offspring": [{"name": "Dora", "dob": "2008-04-12", "farbe": "Marder", "gencode": "aa c[chm]c[chm] DD E- Gg P- spsp", "co_parent": "Tina"}, {"name": "Tasmania", "dob": null, "farbe": "Anthrazit", "gencode": "aa Cc[chm] DD E- gg P- spsp", "co_parent": "Meiky"}]}, {"label": "B", "rids": ["u430"], "count": 1, "dob": ["2007-04-18"], "farbe": ["Marder-Schecke"], "origin": [], "is_own": false, "matched_gerbil_id": null, "gencode": "aa c[chm]c[chm] DD E- Gg P- Spsp", "parents": {"vater": "unbekannt", "mutter": "unbekannt"}, "offspring": []}], "status_note": "Runde 3. Zuechterin fragte 'Sind mehrere Tasmania hinterlegt'. Geprueft via rpro3_lookup: nur EINE Tasmania in RPRO3 (Anthrazit, kein DOB, Eltern Vater Bubi/Mutter Meiky, Herkunft Privatzucht Mettmann, Nachzucht Samira geb 2008-08-07 Anthrazit-Schecke mit Partner Garfield). Tasmania ist Kind von Bubi-A (Zobel-Schecke). KORREKTUR zu frueher: Meiky ist NICHT Partner sondern MUTTER von Tasmania (also Co-Elternteil von Bubi-A). An Zuechterin geliefert + Kernfrage A==B wiederholt. Warte auf gleich/verschieden-Entscheidung."}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 8. [NeedsInfo] Ticket `bedb4017` (ID: `bedb4017-0499-4a50-b2a4-32544601636c`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Speedy" kommt mehrfach vor (3 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Speedy`
|
||||
|
||||
### AI Question Asked:
|
||||
> Danke! Ich führe die drei „Speedy" als verschiedene Tiere getrennt. Zu deinem Speedy (Kohlfuchs, geboren am 12.02.2019, Vater der Speedy×Rin-Jungen): Bei mir steht er gerade fälschlich als „abgegeben an Sabrina Lüftner" — das war aber der Vertrag für sein Junges Fizz, nicht für ihn selbst. Ich trage ihn richtig als dein eigenes (verstorbenes) Zuchttier ein. Weißt du noch ungefähr, wann er gestorben ist? Mit einem (auch ungefähren) Datum kann ich ihn korrekt als verstorben statt als abgegeben anzeigen.
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"ticket":"bedb4017 Speedy rpro3-ambiguity","breeder_answer":"Alle verschieden + Folgefrage: ist B als ehemaliges (verstorbenes) Zuchttier hinterlegt?","decision":"Alle 3 verschieden -> KEIN rpro3 same/different noetig; A/B/C trennen automatisch ueber DOB+Farbe. A=u706/u2883/u6775 Agouti *2009-07-01 Privat (externer Ahn, kein App-Record). B=u6974 Kohlfuchs *2019-02-12 von den Kleinen Chaoten. C=u6991 Schwarz-Ansatzschecke *2010-05-01 (externer Ahn).","variant_B_app_record":"798998c4-cc06-5c75-b762-3e3b68fde442 'Speedy von den Kleinen Chaoten', male, *2019-02-12, geno 'aa Cc[-] D- ee[f] Gg Pp spsp', externalRef stammbaum-speedykleinenchaoten-12022019, fromWurfchronik=false, voller Stammbaum-Record.","current_state_PROBLEM":"status=GivenAway, receiverContactId=5df5b149 (Sabrina Lueftner), goHomeDate=2019-09-01, isResident=false, originBreeder=None, dateOfDeath=None. Abgabe FALSCH attribuiert: receiver stammt aus Vertrag 'Fizz (Speedy.Rin)-Sabrina Lueftner' = Vertrag fuer JUNGES Fizz; parent-Notation (Speedy.Rin) wurde faelschlich als Speedy-Abgabe gematcht (siehe provenance). originContactId korrekt = 'Zucht der kleinen Chaoten' b7e9a81e.","breeder_says":"Speedy B ist ihr ehemaliges verstorbenes Zuchttier. Nachzucht 'von den Kleinen Chaoten' (Fizz/Josy/Lotta/Mausi aus Speedy x Rin; rpro3 Nachzucht 'Rins Tochter' Partner Rin) -> erfuellt isResident-Regel.","plan":"conflict-decisions.json resolution name='Speedy von den Kleinen Chaoten' dob='12.02.2019' (Match-Key name+dob via _apply_conflict_decisions): (1) excludeContractMatch=true -> Fizz-Vertrag nicht mehr als Speedy-Abgabe binden (Praezedenz Danako 85fc96fe); (2) isResident=true Override (Nachzucht von den Kleinen Chaoten); (3) deceased + dateOfDeath=<von Zuechterin> -> Status Deceased (GerbilStatusService: DateOfDeath->Deceased gewinnt). originBreeder ggf. 'Zucht der kleinen Chaoten'.","BLOCKER":"exaktes/ungefaehres Sterbedatum fehlt -> ohne Datum bleibt Status GivenAway statt Deceased. Genau das erfragt diese Rueckfrage.","affectedFiles":["tools/import/conflict-decisions.json"],"overlapKey":"import-data-conflict-decisions","precedent":"Benjiro b97dd9ad gleiches Muster (dort schon korrekt; hier NICHT -> echte Korrektur). excludeContractMatch=Danako 85fc96fe; deceased+dateOfDeath=ec9267b9/Akane 36a3fcde."}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 9. [NeedsInfo] Ticket `cd4b24b0` (ID: `cd4b24b0-7b48-4fcf-880d-6f86f12755d9`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Sina" kommt mehrfach vor (3 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Sina`
|
||||
|
||||
### AI Question Asked:
|
||||
> Notiert: C ist ein anderes Tier. Hier nun alle Gencodes zu A und B — jeweils das Tier selbst, die Eltern, der Partner und die Nachzucht:
|
||||
|
||||
=== Variante A (Marder-Hell, geb. 28.12.2005, Herkunft unbekannt) ===
|
||||
• Sina selbst: aa c[chm]c[h] DD Ee G- P- spsp
|
||||
• Eltern: unbekannt
|
||||
• Partner Sam: aa Cc[chm] DD Ee Gg Pp Spsp
|
||||
• Nachzucht:
|
||||
– Nany (geb. unbekannt, Marder-Schecke): aa c[chm]c[chm] DD Ee G- P- Spsp
|
||||
– Cassie (geb. 15.01.2011, Colourpoint-Agouti): Aa c[chm]c[chm] D- EE GG Pp spsp
|
||||
|
||||
=== Variante B (Marder-Schecke, geb. 28.12.2005, Herkunft Clan of Letter Live) ===
|
||||
• Sina selbst: aa c[chm]c[chm] DD EE Gg PP Spsp
|
||||
• Eltern:
|
||||
– Vater Duka (Zobel): aa c[chm]c[chm] DD EE gg PP spsp
|
||||
– Mutter Helsy (Marder-Hell-Schecke): aa c[chm]c[h] DD EE GG PP Spsp
|
||||
• Partner Lupo (Marder-Hell-Schecke): aa c[chm]c[h] DD EE GG PP Spsp
|
||||
• Nachzucht:
|
||||
– Nany (geb. 03.02.2006, Marder-Schecke): aa c[chm]c[chm] DD EE GG PP Spsp
|
||||
|
||||
Ein Hinweis, der dir helfen kann: Beide Varianten haben ein Junges namens „Nany“ — aber bei A ist Nany ohne Geburtsdatum und mit Partner Sam eingetragen, bei B mit Geburtsdatum 03.02.2006 und Partner Lupo. Es könnte also gut sein, dass A und B trotz gleichem Namen wirklich zwei verschiedene Sina-Tiere mit unterschiedlichen Partnern und Nachkommen sind.
|
||||
|
||||
Sag mir bitte mit diesen Daten: Sind A und B dasselbe Tier (dann lege ich sie zusammen) oder doch zwei verschiedene? Und falls zusammenlegen: Welcher Name und welche Farbe sollen am Ende stehen?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
rpro3 Sina: 3 Varianten (Ticket-Buchstaben). Ticket A=Marder-Hell geb.2005-12-28 Herkunft unbekannt = Lookup-Block B (Gencode aa c[chm]c[h] DD Ee G- P- spsp; Eltern unbekannt; Partner Sam; Nachzucht Nany, Cassie). Ticket B=Marder-Schecke geb.2005-12-28 Clan of Letter Live = Lookup-Block C (Gencode aa c[chm]c[chm] DD EE Gg PP Spsp; Eltern Duka/Helsy; Partner Lupo; Nachzucht Nany 2006-02-03). Ticket C=Agouti-Schecke = Lookup-Block A -> von Zuechterin als ANDERES Tier bestaetigt (getrennt lassen). Runde davor: A/B-Merge-Entscheidung offen; Zuechterin wollte Gencodes von Tier+Eltern+Partner+Nachzucht zu A und B. JETZT geliefert: alle Gencodes (Sina A/B, Eltern Duka/Helsy, Partner Sam/Lupo, Nachzucht Nany/Cassie) + Hinweis dass die je 'Nany' unterschiedliche DOB/Partner haben (Indiz, dass A!=B). WARTE auf: Merge-/Getrennt-Entscheidung zu A vs. B + gewuenschter Name/Farbe bei Merge.
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 10. [NeedsInfo] Ticket `33af41eb` (ID: `33af41eb-ffac-47bc-8467-561dba513c06`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Anton" kommt mehrfach vor (5 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Anton`
|
||||
|
||||
### AI Question Asked:
|
||||
> Ja, die Zusammenführung für Anton als 'WFNZ Agouti' (Geburtstag 31.07.2007) sowie für Romina und Bärchen ist damit abgeschlossen!
|
||||
|
||||
Hier sind die Daten aus RennmausPro für BlackEye, nach denen du gefragt hast:
|
||||
|
||||
• **Farbe**: Silberschimmel
|
||||
• **Gencode**: Aa Cc[-] DD e[f]e[f] gg Pp spsp (Kombination aus deinen verschiedenen Einträgen)
|
||||
• **Eltern**: Anton und Pünktchen
|
||||
• **Herkunft**: Clan of Desert / Unbekannt
|
||||
• **Kinder**: Pegasus (geboren am 27.02.2008 mit Djen) und Yukon (geboren am 16.03.2007 mit Desert)
|
||||
|
||||
In RennmausPro taucht BlackEye insgesamt 28-mal als Vorfahr in verschiedenen Stammbäumen auf, hat aber überall diese übereinstimmenden Daten.
|
||||
|
||||
Sollen wir alle diese 28 Einträge zu einer einzigen BlackEye zusammenlegen?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source":"rpro3-ambiguity","name":"Anton","stage":"Züchterin entschied: A&B=same, C&D&E=same; bat zusätzlich Nachzucht zu mergen + Inkonsistenzen zu melden. Rückfrage gestellt wg. DOB/Farb-Inkonsistenzen in C/D/E und in den Kindern Romina/Bärchen sowie Blackeye-Großcluster.","breeder_decision":"A&B dasselbe Tier; C&D&E dasselbe Tier; Nachzucht ggf. mergen; Inkonsistenzen (außer unbekannt) rückfragen","rpro3_db_rids":{"Anton_unknownDOB_AundB":["u1064","u1605","u1948","u2246","u2387","u2599","u3041","u3199","u3505"],"Anton_BenJeannie_CDE":{"C_31.07.2007_Agouti":["u2899","u6791","u6921"],"D_30.07.2007_Agouti":["u3352"],"E_21.03.2007_WFNZ-Agouti":["u1407"]}},"note_rid_u3199":"u3199 ist ein weiterer Anton mit DOB unbekannt/HERK 205, NICHT im ursprünglichen agentContext gelistet, gehört aber profilgleich in den A&B-Cluster.","merge_data_to_keep":{"AundB":"Daten von B (Anthrazit-Schecke, Gencode 'aa C- D- Ee[f] gg P- Spsp'); A ist komplett unbekannt","CDE":"Gencode bei allen identisch 'AA CC DD EE GG PP spsp'; DOB+Farbe offen (Rückfrage 1)"},"offspring":{"Blackeye":"28 fremd_tb-Records, mehrere Genotypen/Herkünfte (Silberschimmel, Clan of Desert), eigene Nachzucht (Pegasus, Yukon) -> eigener Klärungsschritt, NICHT blind mergen","Romina_rids":["u1405(01.05.2011 Agouti)","u2897","u3348","u6789","u6917(01.03.2011)"],"Baerchen_rids":["u1408(01.03.2011)","u2900(06.04.2012)","u3355(01.03.2011)","u6792(06.04.2012)","u6924(01.03.2011 WFNZ)"]},"matched_gerbil_id":null,"live_app":"Anton + Nachzucht (Blackeye/Romina/Bärchen) sind reine rpro3-Ahnen, noch NICHT in der App (rpro3-Ahnen-Import = ausstehende Folgeaufgabe). Entscheidungen gehören in rpro3-decisions.json und materialisieren beim nächsten RennmausPro-Ahnen-Import.","plan_nach_antwort":"rpro3-decisions.json: (1) same [[u1064,...,u3505]] für A&B, Feld-Override Farbe Anthrazit-Schecke + genotype 'aa C- D- Ee[f] gg P- Spsp'; (2) same [[u2899,u6791,u6921,u3352,u1407]] für C&D&E, Feld-Override DOB+Farbe = Antwort der Züchterin (Notiz für abweichendes Datum). Romina/Bärchen: je nach Antwort same-Cluster + DOB/Farb-Override. Blackeye separat. Schlüssel IMMER rid, nie Variantenbuchstabe."}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 11. [NeedsInfo] Ticket `12440e00` (ID: `12440e00-829e-4209-b3d9-a3d28e7879e7`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Pünktchen" kommt mehrfach vor (5 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Pünktchen`
|
||||
|
||||
### AI Question Asked:
|
||||
> Ich habe Pünktchen A und B (die Eltern von Blackeye) zusammengeführt und C und D wie gewünscht zusammengelegt. Zu den Differenzen bei C und D (geb. 12.05.2006, Eltern Geyo x Nany): Ihre Eltern Geyo und Nany haben in RennmausPro keine eingetragenen Geburtsdaten oder Gencodes. Pünktchen selbst hat mit Partner Tessy zwei Kinder: Maxx (*12.05.2006, Marder-Schecke, u560) und Baileys (*12.05.2006, Marder-Hell-Schecke, u1460). Sollen wir diese Kinder auch zusammenführen, oder welche Daten möchtest du hier anpassen?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source": "rpro3-ambiguity", "name": "Pünktchen", "status": "awaiting-merge-decision", "delivered": "Pro Variante A-E: DOB, Farbe, Gencode, Herkunft, Eltern, Nachzucht+Partner geliefert (Antwort auf Bitte um Eltern/Nachzucht/Partner/Farben/Gencodes).", "hints_given": ["A & B teilen Kind 'Blackeye' (Silberschimmel) mit Partner Anton -> moeglicherweise dasselbe Tier", "C & D teilen DOB 2006-05-12 und Eltern Geyo x Nany -> moeglicherweise dasselbe Tier oder Geschwister"], "variants": [{"label": "A", "rids": ["u1065", "u1124", "u1606", "u1667"], "count": 8, "dob": null, "farbe": "Rotaugen-Weiß", "gencode": "-- c[chm]c[chm] DD -- -- pp spsp", "origin": "The little warrior with Claws", "offspring": "BlackEye (Silberschimmel) / Partner Anton"}, {"label": "B", "rids": ["u2245", "u2386", "u2598", "u3040"], "count": 5, "dob": null, "farbe": "unbekannt", "gencode": "-- -- -- -- -- -- spsp", "origin": null, "offspring": "Blackeye (Silberschimmel) / Partner Anton"}, {"label": "C", "rids": ["u560"], "count": 1, "dob": "2006-05-12", "farbe": "Anthrazit", "gencode": "aa Cc[chm] DD E- gg P- spsp", "origin": null, "parents": "Geyo x Nany", "offspring": "Maxx (Marder-Schecke) / Partner Tessy"}, {"label": "D", "rids": ["u1460"], "count": 1, "dob": "2006-05-12", "farbe": "Marder-Schecke", "gencode": "aa c[chm]c[chm] DD EE Gg PP Spsp", "origin": "Baru of Lienehma", "parents": "Geyo x Nany", "offspring": "Baileys (Marder-Hell-Schecke) / Partner Tessy"}, {"label": "E", "rids": ["u3742"], "count": 1, "dob": "2010-10-12", "farbe": "Anthrazit-Starkschecke DP", "gencode": "aa Cc[chm] DD EE gg PP Spsp", "origin": "Clan of Braveheart", "parents": "Jumanji x Lola", "offspring": "Hell Boy (Agouti-Starkschecke) / Partner Picasso"}]}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 12. [NeedsInfo] Ticket `5931f460` (ID: `5931f460-b39d-4ab4-913b-c29f18df0f13`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Paula" kommt mehrfach vor (5 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Paula`
|
||||
|
||||
### Target Gerbil Context:
|
||||
- **Name**: `Paula`
|
||||
- **Gender**: `female` | **Status**: `Deceased`
|
||||
- **DOB**: `2016-04-07` | **DOD**: `2018-09-10`
|
||||
- **Genotype**: `None`
|
||||
- **Notes**: `None`
|
||||
- **Import Source**: `Wurfchronik`
|
||||
- **ExternalRef**: `Wurfchronik Teil 2_page_0021.md-15cd3c3c-64fb-4d97-bbf9-aba561972a0a`
|
||||
- **Provenance History**:
|
||||
- In „Wurfchronik“ gefunden.
|
||||
- Geburtsdatum (07.04.2016) aus „Wurfchronik“.
|
||||
- Geschlecht (weiblich) aus „Wurfchronik“.
|
||||
- Farbschlag aus „Wurfchronik“.
|
||||
- Sterbedatum (10.09.2018) aus „Wurfchronik“.
|
||||
- Angaben aus der Wurfchronik übernommen.
|
||||
|
||||
### AI Question Asked:
|
||||
> Hier sind die Details aus RennmausPro für die beiden Paula-Einträge:
|
||||
|
||||
• **Paula A (u1241)**: Farbe Anthrazit-Schecke, geboren am 25.08.2007. Eltern sind Princess of Kinghouse (Silberagouti-Schecke) und Young Boy (Marder-Schecke). Sie hat ein Kind namens Donny (*10.02.2008).
|
||||
• **Paula B (u989)**: Farbe Anthrazit, geboren am 25.08.2007. Eltern sind Princess (Silberagouti-Schecke) und Young Boy1 (Marder-Schecke). Ihr Partner war Chango (Kohlfuchs-Schecke) und ihr Kind ebenfalls Donny (*10.02.2008).
|
||||
|
||||
Sind diese beiden Paula dasselbe Tier (dann führen wir sie zusammen) oder zwei verschiedene?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source": "rpro3-ambiguity", "name": "Paula", "variants": [{"label": "A", "rids": ["u1241", "u6865"], "count": 2, "dob": ["2007-08-25"], "farbe": ["Anthrazit-Schecke"], "origin": [], "is_own": false, "matched_gerbil_id": "b41c3686-67a3-5e01-9201-347622ade9d7"}, {"label": "B", "rids": ["u989"], "count": 1, "dob": ["2007-08-25"], "farbe": ["Anthrazit"], "origin": ["Clan of Happy Mice"], "is_own": false, "matched_gerbil_id": "b41c3686-67a3-5e01-9201-347622ade9d7"}, {"label": "C", "rids": ["u1481"], "count": 1, "dob": ["2004-08-01"], "farbe": ["Schwarz-Schecke"], "origin": [], "is_own": false, "matched_gerbil_id": "b41c3686-67a3-5e01-9201-347622ade9d7"}, {"label": "D", "rids": ["u608"], "count": 1, "dob": [], "farbe": ["Agouti"], "origin": ["Züchter aus NL"], "is_own": false, "matched_gerbil_id": "b41c3686-67a3-5e01-9201-347622ade9d7"}, {"label": "E", "rids": ["u352"], "count": 1, "dob": [], "farbe": ["Kohlfuchs, hell"], "origin": [], "is_own": false, "matched_gerbil_id": "b41c3686-67a3-5e01-9201-347622ade9d7"}]}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 13. [NeedsInfo] Ticket `e74e1fab` (ID: `e74e1fab-515b-4296-9161-7b0d6f4be382`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Nancy" kommt mehrfach vor (2 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Nancy`
|
||||
|
||||
### Target Gerbil Context:
|
||||
- **Name**: `Nancy`
|
||||
- **Gender**: `female` | **Status**: `Deceased`
|
||||
- **DOB**: `2014-04-20` | **DOD**: `2016-09-10`
|
||||
- **Genotype**: `AA CC DD EE GG PP Spsp rere`
|
||||
- **Notes**: `Behalten (Zucht-Symbol). | Breeder Dam`
|
||||
- **Import Source**: `Wurfchronik Teil 1_page_0028.md`
|
||||
- **ExternalRef**: `Wurfchronik Teil 1_page_0028.md-c3d4e5f6-0028-0003-0003-000000000003`
|
||||
- **Provenance History**:
|
||||
- In „Wurfchronik Teil 1_page_0028.md“ gefunden.
|
||||
- Geburtsdatum (20.04.2014) aus „Wurfchronik Teil 1_page_0028.md“.
|
||||
- Geschlecht (weiblich) aus „Wurfchronik Teil 1_page_0028.md“.
|
||||
- Genotyp aus „Wurfchronik Teil 1_page_0028.md“.
|
||||
- Farbschlag aus „Wurfchronik Teil 1_page_0028.md“.
|
||||
- Sterbedatum (10.09.2016) aus „Wurfchronik Teil 1_page_0028.md“.
|
||||
- Auch in „Wurfchronik Teil 1_page_0046.md“ gefunden → Datensätze zusammengeführt.
|
||||
- Auch in „Wurfchronik Teil 1_page_0051.md“ gefunden → Datensätze zusammengeführt.
|
||||
- Angaben aus der Wurfchronik übernommen.
|
||||
- Abgabe an „Natascha Marienfeld“ aus Vertrag „Zucht der kleinen Chaoten _Nancy-Natascha M._.docx“ übernommen.
|
||||
- Abgabedatum 2016-08-27 aus Vertrag „Zucht der kleinen Chaoten _Nancy-Natascha M._.docx“ übernommen.
|
||||
- Vertrag „Zucht der kleinen Chaoten _Grace aka Suri (Milon. Nancy of little Hobbits)-Gina Hepp_.docx“ nennt anderen Abnehmer „Gina Heep“ — bestehende Zuordnung beibehalten.
|
||||
|
||||
### AI Question Asked:
|
||||
> Alles klar - notiert: Die beiden Nancys sind verschiedene Tiere und werden getrennt gefuehrt.
|
||||
|
||||
Zu deinen Fragen:
|
||||
|
||||
1) Ist die zweite Nancy (geboren am 20.04.2014, Agouti-Schecke, aus der Linie der Kleinen Chaoten) als ehemaliges Zuchttier aufgefuehrt?
|
||||
Ja - genau diese Nancy ist in deinem Bestand als Zuchttier gefuehrt. (Die andere Nancy, geboren am 18.05.2014, Agouti, von Annemarie Knief, wird als reiner Vorfahre gefuehrt, nicht als dein Zuchttier.)
|
||||
|
||||
2) Kommt bei der ersten Nancy ein "Cloud" im Stammbaum vor - wenn ja, seine Daten?
|
||||
Bei "Cloud" gibt es eine Schwierigkeit: In RennmausPro stehen zwei Tiere namens Cloud, beide Rotfuchs-Schecke und beide als Vorfahren (keine eigenen Zuchttiere). Damit ich dir die richtigen Daten gebe, brauche ich von dir, welcher gemeint ist. Hier beide zum Vergleich:
|
||||
|
||||
- Cloud (1): geboren 09.06.2012, Rotfuchs-Schecke, Herkunft unbekannt. Eltern unbekannt. Nachwuchs: Anubis (geboren 06.01.2013, Algierfuchs-Punktschecke), zusammen mit Arya.
|
||||
|
||||
- Cloud (2): geboren 31.08.2012, Rotfuchs-Schecke, Herkunft "Clan of New Dawn". Eltern unbekannt. Nachwuchs: Sweet Heart (geboren 14.02.2013, Platin-Hell), zusammen mit Arya.
|
||||
|
||||
Welcher der beiden ist der Cloud, den du meinst? Dann ergaenze ich dir gern noch fehlende Details.
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source": "rpro3-ambiguity", "name": "Nancy", "round": 2, "decision": "Zuechterin: 'alle verschieden'. Nancy A (rid 279, 2014-05-18, Agouti, Annemarie Knief1, eigenes/Vorfahre) und Nancy B (rid u6443, 2014-04-20, Agouti-Schecke, Kleinen Chaoten) sind VERSCHIEDENE Tiere -> getrennt fuehren.", "variants": [{"label": "A", "rids": ["279"], "dob": "2014-05-18", "farbe": "Agouti", "origin": "Annemarie Knief1", "eltern": "Fegur x Lumina", "nachzucht": "Cyrano (2016-03-12) mit Milon", "live_match": "Nancy of little hobbits / d923abcf-0364-5a1a-ba6a-53b2586e58fa / isResident=False (reiner Vorfahre)"}, {"label": "B", "rids": ["u6443"], "dob": "2014-04-20", "farbe": "Agouti-Schecke", "origin": "Kleinen Chaoten", "eltern": "Taro x Kazumi", "nachzucht": "Charly (2015-06-05) mit Zorro", "live_match": "Nancy / 2a5e6d88-9b8d-5371-b0d5-b6cdc3bfc787 / isResident=True"}], "answered": {"B_im_bestand": "JA - B (2014-04-20) = isResident=True, als Zuchttier gefuehrt. A (2014-05-18) = isResident=False, reiner Vorfahre."}, "open": {"cloud_query": "Zuechterin fragt nach 'Cloud' im Stammbaum von Nancy A. RPRO3 hat ZWEI Cloud (beide externe Ahnen, Rotfuchs-Schecke): Cloud1 dob 2012-06-09, Herkunft unbek, Gencode aa CC DD ee[f] GG pp Spsp, Kind Anubis (mit Arya); Cloud2 dob 2012-08-31, Herkunft 'Clan of New Dawn', Gencode aa CC D- ee[f] G- pp Spsp, Kind Sweet Heart (mit Arya). Beide vorgelegt; warte auf Auswahl welcher gemeint ist, dann fehlende Details ergaenzen."}}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 14. [NeedsInfo] Ticket `625a86ab` (ID: `625a86ab-51d8-4612-8292-5dbd93be2a5c`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Max" kommt mehrfach vor (2 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Max`
|
||||
|
||||
### AI Question Asked:
|
||||
> Ich habe die beiden Max-Einträge getrennt belassen. In deiner RennmausPro-Datenbank ist Max A (*01.02.2013, Marder) noch ohne Sterbedatum und Herkunft eingetragen (er hat den Status 'abgegeben'). Da er dein Zuchttier war: Wann ist er gestorben, was war die Todesursache und weißt du noch, woher du ihn gekauft hattest?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source": "rpro3-ambiguity", "name": "Max", "variants": [{"label": "A", "rids": ["133"], "count": 1, "dob": ["2013-02-01"], "farbe": ["Marder"], "origin": [], "is_own": true, "matched_gerbil_id": null}, {"label": "B", "rids": ["u7533"], "count": 1, "dob": [], "farbe": ["Schwarz-Schecke"], "origin": ["Clan of Tamyna"], "is_own": false, "matched_gerbil_id": null}]}
|
||||
|
||||
RESOLVED via rpro3-decisions.json: {"same": null, "different": [["133"], ["u7533"]], "fields": null}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 15. [NeedsInfo] Ticket `8d2f8f12` (ID: `8d2f8f12-2767-41af-86d9-07ae67530d61`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Drake" kommt mehrfach vor (2 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Drake`
|
||||
|
||||
### Target Gerbil Context:
|
||||
- **Name**: `Drake von den kleinen Chaoten`
|
||||
- **Gender**: `male` | **Status**: `Deceased`
|
||||
- **DOB**: `2012-06-06` | **DOD**: `2015-09-22`
|
||||
- **Genotype**: `Aa CC DD ee uw[d]uw[d] PP spsp`
|
||||
- **Notes**: `Farbschlag: Polarfuchs. Behalten (b). Tod durch Alterschwäche.`
|
||||
- **Import Source**: `Stammbaum von Jin.xlsx, Stammbaum von South Dakota.xlsx`
|
||||
- **ExternalRef**: `stammbaum-drakekleinenchaoten-06062012`
|
||||
- **Provenance History**:
|
||||
- In „Stammbaum von Jin.xlsx“ gefunden.
|
||||
- Geburtsdatum (06.06.2012) aus „Wurfchronik Teil 1_page_0019.md“.
|
||||
- Geschlecht (männlich) aus „Wurfchronik Teil 1_page_0019.md“.
|
||||
- Genotyp aus „Stammbaum von Jin.xlsx“.
|
||||
- Farbschlag aus „Stammbaum von Jin.xlsx“.
|
||||
- Sterbedatum (22.09.2015) aus „Stammbaum von Jin.xlsx“.
|
||||
- Auch in „Wurfchronik Teil 1_page_0019.md“ gefunden → Datensätze zusammengeführt.
|
||||
- ⚠ Sterbedatum 20.02.2016 aus „Wurfchronik Teil 1_page_0019.md“ verworfen — abweichend; 22.09.2015 aus „Stammbaum von Jin.xlsx“ verwendet (Mehrheit).
|
||||
- Konflikt bei Sterbedatum per Mehrheitsentscheidung gelöst.
|
||||
- Angaben aus der Wurfchronik übernommen.
|
||||
|
||||
### AI Question Asked:
|
||||
> Alles klar, danke! Ich habe notiert: Die beiden Drake-Einträge sind verschiedene Tiere und werden getrennt geführt.
|
||||
|
||||
Zu deiner Frage: Ja, das erste Tier (A) — dein eigener Drake, geboren am 06.06.2012, eigene Zucht — ist bei dir im Bestand eingetragen. Da er aus 2012 stammt, führe ich ihn als dein früheres Zuchttier bzw. Vorfahre. Soll ich bei ihm zusätzlich vermerken, dass er bereits verstorben ist? Wenn ja, sag mir gern das Sterbedatum (oder einfach „nur verstorben, Datum unbekannt").
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source": "rpro3-ambiguity", "name": "Drake", "decision": "all_distinct", "variants": [{"label": "A", "rids": ["52"], "dob": "2012-06-06", "farbe": "Polarfuchs", "origin": "eigene Zucht", "is_own": true, "matched_gerbil_id": "00f7dee7-d590-58d2-81b2-0b305049265a", "distinct": true, "in_bestand": true, "isResident": true, "note": "eigenes Tier, im Bestand, geb 2012 -> faktisch ehemaliges Zuchttier/Vorfahre"}, {"label": "B", "rids": ["u158"], "dob": "2005-12-14", "farbe": "Schwarz-Schecke", "origin": "Clan of Longfieldcity", "is_own": false, "distinct": true, "note": "externer Ahn"}], "status": "Bestätigt: alle verschieden. Bestätigt: A ist im Bestand (isResident=true). Offen: ob A als verstorben markiert werden soll + ggf. Sterbedatum."}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 16. [NeedsInfo] Ticket `90a757b4` (ID: `90a757b4-5599-4cd9-9903-4995305f3051`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Mia" kommt mehrfach vor (3 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Mia`
|
||||
|
||||
### Target Gerbil Context:
|
||||
- **Name**: `Mia von den Kleinen Chaoten`
|
||||
- **Gender**: `female` | **Status**: `Deceased`
|
||||
- **DOB**: `2013-03-04` | **DOD**: `2017-04-15`
|
||||
- **Genotype**: `aa c[chm]c[h] DD EE UwUw Pp spsp`
|
||||
- **Notes**: `Farbschlag: Siam. Behalten. Died + AS 15.4.17.`
|
||||
- **Import Source**: `Stammbaum von Jeremy.xlsx, Stammbaum von Kalea.xlsx, Stammbaum von South Dakota.xlsx, Stammbaum von Vance.xlsx`
|
||||
- **ExternalRef**: `stammbaum-miakleinenchaoten-04032013`
|
||||
- **Provenance History**:
|
||||
- In „Stammbaum von Jeremy.xlsx“ gefunden.
|
||||
- Geburtsdatum (04.03.2013) aus „Wurfchronik Teil 1_page_0022.md“.
|
||||
- Geschlecht (weiblich) aus „Wurfchronik Teil 1_page_0022.md“.
|
||||
- Genotyp aus „Stammbaum von Jeremy.xlsx“.
|
||||
- Farbschlag aus „Wurfchronik Teil 1_page_0022.md“.
|
||||
- Sterbedatum (15.04.2017) aus „Wurfchronik Teil 1_page_0022.md“.
|
||||
- Auch in „Wurfchronik Teil 1_page_0022.md“ gefunden → Datensätze zusammengeführt.
|
||||
- Auch in „Wurfchronik Teil 1_page_0043.md“ gefunden → Datensätze zusammengeführt.
|
||||
- Eltern über Position im Stammbaum erkannt (Quelle: „Stammbaum von Jeremy.xlsx“).
|
||||
- ⚠ Vater-Kandidat „Ina von den kleinen Chaoten (*14.04.2013)“ verworfen — falsches Geschlecht für die Vaterrolle; „Flippi von den kleinen Chaoten (*12.01.2011)“ verwendet.
|
||||
- ⚠ Mutter-Kandidat „Flippi von den kleinen Chaoten (*12.01.2011)“ verworfen — bereits als anderer Elternteil gewählt; „Koo gen. Kyra of Golden Lights (*29.10.2012)“ verwendet.
|
||||
- Angaben aus der Wurfchronik übernommen.
|
||||
|
||||
### AI Question Asked:
|
||||
> Ich habe Mia A (eigene Zucht, Siam) und die schwarze Mia B+C getrennt geführt. In RennmausPro ist Mia A (*04.03.2013) noch als 'aktiv' eingetragen und hat kein Sterbedatum. Da sie verstorben ist: Wann und woran ist sie gestorben?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source": "rpro3-ambiguity", "name": "Mia", "round": 2, "decisions": {"B==C": "same animal (schwarze Mia, Fam.Wendt/Privat) -> merge B(u3600)+C(u3744)", "A": "Zuechterin fragte ob A im Bestand -> beantwortet: ja, isResident=true (Mia von den Kleinen Chaoten, #107, dob 2013-03-04, Marder-Hell, eigene Zucht). A vermutlich separates Tier von B/C; finale Bestaetigung A!=BC angefragt."}, "pending": "Bestaetigung dass A ein anderes Tier ist als B+C (dann 2 distinkte Mias: A eigen-resident, BC extern).", "variants": [{"label": "A", "rids": ["107"], "dob": ["2013-03-04"], "farbe": ["Marder-Hell"], "origin": ["eigene Zucht"], "is_own": true, "matched_gerbil_id": "0166b845-6567-5b35-bc72-19f5d8e11112", "isResident": true}, {"label": "B", "rids": ["u3600"], "farbe": ["Schwarz"], "origin": ["Fam.Wendt"], "is_own": false}, {"label": "C", "rids": ["u3744"], "farbe": ["Schwarz"], "origin": ["Privat"], "is_own": false}]}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 17. [NeedsInfo] Ticket `be6e1446` (ID: `be6e1446-4eef-46a3-8b86-a2fc9e41a699`)
|
||||
- **Breeder's Message**: "RennmausPro-Import: Name „Ben" kommt mehrfach vor (3 Varianten) — dasselbe Tier oder verschiedene?"
|
||||
- **Category**: `General` | **Target Name**: `Ben`
|
||||
|
||||
### Target Gerbil Context:
|
||||
- **Name**: `Ben`
|
||||
- **Gender**: `female` | **Status**: `Breeding`
|
||||
- **DOB**: `2023-01-21` | **DOD**: `None`
|
||||
- **Genotype**: `None`
|
||||
- **Notes**: `None`
|
||||
- **Import Source**: `Wurfchronik-Detail.docx`
|
||||
- **ExternalRef**: `docx-145-ben-2023-01-21`
|
||||
- **Provenance History**:
|
||||
- In „Wurfchronik-Detail.docx“ gefunden.
|
||||
- Geburtsdatum (21.01.2023) aus „Wurfchronik-Detail.docx“.
|
||||
- Geschlecht (weiblich) aus „Wurfchronik-Detail.docx“.
|
||||
- Farbschlag aus „Wurfchronik-Detail.docx“.
|
||||
- Angaben aus der Wurfchronik übernommen.
|
||||
|
||||
### AI Question Asked:
|
||||
> Die drei Ben-Einträge bleiben getrennt. Ben B (Ben of Ben & Lulu) trägt nun die Farbe 'WFNZ Agouti'. In RennmausPro hat Ben A (*04.03.2013) noch den Status 'aktiv' und kein Sterbedatum. Da er dein ehemaliges Zuchttier ist: Wann ist er gestorben und woran?
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
{"source": "rpro3-ambiguity", "name": "Ben", "round": 2, "decision": "all_different", "variants": [{"label": "A", "rids": ["105"], "dob": "2013-03-04", "farbe": "Colourpoint-Agouti-Hell", "origin": "eigene Zucht", "is_own": true, "parents": {"vater": "Flippi", "mutter": "Kyra"}, "offspring": ["Lighting", "Amelia", "Derek", "Alex", "Eliza"], "co_parent": "Sammy 11", "note": "Zuechterin bestaetigt alle verschieden; A ist ihr eigenes Zuchttier -> beim RennmausPro-Ahnen-Import als (ehemaliges) Zuchttier fuehren, nicht nur Vorfahre."}, {"label": "B", "rids": ["u3354", "u6923"], "dob": "2005-02-01", "farbe": "Agouti", "origin": "Clan of Ben und Lulu", "is_own": false}, {"label": "C", "rids": ["u1409"], "dob": "2005-01-01", "farbe": "Agouti", "origin": "Privat aus Holland", "is_own": false}], "status": "Zuechterin sagt: alle verschieden (getrennt fuehren). A als eigenes Zuchttier bestaetigt. Offen: evtl. Sterbedatum fuer A angefragt.", "matched_gerbil_id_note": "matched_gerbil_id 121afa38... ist fuer alle 3 Varianten identisch und zeigt in der Live-App auf ein anderes Tier (dob 2023-01-21) -> nicht als A-Residenz verwenden. A-Residenz ergibt sich aus RPRO3 (eigene Zucht + eigene Wuerfe)."}
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 18. [NeedsInfo] Ticket `955e39e6` (ID: `955e39e6-299c-4f46-b6b7-0556aa9c2673`)
|
||||
- **Breeder's Message**: "Wieso wird das Tier als Zuchttier angezeigt? Du solltest die Jungtiere aus einer Verpaarung nicht automatisch als Bestandstiere (Zucht- oder Liebhabertier) aufnehmen. Ausnahme es gibt einen Stammbaum, wo steht, dass ich dies getan habe. Ansonsten solltest du diese Tiere bei den Würfen lassen, bis ich selbst sie nicht als Bestandstiere ausgewählt habe. Denn es kann auch vorkommen, dass ich Tiere aus den Würfen abgebe oder zur Abgabe stehen oder zurückbekomme. Da sollte es also eine Option geben, wo das auswählen kann. Bitte mach das für alle Tiere, die in den Würfen vorkommen, aber nicht in einem Stammbaum."
|
||||
- **Category**: `General` | **Target Name**: `-`
|
||||
|
||||
### Target Gerbil Context:
|
||||
- **Name**: `-`
|
||||
- **Gender**: `female` | **Status**: `Breeding`
|
||||
- **DOB**: `2022-03-05` | **DOD**: `None`
|
||||
- **Genotype**: `aa Cc[chm] D- ee[f] Gg Pp spsp`
|
||||
- **Notes**: `None`
|
||||
- **Import Source**: `Wurfchronik-Detail.docx`
|
||||
- **ExternalRef**: `docx-74--2022-03-05`
|
||||
- **Provenance History**:
|
||||
- In „Wurfchronik-Detail.docx“ gefunden.
|
||||
- Geburtsdatum (05.03.2022) aus „Wurfchronik-Detail.docx“.
|
||||
- Geschlecht (weiblich) aus „Wurfchronik-Detail.docx“.
|
||||
- Genotyp aus „Wurfchronik-Detail.docx“.
|
||||
- Farbschlag aus „Wurfchronik-Detail.docx“.
|
||||
- Angaben aus der Wurfchronik übernommen.
|
||||
|
||||
### AI Question Asked:
|
||||
> Kurze Rückfrage, bevor ich das umstelle: Diese Regel würde aktuell rund 102 Tiere betreffen, die nur aus der Wurfchronik stammen (94 davon haben einen Namen, 8 sind ohne Namen). Viele der benannten könnten ja durchaus deine echten Zuchttiere sein. Wie möchtest du es: (a) nur die namenlosen Jungtiere künftig NICHT als Zuchttier führen, oder (b) wirklich alle 102, oder (c) als „kein Zuchttier“ nur die markieren, die du schon abgegeben hast? Sag mir einfach a, b oder c.
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
TRIAGE 2026-06-23 Lauf2: isResident-Override-Mechanismus implementiert (merge_and_resolve post-dedup, matcht externalRef oder name+dob). Allgemeine Jungtier-Regel würde 102 resident Breeding-Tiere aus Wurfchronik/docx ohne Stammbaum betreffen (94 benannt, 8 namenlos) -> zu breit für Blind-Umsetzung, Scope-Rückfrage a/b/c gestellt. Bei (a): externalRef-isResident-Overrides fuer die 8 namenlosen; bei (b): generelle Default-Aenderung in merge_and_resolve; bei (c): an ReceiverContactId/GivenAway koppeln.
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 19. [NeedsInfo] Ticket `e9790985` (ID: `e9790985-306d-4f32-94f1-a02a9469e3ad`)
|
||||
- **Breeder's Message**: "Arya Stark wurde an die Zucht Clan of Black Forest abgegeben und sollte nicht mehr bei meiner Zucht angezeigt werden"
|
||||
- **Category**: `General` | **Target Name**: `Arya Stark von den Kleinen Chaoten`
|
||||
|
||||
### Target Gerbil Context:
|
||||
- **Name**: `Arya Stark von den Kleinen Chaoten`
|
||||
- **Gender**: `female` | **Status**: `Deceased`
|
||||
- **DOB**: `2020-06-30` | **DOD**: `2024-12-31`
|
||||
- **Genotype**: `aa c[chm]c[chm] DD Ee Gg PP spsp`
|
||||
- **Notes**: `None`
|
||||
- **Import Source**: `Stammbaum von Alberto Kids.xlsx, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity.xlsx, Stammbaum von Picus Son.xlsx`
|
||||
- **ExternalRef**: `stammbaum-aryastarkkleinenchaoten-30062020`
|
||||
- **Provenance History**:
|
||||
- In „Stammbaum von Alberto Kids.xlsx“ gefunden.
|
||||
- Geburtsdatum (30.06.2020) aus „Stammbaum von Alberto Kids.xlsx“.
|
||||
- Geschlecht (weiblich) aus „Stammbaum von Alberto Kids.xlsx“.
|
||||
- Genotyp aus „Stammbaum von Alberto Kids.xlsx“.
|
||||
- Farbschlag aus „Stammbaum von Alberto Kids.xlsx“.
|
||||
- Sterbedatum (31.12.2024) aus „Stammbaum von Alberto Kids.xlsx“.
|
||||
- Eltern über manuelle Entscheidung erkannt (Quelle: „Stammbaum von Alberto Kids.xlsx“).
|
||||
|
||||
### AI Question Asked:
|
||||
> Abgabedatum an Clan of Black Forest und ob Arya dort verstorben ist (DOD 31.12.2024 stammt aus dem Stammbaum).
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
|
||||
## 20. [NeedsInfo] Ticket `d99563fa` (ID: `d99563fa-a5cf-43da-aaa6-78bd4b677cb2`)
|
||||
- **Breeder's Message**: "Falsches Foto"
|
||||
- **Category**: `Foto` | **Target Name**: `Odelia von den Kleinen Chaoten`
|
||||
|
||||
### Target Gerbil Context:
|
||||
- **Name**: `Odelia von den Kleinen Chaoten`
|
||||
- **Gender**: `female` | **Status**: `Deceased`
|
||||
- **DOB**: `2015-06-21` | **DOD**: `2018-11-24`
|
||||
- **Genotype**: `AA Cc[chm] D- Ee uw[d]uw[d] P- Spsp`
|
||||
- **Notes**: `+ 13.05.18 zu schwach vom Entwurmen geworden. DD-Tumor seit April 18.`
|
||||
- **Import Source**: `Stammbaum von Vance.xlsx, Stammbaum von Zac (Vance.Dorie).xlsx`
|
||||
- **ExternalRef**: `stammbaum-odeliakleinenchaoten-21062015`
|
||||
- **Provenance History**:
|
||||
- In „Stammbaum von Vance.xlsx“ gefunden.
|
||||
- Geburtsdatum (21.06.2015) aus „Stammbaum von Vance.xlsx“.
|
||||
- Geschlecht (weiblich) aus „Wurfchronik Teil 1_page_0049.md“.
|
||||
- Genotyp aus „Stammbaum von Vance.xlsx“.
|
||||
- Farbschlag aus „Wurfchronik Teil 1_page_0049.md“.
|
||||
- Sterbedatum (24.11.2018) aus „Stammbaum von Vance.xlsx“.
|
||||
- Auch in „Stammbaum von Fire Kids.xlsx“ gefunden → Datensätze zusammengeführt.
|
||||
- Auch in „Stammbaum von Stella Kids.xlsx“ gefunden → Datensätze zusammengeführt.
|
||||
- Auch in „Wurfchronik Teil 1_page_0049.md“ gefunden → Datensätze zusammengeführt.
|
||||
- ⚠ Sterbedatum 13.05.2018 aus „Wurfchronik Teil 1_page_0049.md“ verworfen — abweichend; 24.11.2018 aus „Stammbaum von Vance.xlsx“ verwendet (Mehrheit).
|
||||
- ⚠ Genotyp AA CC DD EE gg PP Spsp rere aus „Wurfchronik Teil 1_page_0049.md“ verworfen — abweichend; AA Cc[chm] D- Ee uw[d]uw[d] P- Spsp aus „Stammbaum von Vance.xlsx“ verwendet (Mehrheit).
|
||||
- Eltern über manuelle Entscheidung erkannt (Quelle: „Stammbaum von Vance.xlsx“).
|
||||
- Konflikt bei Sterbedatum per Mehrheitsentscheidung gelöst.
|
||||
- Konflikt bei Genotyp per Mehrheitsentscheidung gelöst.
|
||||
- Angaben aus der Wurfchronik übernommen.
|
||||
|
||||
### AI Question Asked:
|
||||
> Auf Odelias Seite sollten unter dem Reiter "Fotos" drei Bilder liegen. Schau bitte dort einmal nach: Siehst du dort gar keine Bilder (z. B. nur graue/leere Kästchen), oder siehst du drei Bilder? Und falls du Bilder siehst: Welches davon (das 1., 2. oder 3.) zeigt wirklich Odelia? Dann kann ich die falschen entfernen und das richtige als Profilfoto setzen.
|
||||
|
||||
### Internal Agent Context:
|
||||
```json
|
||||
Ticket: 'Falsches Foto' bei Odelia (gerbilId 3609e642-...). Befund: GET /gerbils/{id}/photos liefert 3 Fotos (sortOrder 0/1/2), alle 3 Dateien laden serverseitig sauber (HTTP 200, echte JPEG-Bytes: 16585/8305/16104 B). profilePhotoUrl = /photos/files/33ae45c2...jpeg (= sortOrder 0). Frontend rendert via API_BASE_URL (client.ts) -- gleiche Basis wie die Datenabrufe, die bei ihr offenbar funktionieren, daher kein offensichtlicher Base-URL-Bug. Ihre Antwort 'Ich sehe keine fotos' widerspricht der urspruenglichen Meldung 'Falsches Foto' und beantwortet nicht, welches Foto korrekt ist. Hypothesen: (a) sie schaut nicht im Fotos-Tab; (b) tatsaechliches Anzeige-/Ladeproblem auf dem Handy (Android Chrome, Zugriff ueber 192.168.2.124:5173). Warte auf Klarstellung: sieht sie 0 oder 3 Bilder, und welches zeigt Odelia. Wenn 'keine Bilder trotz vorhandener Dateien' -> Frontend-/Proxy-Bug auf Handy weiterverfolgen; wenn sie 3 sieht -> falsche entfernen + Profilfoto setzen.
|
||||
```
|
||||
|
||||
### Triage Resolution Plan:
|
||||
- [ ] **Action**: Investigate original message and check database records for appropriate override.
|
||||
|
||||
---
|
||||
@@ -1,130 +1,710 @@
|
||||
# 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[-])
|
||||
# Ticket-Triage — Lauf gegen Prod (2026-08-19)
|
||||
|
||||
16 offene Tickets (12 Open, 4 Answered) in 13 Bündeln, je ein Triage-Agent gegen Prod-API
|
||||
(http://truenas:8090/api) + lokale Quellen (Stammbaum-xlsx, Wurfchronik, _rpro3.db,
|
||||
tools/import/output/*.json). Ergebnis: **alle 13 Bündel sind umsetzbar, keine Rückfrage nötig.**
|
||||
|
||||
Übergreifender Befund: Prod ist auf dem aktuellen Importer-Stand (2451 Tiere vs. lokal 2450),
|
||||
die beklagten Dubletten stecken also schon in `resolved_import.json`. Mehrere Tickets von heute
|
||||
sind Folge-Tickets aus dem Juli-Lauf, deren Entscheidungen die neuen Chart-Varianten nicht mehr
|
||||
greifen (Wurfchronik-Elternstubs ohne Geburtsdatum).
|
||||
|
||||
## Ergebnis des Laufs (2026-08-19/20)
|
||||
|
||||
**Alle 16 Tickets sind Resolved**, jeweils mit Changelog fuer die Zuechterin; in Prod sind
|
||||
0 Tickets offen. Umsetzung in 5 datei-disjunkten Workstreams (Importer-Logik + Ingest-Backend,
|
||||
Stammbaum-Extraktion, Genetik-Engine, Frontend, Daten-Entscheidungen), Verifikation zentral:
|
||||
Python-Tests, 158 vitest, 325 Playwright, 290 dotnet-Tests gruen; Migration
|
||||
`ReseedColorVarietiesGen6Saphir`; Deploy via CI; Prod-Ingest per Upload
|
||||
(`-4 stale gerbils`, `-4 stale litters`, +1 Kontakt, 9 Freeze-Overrides respektiert);
|
||||
27/27 Nachpruefungen gegen die Prod-API gruen.
|
||||
|
||||
### Offene Folgeaufgaben (bewusst NICHT in diesem Lauf)
|
||||
|
||||
- **H2-Wurf 13.04.2013**: listet 6 Jungtiere, die Wurfchronik nennt WS=5 — „Akina von den Kleinen
|
||||
Chaoten" und „Alkina" sind vermutlich dasselbe Tier (Merge braucht eine Entscheidung der Zuechterin).
|
||||
- **`tools/import/genotype.py` kann die interne Kompaktnotation nicht parsen** (`Ccchm`, `cchmcchm`,
|
||||
`Cch`, `efef`) und `c[-]`/`e[-]`-Bracket-Unknowns: 88 Tiere laufen deshalb in den C/E-Guard von
|
||||
`resolve_color_and_genotype` und behalten ihr Text-Label; 29 distinkte Gencodes liefern in TS und
|
||||
Python unterschiedliche Namen. Ein Parser-Fix aktiviert die Farbherleitung von ~55 Tieren auf
|
||||
einmal → eigenes Ticket mit Vorher/Nachher-Liste.
|
||||
- **5 vorbestehende Doppel-Keys in `conflict-decisions.json`** (`('lotta','')`, `('sunny','2014-04-10')`,
|
||||
`('jd','')`, `('blue','')`, `('danny','2009-10-09')`): dort gewinnt still der letzte Eintrag →
|
||||
potenzieller Datenverlust, Aufraeum-Ticket.
|
||||
- **Chart-Zeilenversatz bei tieferen Ahnen** (Akane-Muster) in den neuen Charts: Arya Stark erhaelt
|
||||
Vance x Milka statt Vance x Sansa, Nani erhaelt Udo x Milka statt Udo x Hedwig.
|
||||
- **Ophelie-Dublette**: „Stammbaum von Valentino Firehearts Kids.xlsx" fuehrt K79 „Ophelie von den
|
||||
Kleinen Chaoten" mit Kathlins Geburtsdatum (*20.04.2021) → zweite Ophelie neben der echten
|
||||
(*06.05.2023), vermutlich DOB-Verschreiber in der Quelle.
|
||||
- **Kukes Gencode**: Chart sagt `aa CC DD Ee Gg Pp Spsp` (Schwarz-Schecke), rpro3 sagt
|
||||
Anthrazit-Schecke (`gg`). Es gilt der Chart-Wert; im Ticket-Changelog ist die Rueckfrage vermerkt.
|
||||
- **Merle**: zweiter Wurf mit Smoke vom 19.08.2024 (Tochter Speedy) ist noch nicht erfasst — im
|
||||
Changelog erfragt.
|
||||
- Die drei Merle-Tickets zeigen auf eine tote `gerbilId` (ce05fad7) → Links in der Ticketliste
|
||||
laufen ins Leere; Umhaengen waere ein reiner Feedback-Update.
|
||||
|
||||
## Cluster-Matrix
|
||||
|
||||
| Tickets | Thema | Layer | Cluster | Aufwand | Konfidenz |
|
||||
|---|---|---|---|---|---|
|
||||
| 2322c2a8 | Jay (*24.10.2021) — Farbschlag Zobel-Hell statt Zobel (C-Locus-Tippfehler c[hm]) | import-data | conflict-decisions-genotype-override | S | high |
|
||||
| 8a51ac73 | Kathlin von den Kleinen Chaoten (*20.04.2021) — falsche Eltern (Grossmutter Izumi als Mutter, kein Vater) | import-data | conflict-decisions-parent-override | S | high |
|
||||
| 65266679 | Bijou (*26.06.2022) — falsche Eltern: Arya Stark statt Louis × Nani of Black Forest | import-logic | extract-starless-dob-block | S | high |
|
||||
| 5851bb94 | Spike (*03.04.2015) — Verpaarung mit Jacky bestätigt; Eltern seines Geburtswurfs (T5/TS-Wurf) fehlen | import-data | import-data-conflict-decisions-litterparents | S | high |
|
||||
| 6c89082e | Fast Boy of Golden Lights (*31.03.2017) — beide Eltern im Stammbaum falsch (Großvater als Vater, Schwiegermutter als Mutter) | import-data | conflict-decisions-resolutions-parents | S | high |
|
||||
| 24522f5f | JackJack (b6d8b3ef) — Abgabedatum-Zeile unter Abnehmer in der Tier-Akte (+ kaputtes Abgabedatum 1310-05-13) | frontend | gerbil-detail-abgabedatum-goHomeDate | S | high |
|
||||
| a547be62 | Eliza *20.08.2010 (E-Wurf Blacky x Kuke) — Gencode + Scheckungsart Ansatzschecke | import-logic | decisions-genotype-spottingtype-override | M | high |
|
||||
| 66ef9bdd, 3bbd6ab4 | Jamie (*27.12.2010, J-Wurf Danny x Jana) — Dublette: zwei weitere Wurfchronik-Elternstubs nie gemergt | import-data | conflict-decisions-mergeexternalrefs-wurfchronik-elternstubs | S | high |
|
||||
| 6c9539d2 | Sakura / Gwen gen. Sakura of little Angels — Dublette nach Merge-Fix zurueck (Folge-Ticket zu 7f9b3724) | import-data | wurfchronik-md-stub-dubletten-mergeexternalrefs | S | high |
|
||||
| 4f890829 | „Unbekannt" (be299aaf, *21.06.2014) — namenlose Q3-Dublette + falscher Vater des Q4-Wurfs | import-data | conflict-decisions-wurfchronik-page0009-platzhalter-eltern | S | high |
|
||||
| 7037f5d8 | Merle (2023-06-18): Herkunft leer + noch als Zuchttier im Bestand (Abgabe 22.12.2024 fehlt) | import-data | conflict-decisions-resolutions-merge-and-resolve | M | medium |
|
||||
| bde4ec70, f89e95ad | JackJack (b6d8b3ef) + A-Wurf 18.02.2010 (29f4bae9): Saphir wird als Platin errechnet — C-Locus-Zygotie fehlt im Farbkatalog | genetics-engine | genetik-c-locus-zygotie-saphir-platin | M | high |
|
||||
| 24006ccd, ea803c3d | Kuke (prod e4898ccc): fehlender Zuchtname + alter Name „Kruke" in 5 Wurf-Notizen | import-logic | conflict-decisions+merge_and_resolve-rename | M | high |
|
||||
|
||||
## Befund & Fix je Bündel
|
||||
|
||||
### Jay (*24.10.2021) — Farbschlag Zobel-Hell statt Zobel (C-Locus-Tippfehler c[hm])
|
||||
|
||||
Tickets: `2322c2a8-a616-4967-88f6-031b4519a1ca` · Layer **import-data** · Cluster `conflict-decisions-genotype-override` · Aufwand S · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Jay steht in Prod auf ColorVarietyId 00000000-0000-0000-0000-000000000053 = "Zobel-Hell" mit Genotype "aa c[chm]c[hm] Dd Ee gg P- spsp". Die Quell-Charts "Stammbaum von Alberto Kids.xlsx" und "Stammbaum von Pukas Kids.xlsx" enthalten diesen String woertlich (sharedStrings geprueft) — das zweite C-Allel ist als "c[hm]" statt "c[chm]" geschrieben (fehlendes c, Tippfehler); das Farbschlag-Label beider Charts lautet korrekt "Zobel". tools/import/genotype.py akzeptiert "hm" in den C-Locus-Regexen (Zeilen 28 und 38), mappt es auf Allel 'c^hm' != 'cchm', dadurch ist in _colourpoint_name (Z. 383/389) both_cchm=False -> "Zobel-Hell". Die Regel "GENOTYPE WINS" in merge_and_resolve.resolve_color_and_genotype (ab Z. 530) laesst diesen falsch abgeleiteten Namen das richtige Quell-Label "Zobel" ueberschreiben. Zusatzschaden: der TS-Parser (gerbil-manager-web/src/genetics/genotype.ts, Allele nur C/cchm/ch) kennt "c[hm]" nicht -> Jays Genotyp ist in UI/Probeverpaarung ungueltig. Dass "c[hm]" = "c[chm]" gemeint ist, ist zweifach belegt: (1) alle anderen Zobel-Tiere der Zucht (Plasma, Smoke, Valentino Fireheart, Kalea "Zobel schecke") haben c[chm]c[chm], "Zobel-Hell" kommt in keinem Chart als Label vor; (2) Genetik-Ausschluss: Vater Hagrid Rubeus of Black Forest = "Aa Cc[chm] dd Ee gg Pp Spsp", Mutter Arya Stark von den Kleinen Chaoten = "aa c[chm]c[chm] DD Ee Gg PP spsp" — kein Elternteil traegt c[h], ein c[h] bei Jay ist also unmoeglich, waehrend c[chm] von beiden Eltern kommen kann (aa/Dd/Ee/gg/spsp passen ebenfalls). Zobel im Katalog = aa c[chm]c[chm] DD EE gg PP spsp (Id ...0004); Dd/Ee/P- sind mit dem Zobel-Phaenotyp vereinbar.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Data-driven Fix in tools/import/conflict-decisions.json — neue Resolution im Array "resolutions" (Mechanismus "genotype"-Override, existiert dort bereits 36x, greift in extract.apply_conflict_decisions via canon(name)+dob):
|
||||
|
||||
{
|
||||
"name": "Jay",
|
||||
"dob": "24.10.2021",
|
||||
"decision": "C-Locus = c[chm]c[chm] — die Quell-Charts schreiben 'c[hm]' (fehlendes c, Tippfehler); Farbschlag-Label beider Charts ist 'Zobel', und c[h] ist genetisch unmoeglich, da beide Eltern kein c[h] tragen (Hagrid Rubeus Cc[chm], Arya Stark c[chm]c[chm]). Ticket 2322c2a8",
|
||||
"genotype": "aa c[chm]c[chm] Dd Ee gg P- spsp",
|
||||
"source": "Zuechterin-Ticket 2322c2a8 (2026-08-19) — „Falsche Farbe. Er ist ein Zobel“"
|
||||
}
|
||||
|
||||
Danach: python tools/import/extract.py && python tools/import/merge_and_resolve.py, dann Upload-Ingest gegen Prod. Erwartetes Ergebnis fuer feebbbe2-c656-505e-a2c1-c0d7dd1a6ebc: Genotype = "aa c[chm]c[chm] Dd Ee gg P- spsp", ColorVarietyId = 00000000-0000-0000-0000-000000000004 (Zobel), Genotyp in der UI wieder parsebar. Kollisionsrisiko geprueft: die zwei weiteren Roh-"Jay"-Saetze (jay, jay-2) haben leeres dob, Key ("jay","") matcht die Resolution nicht.
|
||||
|
||||
Optionale Haertung (separat, weil Genetik-kritisch -> nur mit Tests): in tools/import/genotype.py den Token "hm" aus den C-Locus-Regexen (Zeile 28: "C": re.compile(r"^(C|c)(\[(?:chm|chl|ch|h|hm|e|-)\])?…") und Zeile 38) entfernen ODER "c[hm]" explizit als Alias auf 'c^chm' normalisieren, plus Regressionstest in tools/import/test_genotype.py ("aa c[chm]c[hm] Dd Ee gg P- spsp" darf nicht Zobel-Hell ergeben). Aktuell ist Jay der einzige Datensatz in resolved_import.json mit "hm]"-Token; in den Charts steht "aa Cc[hm] …" noch 2x (Stammbaum von Fire Kids.xlsx, Stammbaum von Stella Kids.xlsx) — dort phaenotypisch irrelevant (C dominant), aber der c[chm]-Carrier-Status fehlt der Probeverpaarung.
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Jay wird jetzt richtig als Zobel geführt. In seinen beiden Stammbaum-Dateien hatte sich beim Gencode ein kleiner Schreibfehler eingeschlichen (bei einem der beiden Farb-Bausteine fehlte ein Buchstabe), dadurch hat das Programm ihn als „Zobel-Hell" einsortiert. Sein Gencode lautet nun „aa c[chm]c[chm] Dd Ee gg P- spsp" und passt damit auch zu seinen Eltern Hagrid Rubeus und Arya Stark — die helle Variante wäre bei diesen Eltern gar nicht möglich gewesen. Nebeneffekt: sein Gencode lässt sich jetzt auch wieder für Probeverpaarungen verwenden.
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json; tools/import/genotype.py; tools/import/merge_and_resolve.py; tools/import/test_genotype.py
|
||||
|
||||
---
|
||||
|
||||
### Kathlin von den Kleinen Chaoten (*20.04.2021) — falsche Eltern (Grossmutter Izumi als Mutter, kein Vater)
|
||||
|
||||
Tickets: `8a51ac73-c891-4c51-9b09-959856c8c46c` · Layer **import-data** · Cluster `conflict-decisions-parent-override` · Aufwand S · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Kathlins Eltern wurden per chart-position rekonstruiert und dabei um eine Zeile verrechnet (gleicher Offset-Fehler wie im Akane-Fall 88389f8e: in diesen Charts sitzt das Elternpaar eine Zeile unter dem 3-Zeilen-Kindblock). Prod: LitterId ecff0b8a "Wurf von Izumi + Ella von den Kleinen Chaoten", FatherId=NULL, MotherId=2cb68a8f = Izumi (*07.02.2018) — Izumi ist real Kathlins GROSSMUTTER (Mutter von Yuki). Provenance belegt das Chaos: Vater-Kandidaten Vestra/Unit/Izumi wegen falschem Geschlecht verworfen, Mutter-Kandidat Yuki verworfen, dann Ella ("ein Wurf hat nur einen Vater und eine Mutter") verworfen. Die Stammbaum-xlsx sagen einheitlich in 6 Charts: Kathlin = Vater Yuki von den Kleinen Chaoten (*03.02.2020, maennlich) x Mutter Ella (*10.06.2019) — belegt in "Stammbaum von CP-Fuchs, CP-Sa Sp von Unity.xlsx" und "Stammbaum von Sa von Johnny Kids.xlsx" (Kathlin K79-K81, Eltern N80/N81, Grosseltern Q79 Chevrolet Camaro of Topolino + Q80 Izumi bzw. Q81 Louis + Q82 Roswitha, Urgrosseltern R80 "Nduga & Benjiro", R81 "Hannah & Petri", R82 "Jin & Sokrates"), ebenso Blitzis Kids (N67 -> Q67/Q68), Valentino Firehearts Kids (N81 -> Q81/Q82), CP Maedels von Grisu und CP-Polarfuchs von Grisu (K35 -> N36/N37). Zusatzbeleg: die handschriftliche Notiz der Zuechterin direkt unter Kathlins Zelle ("Woher kommt das ch?? Louis? Izumi?") verfolgt genau diese Linie. Genetisch/altersmaessig plausibel (Kathlin AA ee[f] spsp aus Yuki Aa Ee Spsp x Ella Aa ee[f] spsp). Die Vorfahren oberhalb sind in Prod bereits korrekt (Yuki 55fba1b1 -> Wurf fd9310db "Camaro + Izumi"; Ella ebc87c8c -> Wurf d7c4e38c "Louis + Roswitha") — nach dem Fix stimmt die ganze Ahnentafel. Kein Rueckfall: fuer Kathlin existiert bisher kein Eintrag in conflict-decisions.json.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Owner-Regel, data-driven, re-ingest-stabil: EINEN Eintrag in tools/import/conflict-decisions.json -> "resolutions" ergaenzen (kein Live-DB-Patch), danach extract.py + merge_and_resolve.py + Upload-Ingest gegen Prod:
|
||||
|
||||
{
|
||||
"name": "Kathlin von den Kleinen Chaoten",
|
||||
"dob": "20.04.2021",
|
||||
"decision": "Eltern sind Yuki von den Kleinen Chaoten (*03.02.2020, Vater) x Ella (*10.06.2019, Mutter) — so in allen 6 Stammbaum-Charts (z. B. 'Stammbaum von CP-Fuchs, CP-Sa Sp von Unity.xlsx' K79 -> N80/N81, Grosseltern Q79 Camaro + Q80 Izumi / Q81 Louis + Q82 Roswitha). Chart-Position hatte die Grossmutter Izumi als Mutter gesetzt und keinen Vater (Zeilen-Offset wie im Akane-Fall).",
|
||||
"father": "Yuki von den Kleinen Chaoten",
|
||||
"fatherDob": "03.02.2020",
|
||||
"mother": "Ella",
|
||||
"motherDob": "10.06.2019",
|
||||
"source": "Ticket 8a51ac73 — Stammbaum-xlsx (Zuechterin verweist ausdruecklich darauf)"
|
||||
}
|
||||
|
||||
Wirkung: extract.apply_conflict_decisions ersetzt Kathlins parentRefs komplett (method=decision, confidence=high), merge_and_resolve legt den virtuellen Wurf "Wurf von Yuki von den Kleinen Chaoten + Ella" (20.04.2021) an; der alte Wurf ecff0b8a (einziges Kind = Kathlin) wird beim Upsert-Ingest stale und entfernt. Namensschluessel "Ella" + motherDob ist erprobt (Danielle-Resolution -> Wurf e1a14122 "Makoto + Ella", motherId ebc87c8c); "Yuki von den Kleinen Chaoten" ist mehrfach vergeben, daher fatherDob zwingend.
|
||||
|
||||
Verifikation nach Ingest: GET /api/gerbils/5a8e3035-... -> litterId zeigt auf "Wurf von Yuki von den Kleinen Chaoten + Ella" mit fatherId 55fba1b1-f59e-5117-92bf-85e950c953f9 und motherId ebc87c8c-0528-5b70-a251-9d32858dbc68; Stammbaum-Ansicht muss darueber Camaro/Izumi und Louis/Roswitha zeigen. Zusaetzlich python test_merge_resolve.py + test_extract.py gruen.
|
||||
|
||||
Getrennt zu ticketn (NICHT Teil dieses Fixes): "Stammbaum von Valentino Firehearts Kids.xlsx" fuehrt K79 "Ophelie von den Kleinen Chaoten" mit *20.04.2021 (Kathlins DOB) und macht Baxter x Kathlin zu ihren Eltern -> dadurch existiert in Prod eine zweite Ophelie b981d41d (*20.04.2021) neben der echten b413df65 (*06.05.2023); vermutlich DOB-Verschreiber/Dublette im neuen Chart.
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Kathlins Stammbaum ist korrigiert: Als Eltern stehen jetzt Yuki von den Kleinen Chaoten (*03.02.2020) als Vater und Ella (*10.06.2019) als Mutter — so wie es in Deinen Excel-Stammbäumen steht. Vorher war fälschlich Izumi als Mutter eingetragen (das ist Yukis Mutter, also Kathlins Großmutter) und es fehlte der Vater. Damit stimmen auch die weiteren Vorfahren: über Yuki kommen Chevrolet Camaro of Topolino und Izumi dazu, über Ella Louis und Roswitha.
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json; tools/import/extract.py (_reconstruct_parents / apply_conflict_decisions); tools/import/merge_and_resolve.py (virtuelle Wuerfe aus Decision-parentRefs); Prod-Daten: Gerbil 5a8e3035-bf60-5af8-9275-7d40352a27b5, Litter ecff0b8a-ac39-52c1-ac85-2cd442a901c5
|
||||
|
||||
---
|
||||
|
||||
### Bijou (*26.06.2022) — falsche Eltern: Arya Stark statt Louis × Nani of Black Forest
|
||||
|
||||
Tickets: `65266679-9bc3-43da-8280-cf8e7bb5e576` · Layer **import-logic** · Cluster `extract-starless-dob-block` · Aufwand S · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Prod zeigt Bijou (4b60e1fe) im virtuellen Wurf 237ed36d mit motherId=b4f59085 (Arya Stark von den Kleinen Chaoten) und ohne Vater; Provenance: "Eltern über Position im Stammbaum erkannt (Stammbaum von Alberto Kids.xlsx)" + "⚠ Elternteil Nani of Black Forest verworfen — ein Wurf hat nur einen Vater und eine Mutter". Laut Chart (identisch in "Stammbaum von Alberto Kids.xlsx" und "Stammbaum von Pukas Kids.xlsx", Gen-2-Band Spalte K): Vater = Louis of Black Forest (*22.08.2019, †09.04.2024, Block K44–K46, "Aa CC dd EE Gg P- spsp", blaue Box = männlich), Mutter = Nani of Black Forest (*15.10.2021, †27.11.2025, K48–K50). Arya Stark ist genetisch unmöglich als Mutter (Arya "aa c[chm]c[chm] …" kann Bijou "AA CC …" nicht vererben) — sie ist real die Mutter von Jay, also Bijous Partner, d. h. der Nachbar-Ast im Chart. URSACHE im Importer: tools/import/extract.py, extract_stammbaum → die Blockerkennung verlangt ein Sternchen vor dem Geburtsdatum (`is_block_dob = re.match(r"^\*\s?\d", t)`, ebenso DOB-Regex Zeile 33). In beiden Charts fehlt der Stern bei Louis (Zelle K45 = "22.08.2019/+09.04.2024") → der gesamte Block wird übersprungen, Louis existiert im Chart-Extrakt nicht, `_reconstruct_parents` nimmt als "nächsten Block oberhalb" Arya Stark (K35) aus Jays Ast und Nani als zweite Mutter, die anschließend als überzählige Mutter verworfen wird. Repo-weiter Scan aller 55 Charts: genau 3 solche sternlosen DOB-Blöcke (Louis 2×, Jiminy of Black Forest in "Stammbaum von Stella Kids.xlsx"), also ein klar begrenzter, generischer Importer-Bug — kein Rückfall einer alten conflict-decisions-Entscheidung (zu Bijou/Louis existiert kein Eintrag).
|
||||
|
||||
**Fix:**
|
||||
|
||||
Code-Fix im Importer (kein Live-DB-Patch, kein conflict-decisions-Eintrag nötig, da Louis/Jiminy bereits als deduplizierte Tiere existieren):
|
||||
|
||||
1) tools/import/extract.py, in `extract_stammbaum` direkt nach `cells = xu.read_cells(z, sheets[0], ss)` (ca. Zeile 231, vor `fillsex = …`) einfügen:
|
||||
|
||||
```python
|
||||
# FIX (Ticket 65266679 Bijou): einige Charts vergessen den Stern vor dem Geburtsdatum
|
||||
# ("22.08.2019/+09.04.2024" statt "*22.08.2019/+09.04.2024"). Ohne Stern erkennt die
|
||||
# Blockheuristik das Tier gar nicht (Louis of Black Forest) und _reconstruct_parents
|
||||
# greift in den Nachbar-Ast (Bijou bekam Arya Stark). Nur normalisieren, wenn die Zelle
|
||||
# wirklich in einem Block sitzt: Genotyp <=3 Zeilen darunter UND Namenszelle <=3 darüber.
|
||||
_bare_dob = re.compile(r"^\s*\d{1,2}\.\s?\d{1,2}\.\d{4}(\s*/\s*\+.*)?$")
|
||||
for (c, r), t in list(cells.items()):
|
||||
if not _bare_dob.match(t):
|
||||
continue
|
||||
has_geno = any(gt.looks_like_genotype(cells.get((c, rr), "")) for rr in range(r + 1, r + 4))
|
||||
has_name = any(cells.get((c, rr)) and not re.match(r"^\*?\s?\d", cells[(c, rr)])
|
||||
and not gt.looks_like_genotype(cells[(c, rr)]) for rr in range(r - 3, r))
|
||||
if has_geno and has_name:
|
||||
cells[(c, r)] = "*" + t.strip()
|
||||
```
|
||||
|
||||
2) Regressionstest in tools/import/test_extract.py mit dem vorhandenen `_make_xlsx`-Helper, z. B.:
|
||||
```python
|
||||
tmp = os.path.join(tempfile.gettempdir(), "starless_dob.xlsx")
|
||||
_make_xlsx(tmp, {
|
||||
"K44": "Louis of Black Forest",
|
||||
"K45": "22.08.2019/+09.04.2024", # Stern fehlt (Quelldatei-Tippfehler)
|
||||
"K46": "Aa CC dd EE Gg P- spsp",
|
||||
"N44": "Ignoriertes Datum 01.01.2020", # ohne Genotyp/Name-Kontext -> kein Block
|
||||
})
|
||||
animals = e.extract_stammbaum(tmp)
|
||||
by_name = {a["name"]: a for a in animals}
|
||||
check("sternloses DOB wird als Block erkannt", "Louis of Black Forest" in by_name)
|
||||
check("DOB/Todesdatum trotzdem geparst",
|
||||
by_name["Louis of Black Forest"]["dob"] == "22.08.2019"
|
||||
and by_name["Louis of Black Forest"]["death"] == "09.04.2024")
|
||||
```
|
||||
|
||||
3) Verifikation: `python test_extract.py test_extract_docx.py test_genotype.py test_merge_resolve.py test_extract_contracts.py` grün, dann `python tools/import/extract.py` + `extract_contracts.py` + `merge_and_resolve.py`; in resolved_import.json prüfen: Bijou (*26.06.2022) → Vater "Louis of Black Forest" (louisblackforest-22082019 / Prod b7f0a7bd-6420-58ea-bf59-5ff96979b9d5), Mutter "Nani of Black Forest"; Velvet (*23.03.2022) → Vater Jiminy of Black Forest. Danach Upload-Ingest gegen Prod (`curl -X POST http://truenas:8090/api/import/ingest-resolved/upload -F "resolved=@tools/import/output/resolved_import.json"`).
|
||||
|
||||
Verifizierter Prototyp-Diff (Monkeypatch über alle 55 Charts, verglichen wurden Tier-Set und alle parentRefs): nur 3 Dateien ändern sich, rein additiv — neu erkannt Louis of Black Forest (Alberto Kids, Pukas Kids) und Jiminy of Black Forest (Stella Kids); geänderte Elternlinks nur Bijou (Arya Stark → Louis of Black Forest) und Velvet (Tomomi → Jiminy of Black Forest, deckt sich mit Wurfchronik S20-Wurf 23.03.2022 "Chelsea × Jiminy [Black Forest]"; Velvet hängt in Prod fälschlich am R20-Wurf 21.03.2022 Belica/Zac, Litter e92f2afe). Kein Tier verloren, keine weiteren parentRefs berührt. Der falsche virtuelle Wurf 237ed36d hat nur Bijou als Kind und wird beim Upsert-Ingest als stale entfernt.
|
||||
|
||||
Separat (nicht Teil dieses Fixes, eigenes Ticket sinnvoll): in denselben Charts sind tiefere Ahnen-Paare um eine Zeile versetzt (Akane-Muster) — Arya Stark erhält Vance × Milka statt Vance × Sansa, Nani erhält Udo × Milka statt Udo × Hedwig.
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Bijous Eltern sind jetzt richtig: Vater Louis of Black Forest (*22.08.2019), Mutter Nani of Black Forest (*15.10.2021) — genau so, wie es im Stammbaum von Albertos Kids und im Stammbaum von Pukas Kids steht. Vorher stand dort Arya Stark als Mutter; das war der Nachbar-Ast im Stammbaum (Arya ist die Mutter von Jay, Bijous Partner) und konnte auch von der Farbvererbung her nicht passen.
|
||||
|
||||
Ursache: In den Stammbaum-Tabellen fehlte bei Louis das Sternchen vor dem Geburtsdatum ("22.08.2019" statt "*22.08.2019"). Das Programm erkannte das Kästchen deshalb gar nicht als Tier und hat als Vater/Mutter versehentlich die Tiere aus dem darüberliegenden Ast genommen. Das Einlesen versteht solche Datumsangaben jetzt auch ohne Sternchen — du musst in den Excel-Dateien nichts korrigieren.
|
||||
|
||||
Nebenbei mit korrigiert: Bei Velvet (*23.03.2022) ist nun ebenfalls der richtige Vater eingetragen, Jiminy of Black Forest — passend zum S-Wurf von Chelsea und Jiminy aus der Wurfchronik.
|
||||
|
||||
**Betroffen:** tools/import/extract.py; tools/import/test_extract.py; Stammbaum von Alberto Kids.xlsx; Stammbaum von Pukas Kids.xlsx; Stammbaum von Stella Kids.xlsx; Gerbil Bijou 4b60e1fe-8b57-505b-9683-5e1536670b99; Litter 237ed36d-1d65-5e4d-bf38-1ed32542b530; Gerbil Velvet f5ffda6d-458c-570d-9cf6-67ac0c5e37a5
|
||||
|
||||
---
|
||||
|
||||
### Spike (*03.04.2015) — Verpaarung mit Jacky bestätigt; Eltern seines Geburtswurfs (T5/TS-Wurf) fehlen
|
||||
|
||||
Tickets: `5851bb94-582e-4d83-846a-faa3e6df7049` · Layer **import-data** · Cluster `import-data-conflict-decisions-litterparents` · Aufwand S · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Zwei getrennte Befunde. (1) Die Verpaarung Spike x Jacky ist KORREKT und quellenbelegt: die handschriftliche Detail-Chronik "Wurfchronik Teil 1" S.52 (PNG geprüft) schreibt "Spike + Jacky *14.08.15 => D7-Wurf, WS:3" mit genau den drei Jungtieren Smartie/Cookie/Cloe; die Wurfchronik-xlsx (sheet1 Zeile 197) nennt dieselbe Verpaarung als "C7 | *14.08.2015 | Mutter Jacky | Vater 'Spike /Jack Jr.?'". Die frühere Rückfrage beruhte auf einem Denkfehler von mir: 4 Monate sind für einen Bock KEIN Hindernis (zuchtreif ab 9-12 Wochen, Zeugung ~20.07.2015 => Spike ~15 Wochen). Ein alternativer, älterer "Spike" existiert in keiner Quelle (1b6a76d1 *31.03.2017 = nach dem Wurf geboren, e185e7fa "Spike of colourful furry gerbils" *27.04.2015 = fremde Zucht und jünger, 0d381af5 "Spikey" *13.01.2012 = anderes Tier/anderer Wurf). Die Antwort "OK, dieser Spike existiert tatsächlich" bestätigt damit den Eintrag => nichts zu ändern am Wurf aa48fc4a. (2) Der eigentliche Grund für ihre Frage "Woher hast du dieses Böckchen?" ist ein echter Datenmangel: Spikes Geburtswurf 1e49ad0e ("TS-Wurf", 03.04.2015) hat in prod FatherId=MotherId=NULL, seine Akte zeigt also keine Herkunft. Ursache: die Seiten-Extraktion "Wurfchronik Teil 1_page_0044.md" liefert für ALLE 9 Würfe dieser Seite fatherId/motherId=null (Eltern stehen dort nur im Overview-Fließtext "TS-Wurf (Jack Jr + Lila)"). Die xlsx belegt sie eindeutig: Zeile 162 "T5 | *03.04.2015 | Mutter Lila [Kleine Chaoten] | Vater Jack Jr. [Kleine Chaoten] | 4/6". Beide Elterntiere existieren bereits mit plausibler Lebenszeit (Jack Jr. von den Kleinen Chaoten 595361b4 *19.06.2013; Lila dacb12a3 *13.02.2014, +15.05.2015). Konsistenzcheck: Wurfschwester Viven (*03.04.2015) passt zur bekannten Geschwisterverpaarung Spike x Viven (H9-Wurf 29.01.2016, xlsx Z.256). Kein Rückfall durch die 12 neuen Charts vom 2026-08-18 — der Wurf stammt komplett aus der Wurfchronik, keine frühere Entscheidung betroffen.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Kein Eingriff am D7-Wurf (aa48fc4a) — Spike bleibt Vater. Ein Datenfix, re-ingest-stabil über den bestehenden litterParents-Mechanismus (merge_and_resolve.py ~Z.4098, Match litterName+date, Auflösung via name+dob): neuer Eintrag im Array "litterParents" in C:\Users\gulum\dev\GerbilManager\tools\import\conflict-decisions.json:
|
||||
|
||||
{
|
||||
"litterName": "TS-Wurf",
|
||||
"date": "03.04.2015",
|
||||
"father": "Jack Jr. von den Kleinen Chaoten",
|
||||
"fatherDob": "19.06.2013",
|
||||
"mother": "Lila",
|
||||
"motherDob": "13.02.2014",
|
||||
"source": "Ticket 5851bb94: Wurfchronik-xlsx sheet1 Z.162 (T5-Wurf *03.04.2015, Lila x Jack Jr.) + Overview 'Wurfchronik Teil 1_page_0044.md' ('TS-Wurf (Jack Jr + Lila)'); die Seiten-Extraktion 0044 liefert für alle Würfe fatherId/motherId=null"
|
||||
}
|
||||
|
||||
Danach: python tools/import/merge_and_resolve.py, Stichprobe (Wurf 1e49ad0e hat FatherId 595361b4 / MotherId dacb12a3, Spike 73d087f8 unverändert an diesem Wurf), dann Upload-Ingest gegen prod und Ticket mit fixNoteDraft schließen.
|
||||
|
||||
NICHT in diesem Ticket fixen (eigenes Ticket wert): auf Seite 0044 sind die Wurfbuchstaben OCR-verlesen ("5" als "S": TS=T5, SS=S5, US=U5, VS=V5), und die Buchstaben der Seite 0052 sind gegenüber der xlsx um einen verschoben (xlsx C7 *14.08.2015 = App "D7-Wurf"). Rein kosmetisch, betrifft keine Abstammung, aber verwirrend für die Züchterin.
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Der Eintrag ist richtig: In deiner Wurfchronik steht auf der Seite mit den August-2015-Würfen von Hand "Spike + Jacky *14.08.15" mit genau den drei Jungtieren Smartie, Cookie und Cloe - und in deiner Wurfchronik-Tabelle dieselbe Verpaarung (dort mit dem Zusatz "Spike / Jack Jr.?"). Mein Einwand, Spike sei mit gut 4 Monaten zu jung gewesen, war falsch: Böcke können schon ab etwa 2-3 Monaten Vater werden. Die Verpaarung Spike x Jacky bleibt also so stehen. Zusätzlich habe ich ergänzt, woher Spike selbst stammt: aus deinem Wurf vom 03.04.2015 von Lila und Jack Jr. von den Kleinen Chaoten - das stand bisher nirgends bei ihm, weil bei diesem Wurf die Elterntiere fehlten. Damit siehst du auf Spikes Seite jetzt auch seine Eltern und seine Wurfschwester Viven, mit der er später den Wurf vom 29.01.2016 hatte. Falls du sicher weißt, dass doch Jack Jr. der Vater von Smartie, Cookie und Cloe war, sag Bescheid - dann tausche ich den Vater dort.
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json (litterParents); Wurf 1e49ad0e TS-Wurf 03.04.2015; Gerbil 73d087f8 Spike; Wurf aa48fc4a D7-Wurf (unverändert)
|
||||
|
||||
---
|
||||
|
||||
### Fast Boy of Golden Lights (*31.03.2017) — beide Eltern im Stammbaum falsch (Großvater als Vater, Schwiegermutter als Mutter)
|
||||
|
||||
Tickets: `6c89082e-4060-4ec8-8ebc-c287bdd6f31d` · Layer **import-data** · Cluster `conflict-decisions-resolutions-parents` · Aufwand S · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Beide Elternteile sind falsch importiert. Prod zeigt über den virtuellen Wurf 4d3afe0a ("Wurf von Danjo of Golden Lights + Xtra Bounty von den Schlossmäusen", 31.03.2017, einziges Kind = Fast Boy) Vater = Danjo of Golden Lights (cbac1d65, ohne DOB/Geschlecht) und Mutter = Xtra Bounty von den Schlossmäusen (354efa96). Belegt richtig sind Vater = Charly of Golden Lights (*05.04.2016, prod 80987264, male) und Mutter = Ziwa of Golden Lights (*29.04.2016, prod 26986f3a, female): (1) `python tools/import/rpro3_lookup.py tools/import/_rpro3.db "Fast Boy"` → "eigenes Tier, *2017-03-31 … Eltern: Vater Charly · Mutter Ziwa", Nachzucht Erestor *05.04.2018 mit Partner Addison; (2) Paar-Zelle R35 in C:/Users/gulum/dev/Sttammbäume/"Stammbaum von CP-Fuchs, CP-Sa Sp von Unity.xlsx" = "Charly of Golden Lights & Ziwa of Golden Lights", auf derselben Zeile wie Fast Boy (Q35); (3) "Stammbaum von Kid von Cassy & Blaster.xlsx": N75 Fast Boy, Q74 Charly *05.04.2016, Q75 Ziwa *29.04.2016. Danjo ist laut rpro3 der Vater von Ziwa, also Fast Boys GROSSVATER (Ziwa *29.04.2016 → Vater Danjo, Mutter Isiri); Xtra Bounty (*20.03.2014, eigenes Tier, Schlossmäuse, keine Nachzucht in rpro3) ist die Mutterlinie von Zuleika, Fast Boys Partnerin, die im Chart einen Block darunter steht (N62/N76 Zuleika mit Q62/Q76 Xtra Bounty + Q63/Q77 Ruby). URSACHE: die Chart-Positions-Heuristik. tools/import/output/animals.json führt für Fast Boy drei widersprüchliche parentRefs (Danjo/father, Xtra Bounty/mother, Ziwa/father) — der echte Vater Charly wurde NIE als parentRef erfasst, weil er im Chart eine Zeile ÜBER dem Kind steht (Q60/Q74), während die Großeltern-Paarzelle R61 "Isiri of Golden Lights & Danjo of Golden Lights" (gehört zu Ziwas Zeile 61) auf Fast Boys Zeile fällt. Anschließend verwarf `pick_parent_ref` Ziwa für die Vaterrolle ("falsches Geschlecht für die Vaterrolle" — steht wörtlich in der Provenance des Tiers) und fiel auf Danjo zurück. Die Zeilen-Offsets sind im xlsx nicht durch mergeCells gestützt (geprüft: im Bereich N/Q Zeilen 56–68 von "Stammbaum von Akio Kids.xlsx" existiert KEIN einziger Merge) und variieren von Block zu Block — ein Code-Fix der Heuristik ist deshalb nicht sicher möglich. Kein Rückfall durch die 12 neuen Charts vom 2026-08-18: es gab für dieses Tier bisher überhaupt keine Entscheidung (kein "Fast Boy"/"Ziwa"/"Charly of Golden" in conflict-decisions.json, kein Eintrag in rpro3-decisions.json).
|
||||
|
||||
**Fix:**
|
||||
|
||||
Owner-Regel: Datenfix im Importer, kein Live-Patch. In tools/import/conflict-decisions.json → Array "resolutions" die folgenden ZWEI Einträge anhängen (Muster identisch zu den 41 bestehenden father/mother-Einträgen; `extract.apply_conflict_decisions` ersetzt damit die kompletten parentRefs mit method "decision"/confidence "high"). fatherDob/motherDob sind zwingend mitzugeben, weil animals.json je zwei Charly-/Ziwa-Datensätze kennt (datenloser Paarzellen-Platzhalter ohne dob + datiertes Tier):
|
||||
|
||||
{
|
||||
"name": "Fast Boy of Golden Lights",
|
||||
"dob": "31.03.2017",
|
||||
"decision": "parents are Charly of Golden Lights x Ziwa of Golden Lights, NOT Danjo (= Ziwa's father, i.e. the grandfather) + Xtra Bounty (= Zuleika's dam line) - chart-position misread",
|
||||
"father": "Charly of Golden Lights",
|
||||
"fatherDob": "05.04.2016",
|
||||
"mother": "Ziwa of Golden Lights",
|
||||
"motherDob": "29.04.2016",
|
||||
"source": "Ticket 6c89082e - RennmausPro III (_rpro3.db: Vater Charly / Mutter Ziwa) + Paarzelle R35 in 'Stammbaum von CP-Fuchs, CP-Sa Sp von Unity.xlsx' + Q74/Q75 in 'Stammbaum von Kid von Cassy & Blaster.xlsx'"
|
||||
},
|
||||
{
|
||||
"name": "Ziwa of Golden Lights",
|
||||
"dob": "29.04.2016",
|
||||
"decision": "father = Danjo of Golden Lights, mother = Isiri of Golden Lights (rpro3; die Paarzelle R61 'Isiri of Golden Lights & Danjo of Golden Lights' in 'Stammbaum von Akio Kids.xlsx' ist rollenvertauscht)",
|
||||
"father": "Danjo of Golden Lights",
|
||||
"mother": "Isiri of Golden Lights",
|
||||
"source": "Ticket 6c89082e - RennmausPro III (_rpro3.db)"
|
||||
}
|
||||
|
||||
Der zweite Eintrag ist optional-aber-empfohlen: er hängt Danjo (+ Isiri) dort ein, wo sie hingehören (als Ziwas Eltern = Fast Boys Großeltern), sodass der Stammbaum eine Generation tiefer korrekt wird statt bei Ziwa abzubrechen. Beide Elternteile haben in prod unknown gender/kein DOB, die explizite Rollenangabe ist also nötig, weil die Paar-Reihenfolge im Chart hier vertauscht ist.
|
||||
|
||||
Danach: `python tools/import/extract.py` → `python tools/import/merge_and_resolve.py`, `python test_merge_resolve.py` grün, und in tools/import/output/resolved_import.json prüfen, dass Fast Boy (externalRef `stammbaum-fastboygoldenlights-31032017`) an einem Wurf mit FatherId = Charly of Golden Lights *05.04.2016 und MotherId = Ziwa *29.04.2016 hängt; dann Upload-Ingest gegen Prod (`curl -X POST http://truenas:8090/api/import/ingest-resolved/upload -F "resolved=@tools/import/output/resolved_import.json"`). Erwarteter Effekt: der virtuelle Wurf 4d3afe0a (Danjo × Xtra Bounty) verschwindet, weil er dadurch kinderlos und virtuell ist; ein neuer virtueller Wurf Charly × Ziwa vom 31.03.2017 mit Kind Fast Boy entsteht. Altersplausibilität ok (beide Eltern ~11 Monate bei Geburt, `parent_age_plausible` greift nicht), Geschlechter passen (Charly male → Vaterrolle, Ziwa female → Mutterrolle), Xtra Bounty bleibt unverändert als Elternteil von Zuleika erhalten.
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Bei Fast Boy of Golden Lights waren im Stammbaum beide Eltern falsch: als Vater stand Danjo of Golden Lights, der aber in Wirklichkeit sein Großvater ist (Danjo ist der Vater von Ziwa), und als Mutter stand Xtra Bounty von den Schlossmäusen, die zur Familie seiner Partnerin Zuleika gehört. Ursache war ein Verrutschen um eine Zeile beim Auslesen der Stammbaum-Tabellen — dort steht der Vater je nach Block manchmal eine Zeile höher, und dadurch wurde die Elternangabe der Großeltern-Zeile auf Fast Boy bezogen. Korrigiert nach deinen Angaben aus RennmausPro und den Stammbaum-Dateien: Vater ist jetzt Charly of Golden Lights (geb. 05.04.2016), Mutter ist Ziwa of Golden Lights (geb. 29.04.2016). Zusätzlich sind Danjo und Isiri of Golden Lights jetzt als Ziwas Eltern eingetragen, also als Fast Boys Großeltern — der Stammbaum reicht damit eine Generation weiter und stimmt wieder.
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json (resolutions); tools/import/extract.py (_reconstruct_parents / Chart-Positions-Heuristik, nur als Ursache — kein Code-Fix); tools/import/merge_and_resolve.py (pick_parent_ref, virtuelle Wuerfe); Stammbaum-Ansicht / Tierakte Fast Boy of Golden Lights; virtueller Wurf 4d3afe0a-1d78-5c55-bd99-4f216bd84afc
|
||||
|
||||
---
|
||||
|
||||
### JackJack (b6d8b3ef) — Abgabedatum-Zeile unter Abnehmer in der Tier-Akte (+ kaputtes Abgabedatum 1310-05-13)
|
||||
|
||||
Tickets: `24522f5f-8c31-46c7-8dd0-d9a88870febb` · Layer **frontend** · Cluster `gerbil-detail-abgabedatum-goHomeDate` · Aufwand S · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Zwei Ursachen. (1) FRONTEND: gerbil-manager-web/src/pages/GerbilDetailPage.tsx Z.493-495 rendert die Abgabedatum-Zeile nur bei `g.status === 'GivenAway'`, waehrend die Abnehmer-Zeile (Z.552-554) schon bei `g.receiverContactId || g.status !== 'Deceased'` erscheint. JackJack (prod b6d8b3ef, status=Deceased, receiverContactId=3192070e = "Larissa Draeger + Nicolas", goHomeDate gesetzt) zeigt daher den Abnehmer, aber kein Abgabedatum — genau die fehlende Zeile. (2) DATEN (blockiert 1): goHomeDate von JackJack ist "1310-05-13". Quelle ist die Transkription C:\Users\gulum\dev\Wurfchronik_Bilder\Wurfchronik Teil 1_page_0013.md, Eintrag g1000000-...-0002: `"givenAwayDate": "1310-05-13"`, obwohl die Notiz derselben Zeile "→ Larissa Draeger + Nicolas 13.5.10" = 13.05.2010 sagt (Wurfbruder Bodo auf derselben Seite: korrekt "2010-06-06"). parse_date() (tools/import/merge_and_resolve.py Z.595ff) mappt nur 1900<year<2000 (+100) und laesst 4-stellige Jahre <1900 unveraendert durch. Scan von tools/import/output/resolved_import.json: dies ist der EINZIGE Datensatz mit Jahr<1990 in DateOfBirth/DateOfDeath/GoHomeDate. Geprueft: /verified-gerbils zeigt fuer b6d8b3ef einen GerbilOverride (isVerified=false) mit protectedFields [name, spottingType, genotype, isDeaf] — goHomeDate ist NICHT eingefroren, der Importfix landet also nach Re-Ingest. Nebenbefund (gehoert zu Ticket 0cf6b838, hier NICHT fixen): lokal heisst das Tier in resolved_import.json noch "JackSack"; die renameTo-Resolution feuert im aktuellen Lauf nicht mehr, weil der Merge-Partner "Wurfchronik Teil 1_page_0001.md-3d8c071c-..." nach Neu-Transkription nicht mehr existiert — in Prod haelt nur ihr UI-Override den Namen "JackJack".
|
||||
|
||||
**Fix:**
|
||||
|
||||
A) FRONTEND — gerbil-manager-web/src/pages/GerbilDetailPage.tsx
|
||||
|
||||
A1) Z.493-495 loeschen:
|
||||
{g.status === 'GivenAway' && (
|
||||
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
|
||||
)}
|
||||
|
||||
A2) Z.552-554 ersetzen durch (Abgabedatum direkt UNTER Abnehmer):
|
||||
{/* Ticket 24522f5f (JackJack): Abgabedatum gehoert direkt unter den Abnehmer und
|
||||
darf nicht am Status haengen — Tiere, die abgegeben wurden und spaeter beim
|
||||
Abnehmer starben (status=Deceased), haben Abnehmer UND Abgabedatum. */}
|
||||
{(g.receiverContactId || g.goHomeDate || g.status !== 'Deceased') && (
|
||||
<>
|
||||
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</Kv>
|
||||
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
|
||||
</>
|
||||
)}
|
||||
Keine neuen Strings noetig (de.ts Z.94 `goHomeDate: 'Abgabedatum'`); formatDate(null) -> '—'.
|
||||
|
||||
A3) Optionaler Regressionsschutz: in gerbil-manager-web/e2e/mock-data.ts bei 'willi' (Z.180, Deceased) `receiverContactId: 'con-huber', goHomeDate: '2022-01-15'` ergaenzen und in e2e/tiere.spec.ts assert, dass die Akte de.pages.gerbils.fields.goHomeDate zeigt.
|
||||
|
||||
B) DATEN/IMPORT (zwingend zusammen mit A, sonst zeigt die neue Zeile "13.05.1310")
|
||||
|
||||
B1) tools/import/merge_and_resolve.py — neuen Resolution-Key `goHomeDate` zulassen. Im has_ovr-Tuple (Z.3211-3214) ergaenzen:
|
||||
or d.get("goHomeDate")
|
||||
und im Apply-Block direkt NACH dem dateOfDeath-Zweig (nach Z.3294) einfuegen:
|
||||
# goHomeDate: setzt/korrigiert das Abgabedatum autoritativ (Ticket 24522f5f).
|
||||
# Nötig, weil Wurfchronik-Transkriptionen das Datum falsch zusammenbauen
|
||||
# können (JackJack: "1310-05-13" statt 13.05.2010).
|
||||
if d.get("goHomeDate"):
|
||||
_gh = parse_date(d["goHomeDate"])
|
||||
if _gh and g.get("GoHomeDate") != _gh:
|
||||
g["GoHomeDate"] = _gh
|
||||
if g.get("Status") not in ("Deceased",) and not g.get("ReceiverContactId"):
|
||||
pass # Status bewusst unangetastet
|
||||
applied = True
|
||||
|
||||
B2) tools/import/conflict-decisions.json — bestehenden JackSack-Block (Z.1087-1099) um eine Zeile erweitern (Match ueber name+dob greift, das Tier heisst im resolved noch "JackSack"):
|
||||
{
|
||||
"name": "JackSack",
|
||||
"dob": "18.02.2010",
|
||||
"mergeExternalRefs": [
|
||||
[
|
||||
"Wurfchronik Teil 1_page_0013.md-g1000000-0000-0000-0000-000000000002",
|
||||
"Wurfchronik Teil 1_page_0001.md-3d8c071c-f172-44f6-aff5-ca392c284bf3"
|
||||
]
|
||||
],
|
||||
"renameTo": "JackJack",
|
||||
"goHomeDate": "13.05.2010",
|
||||
"decision": "Dublette JackSack/JackJack = dasselbe Tier (A-Wurf, *18.02.2010). Zusammenfuehren, richtiger Name JackJack. Abgabedatum aus der Wurfchronik-Notiz '-> Larissa Draeger + Nicolas 13.5.10' = 13.05.2010 (Quelle transkribiert faelschlich 1310-05-13).",
|
||||
"source": "Zuechterin - Tickets 0cf6b838, 24522f5f"
|
||||
},
|
||||
|
||||
B3) tools/import/merge_and_resolve.py parse_date() — Guard gegen implausible Jahre, damit kuenftige Fehl-Transkriptionen kein 14.-Jahrhundert-Datum importieren. Im ISO-Zweig (Z.601-610) vor `return d`:
|
||||
if year < 1900 or year > datetime.now().year + 1:
|
||||
return None
|
||||
(analog im DD.MM.YYYY-Zweig nach der Jahres-Normalisierung) + Regressionstest in tools/import/test_merge_resolve.py: parse_date("1310-05-13") is None, parse_date("13.5.10") == "2010-05-13".
|
||||
|
||||
C) VERIFIKATION: npx tsc --noEmit, npx vitest run, npx eslint auf GerbilDetailPage.tsx, betroffene playwright-Specs; python test_merge_resolve.py; merge_and_resolve.py neu laufen lassen und pruefen, dass b6d8b3ef GoHomeDate == "2010-05-13" hat; dann Upload-Ingest gegen Prod (POST /api/import/ingest-resolved/upload) und Akte pruefen: "Abnehmer: Larissa Draeger + Nicolas" + "Abgabedatum: 13.05.2010".
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** In der Tier-Akte steht das Abgabedatum jetzt direkt unter dem Abnehmer — und zwar immer, nicht nur bei Tieren mit Status „abgegeben". Bei JackJack war ausserdem das Abgabedatum aus der Wurfchronik falsch uebertragen worden (es stand ein Datum aus dem Jahr 1310); richtig ist der 13.05.2010, wie es in deiner Notiz „→ Larissa Draeger + Nicolas 13.5.10" steht. Das ist korrigiert, und der Importer verwirft solche unmoeglichen Jahreszahlen künftig, statt sie zu uebernehmen.
|
||||
|
||||
**Betroffen:** gerbil-manager-web/src/pages/GerbilDetailPage.tsx; gerbil-manager-web/e2e/mock-data.ts; tools/import/merge_and_resolve.py; tools/import/conflict-decisions.json; tools/import/test_merge_resolve.py
|
||||
|
||||
---
|
||||
|
||||
### Eliza *20.08.2010 (E-Wurf Blacky x Kuke) — Gencode + Scheckungsart Ansatzschecke
|
||||
|
||||
Tickets: `a547be62-3611-43b8-b72b-446978d545bf` · Layer **import-logic** · Cluster `decisions-genotype-spottingtype-override` · Aufwand M · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Eliza (29cbd5e6, Quelle: Wurfchronik-md „Wurfchronik Teil 1_page_0013.md", Roh-Id g1000000-…-011) hat in der Quelle NUR colorDescription „Schwarz-Sp", keinen Gencode. Der angezeigte „aa CC DD EE GG PP Spsp rere" ist reiner Kanon-Fill: resolve_color_and_genotype() nimmt den Katalog-Genotyp der Variety „Schwarz" (ColorVariety 007) und hängt Spsp an, weil das Label scheckig klingt. Die Antwort der Züchterin („aa C- DD E- G- P- Spsp", Ansatzschecke) ist damit echte Neuinformation; genotype.parse() nimmt den String sauber an (unmappedTokens leer) und liefert Farbschlag „Schwarz" → ColorVarietyId 007 bleibt korrekt. Ein Nachtragen scheitert aktuell an ZWEI Lücken: (1) tools/import/merge_and_resolve.py ~Z.3302 wendet einen decision-`genotype` nur an, wenn Genotype LEER ist (`if d.get("genotype") and not g.get("Genotype")`) — genau dieser Guard hat bereits die vorhandene Entscheidung für CHRIS (externalRef „Wurfchronik Teil 1_page_0013.md-g1000000-0000-0000-0000-000000000007", soll „aa Cc[-] DD Ee gg P- Spsp", ist „aa CC DD EE gg PP Spsp rere") stillschweigend verschluckt; nur Tiere ohne Quell-Genotyp (Jana, Kyara) bekamen ihren Override. (2) `spottingType` existiert im Importer-Pfad gar nicht, und GerbilManagerWebAPI/Import/IngestResolvedService.cs (Upsert-Block Z.126–152) kopiert Gerbil.SpottingType NICHT — ein Payload-Wert würde ignoriert. Eliza hat zwar einen GerbilOverride, der friert aber nur „causeOfDeath" ein, blockiert also nichts.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1) DATEN — tools/import/conflict-decisions.json: die BESTEHENDE Eliza-Resolution (Key externalRef …-011, Ticket aa5238b6) IN PLACE erweitern, KEINEN zweiten Eintrag mit derselben externalRef anlegen (`_ovr_by_extref` ist ein dict, der letzte gewinnt → isResident/notes gingen verloren):
|
||||
{"externalRef": "Wurfchronik Teil 1_page_0013.md-g1000000-0000-0000-0000-000000000011", "isResident": false, "genotype": "aa C- DD E- G- P- Spsp", "spottingType": "Ansatzschecke", "notes": "Schwarz-Schecke; Tochter von Blacky x Kuke (E-Wurf 2010); verstorben 06.04.2011 (Ausbruch-Unfall). War nie Zuchttier. NICHT die Mutter des J5-Wurfs — das ist die spätere Eliza (*13.02.2014).", "decision": "Zweite/2010er Eliza von der J5-Mutter getrennt; irreführende 'Mutter des J5-Wurfs'-Notiz entfernt. Gencode + Scheckungsart von der Züchterin: aa C- DD E- G- P- Spsp, Ansatzschecke (bisheriger Wert war nur Kanon-Fill aus dem Farbschlag 'Schwarz-Sp').", "source": "Ticket aa5238b6 + Ticket a547be62"}
|
||||
|
||||
2) IMPORTER — tools/import/merge_and_resolve.py, Override-Block:
|
||||
(a) has_ovr-Guard (~Z.3211-3216): `or d.get("originBreeder"))` → `or d.get("originBreeder") or d.get("spottingType"))`.
|
||||
(b) Match-Schleife (~Z.3228-3238): Präzision des Matches merken. Vor `if er and er in _ovr_by_extref:` ein `_precise = False` setzen; in den beiden externalRef-Zweigen `_precise = True`; im Zweig `elif (ck, iso) in _ovr_by_namedob:` zusätzlich `_precise = bool(iso)`; der name-only-Zweig `(ck, "")` bleibt unpräzise.
|
||||
(c) Z.3302-3304 ersetzen:
|
||||
# Ein expliziter Gencode aus einer Züchterin-Entscheidung ist AUTORITATIV und
|
||||
# überschreibt auch einen vorhandenen Wert — bei Wurfchronik-Tieren ist der meist
|
||||
# nur der Kanon-Fill des Farbschlags (resolve_color_and_genotype), keine echte
|
||||
# Gencode-Quelle. Nur bei PRÄZISEM Match (externalRef oder name+dob), damit eine
|
||||
# name-only-Entscheidung nicht den Gencode eines gleichnamigen fremden Tieres
|
||||
# überschreibt. (Ticket a547be62 Eliza; repariert auch Chris/page_0013.)
|
||||
if d.get("genotype"):
|
||||
_geno_ovr = d["genotype"].strip()
|
||||
if (_precise or not g.get("Genotype")) and g.get("Genotype") != _geno_ovr:
|
||||
g["Genotype"] = _geno_ovr
|
||||
applied = True
|
||||
# Scheckungsart (freier Text, Werte wie in de.ts `spottingTypes`, z. B. „Ansatzschecke").
|
||||
# Der Importer leitet sie aus keiner Quelle ab — nur per Entscheidung setzbar.
|
||||
if d.get("spottingType"):
|
||||
_sp_ovr = d["spottingType"].strip()
|
||||
if g.get("SpottingType") != _sp_ovr:
|
||||
g["SpottingType"] = _sp_ovr
|
||||
applied = True
|
||||
|
||||
3) BACKEND — GerbilManagerWebAPI/Import/IngestResolvedService.cs (Upsert der Gerbils):
|
||||
im Update-Zweig hinter `eg.GoHomeDate = g.GoHomeDate; eg.Genotype = g.Genotype; eg.Notes = g.Notes;` einfügen:
|
||||
// SpottingType nur übernehmen, wenn der Payload einen Wert liefert (der Importer
|
||||
// kennt die Scheckungsart nur über conflict-decisions) — sonst würde eine von Hand
|
||||
// erfasste Scheckungsart bei jedem Ingest genullt.
|
||||
if (g.SpottingType is not null) eg.SpottingType = g.SpottingType;
|
||||
und im `new Gerbil { … }`-Initializer `SpottingType = g.SpottingType,` ergänzen. (Keine Migration nötig, Spalte existiert; `ResolvedImportData.Gerbils` ist List<Gerbil>, „SpottingType" deserialisiert automatisch.)
|
||||
|
||||
4) TESTS: tools/import/test_merge_resolve.py — Regression: Resolution mit genotype+spottingType per externalRef überschreibt einen NICHT-leeren (kanon-gefüllten) Genotyp und setzt SpottingType; name-only-Resolution überschreibt einen vorhandenen Genotyp NICHT. GerbilManager.Tests/IngestResolvedServiceTests.cs — SpottingType-Roundtrip (Payload-Wert wird geschrieben; null im Payload lässt einen bestehenden Wert stehen).
|
||||
|
||||
5) AUSFÜHREN/PRÜFEN: nur `python tools/import/merge_and_resolve.py` nötig (animals.json/extract unverändert, weil die Entscheidung ein Wurfchronik-md-Tier trifft), dann Upload-Ingest gegen Prod. Erwartet: Eliza → Genotype „aa C- DD E- G- P- Spsp", SpottingType „Ansatzschecke", ColorVarietyId weiter 00000000-0000-0000-0000-000000000007; Chris → „aa Cc[-] DD Ee gg P- Spsp". Genotype-Diff des gesamten Payloads gegen den alten Stand sichten — es dürfen nur Tiere mit eigener Entscheidung wechseln.
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Elizas Gencode (*20.08.2010, Tochter von Blacky und Kuke) steht jetzt so in der Akte, wie du ihn angegeben hast: aa C- DD E- G- P- Spsp, Scheckungsart „Ansatzschecke". Vorher stand dort ein automatisch aus dem Farbschlag „Schwarz-Schecke" errechneter Wert, weil die Wurfchronik zu ihr keinen Gencode nennt. Dabei ist außerdem aufgefallen, dass deine Gencode-Angaben bisher nur dann übernommen wurden, wenn noch gar kein Wert vorhanden war — deshalb war z. B. auch bei Chris (gleicher Wurf) noch der automatische Wert zu sehen. Das ist behoben: dein Wert hat jetzt immer Vorrang, und die Scheckungsart lässt sich ebenfalls dauerhaft mitgeben. Beides bleibt bei künftigen Daten-Aktualisierungen erhalten.
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json; tools/import/merge_and_resolve.py (Override-Block ~Z.3211/3228/3302); GerbilManagerWebAPI/Import/IngestResolvedService.cs (Gerbil-Upsert Z.126-152); tools/import/test_merge_resolve.py; GerbilManager.Tests/IngestResolvedServiceTests.cs
|
||||
|
||||
---
|
||||
|
||||
### Jamie (*27.12.2010, J-Wurf Danny x Jana) — Dublette: zwei weitere Wurfchronik-Elternstubs nie gemergt
|
||||
|
||||
Tickets: `66ef9bdd-1a92-46cc-a268-8c1ce1bc3aaa, 3bbd6ab4-d9e4-4f02-a4de-952ff0dd3738` · Layer **import-data** · Cluster `conflict-decisions-mergeexternalrefs-wurfchronik-elternstubs` · Aufwand S · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Kein Rueckfall durch die 12 neuen Charts vom 18.08. (Wurfchronik-md-Dateien in C:\Users\gulum\dev\Wurfchronik_Bilder sind seit Juni unveraendert; Ticket 3bbd6ab4 stammt vom 13.08., also VOR dem Chart-Import). Ursache ist eine unvollstaendige alte Entscheidung: Jamie hat pro Wurfchronik-Seite, auf der er als Elternteil steht, einen eigenen dobless "empty shell"-Record. Der Kanon ist 8ddf3717 "Jamie von den kleinen Chaoten" *27.12.2010 (ExternalRef stammbaum-jamiekleinenchaoten-27122010). Die Resolution aus 06b6734d/e96db323/c092f8d2 enthaelt nur EIN mergeExternalRefs-Paar (Drop "Wurfchronik Teil 1_page_0022.md-page_0022_parent_jamie") — das greift korrekt (page_0022-Stub ist verschwunden). Die Stubs von page_0017 (id f651e1cf-54bc-4d8b-9be0-4b6298e7ab48, "Parent listed on page 0017") und page_0023 (id 7cbb62c5-7a46-4cb4-a3ad-bf7f94080e14, "Father of H2-Wurf") blieben uebrig und wurden vom Namens-Dedup untereinander zur gemeldeten Dublette 4e4e507f verschmolzen (Provenance: mergedRecordCount 2, sourceFiles Wurfchronik + page_0017); gegen den Full-Name-Kanon mergen sie nicht (Call-Name "Jamie" ohne DOB). Auswirkung: 9 Wuerfe haengen am falschen Jamie (U 13.12.2011, X 10.01.2012, E1 26.03.2012, M1 08.07.2012, H2 13.04.2013, L2 20.05.2013, N2 14.06.2013, V2 27.07.2013, W2 29.07.2013), der Kanon hat nur A2, D2 und den Chart-Wurf. Derselbe Fehler bei der H2-Mutter: Stub 336ad972 "Sakura" (page_0023 ...080e15) neben Kanon 4be212ef "Gwen gen. Sakura of little Angels" *17.12.2011 — die Sakura-Resolution (Index 147, Ticket 7f9b3724) nennt im Decision-Text ausdruecklich A2/D2/H2, hat aber nur page_0022_parent_sakura als Drop-Ref. Ohne diesen zweiten Merge bleiben nach dem Jamie-Merge zwei Wuerfe vom 13.04.2013 am Kanon stehen, weil deduped2 gleiches Datum UND beide Eltern identisch verlangt. mergeExternalRefs laeuft vor dem Gerbil-Dedup (merge_and_resolve.py L2743 auf all_processed_gerbils ab L2209) und remappt Litter-Father/MotherId (L3443-3448) — beide Stub-Records existieren zu diesem Zeitpunkt noch getrennt, daher sind beide Drop-Paare noetig.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Nur tools/import/conflict-decisions.json (Owner-Regel, re-ingest-stabil, deterministische Refs).
|
||||
|
||||
(1) In der bestehenden Resolution {"name":"Jamie von den kleinen Chaoten","dob":"27.12.2010", ...} das Feld mergeExternalRefs komplett ersetzen durch:
|
||||
"mergeExternalRefs": [
|
||||
["stammbaum-jamiekleinenchaoten-27122010", "Wurfchronik Teil 1_page_0022.md-page_0022_parent_jamie"],
|
||||
["stammbaum-jamiekleinenchaoten-27122010", "Wurfchronik Teil 1_page_0017.md-f651e1cf-54bc-4d8b-9be0-4b6298e7ab48"],
|
||||
["stammbaum-jamiekleinenchaoten-27122010", "Wurfchronik Teil 1_page_0023.md-7cbb62c5-7a46-4cb4-a3ad-bf7f94080e14"]
|
||||
]
|
||||
und im "source"-Feld ergaenzen: " + Tickets 66ef9bdd/3bbd6ab4 (restliche Wurfchronik-Elternstubs page_0017/page_0023)".
|
||||
|
||||
(2) In der Sakura-Resolution (resolutions-Index 147, {"externalRef":"stammbaum-gwengensakuralittleangels-17122011", ...}) mergeExternalRefs ersetzen durch:
|
||||
"mergeExternalRefs": [
|
||||
["gwengensakuralittleangels-17122011", "page_0022_parent_sakura"],
|
||||
["gwengensakuralittleangels-17122011", "Wurfchronik Teil 1_page_0023.md-7cbb62c5-7a46-4cb4-a3ad-bf7f94080e15"]
|
||||
]
|
||||
(Pflicht, sonst zeigt Jamie zwei Wuerfe vom 13.04.2013.)
|
||||
|
||||
(3) Optional (mittlere Sicherheit, Haupt-Loop entscheidet — sonst separates Ticket): neue Resolution anhaengen, damit im gemergten Wurf nicht Akina und Alkina doppelt stehen:
|
||||
{
|
||||
"externalRef": "stammbaum-akinakleinenchaoten-13042013",
|
||||
"mergeExternalRefs": [["stammbaum-akinakleinenchaoten-13042013", "Wurfchronik Teil 1_page_0023.md-a2867c29-3733-4f9e-a4b5-bf6e44b9d031"]],
|
||||
"decision": "'Alkina' (Wurfchronik H2-Wurf 13.04.2013, behalten, +01.12.2017) und 'Akina von den Kleinen Chaoten' (*13.04.2013, Mutter R3/G5) sind dasselbe Tier: H2 hatte laut Wurfchronik genau 5 Junge (Enzo, Schmidti, Lilly, Anthrazit, Alkina), Akina kann kein 6. Jungtier sein; Buchstabendreher blockiert den Auto-Merge.",
|
||||
"source": "Tickets 66ef9bdd/3bbd6ab4"
|
||||
}
|
||||
|
||||
Danach: python tools/import/merge_and_resolve.py, python test_merge_resolve.py, Stichprobe (Kanon 8ddf3717 muss U/X/E1/M1/H2+Chart-Wurf/L2/N2/V2/W2 + A2/D2 tragen; 4e4e507f und 336ad972 duerfen in resolved_import.json nicht mehr vorkommen; genau ein Wurf am 13.04.2013), dann Upload-Ingest gegen Prod (POST /api/import/ingest-resolved/upload). Keine Live-DB-Patches.
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Die beiden Jamie-Einträge sind jetzt endgültig zusammengeführt: Es bleibt nur der Jamie aus dem J-Wurf von Danny und Jana (geboren 27.12.2010). Beim letzten Mal war nur eine von drei Fundstellen aus der Wurfchronik zusammengeführt worden, deshalb tauchte er wieder doppelt auf. Jetzt landen alle seine Würfe bei ihm: U, X, E1, M1, H2, L2, N2, V2 und W2 zusätzlich zu A2 und D2. Gleichzeitig war seine Partnerin Sakura doppelt vorhanden — auch sie ist jetzt ein Tier (Gwen gen. Sakura of little Angels, geboren 17.12.2011), wodurch der Wurf vom 13.04.2013 nur noch einmal in der Liste steht und alle Jungtiere beisammen sind.
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json; tools/import/merge_and_resolve.py (mergeExternalRefs L2743-2786, Litter-Remap L3443-3448, deduped2 L3732); Gerbil 8ddf3717 Jamie von den kleinen Chaoten; Gerbil 4e4e507f Jamie (Dublette); Gerbil 336ad972 Sakura (Dublette); Litter 8f043703 H2-Wurf + Litter 27ab49ae (13.04.2013)
|
||||
|
||||
---
|
||||
|
||||
### Sakura / Gwen gen. Sakura of little Angels — Dublette nach Merge-Fix zurueck (Folge-Ticket zu 7f9b3724)
|
||||
|
||||
Tickets: `6c9539d2-91e2-4323-a76a-c8a1962dbca9` · Layer **import-data** · Cluster `wurfchronik-md-stub-dubletten-mergeexternalrefs` · Aufwand S · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Kein Effekt der 12 neuen Stammbaum-Charts, sondern eine unvollstaendige alte Entscheidung. Die Wurfchronik-Quelle (LLM-Markdown-Seiten in C:\Users\gulum\dev\Wurfchronik_Bilder, DIR_PATH in tools/import/merge_and_resolve.py) enthaelt ZWEI Sakura-Stubs: "Wurfchronik Teil 1_page_0022.md" (lokale id "page_0022_parent_sakura") und "Wurfchronik Teil 1_page_0023.md" (lokale id 7cbb62c5-7a46-4cb4-a3ad-bf7f94080e15). Die Entscheidung zu Ticket 7f9b3724 (tools/import/conflict-decisions.json, Eintrag externalRef "stammbaum-gwengensakuralittleangels-17122011", Zeilen ~1281-1293) listet in mergeExternalRefs nur den page_0022-Stub. Vor dem Fix wurden beide Stubs vom Call-Name-Dedup zu EINEM Tier verschmolzen (alte Id f9ba065c-dfb7-5c0f-bbf6-0d3c2e4afed9, in Prod jetzt 404). Da mergeExternalRefs VOR dem Dedup laeuft (merge_and_resolve.py Z.2743-2785) und den page_0022-Record entfernt, bleibt der page_0023-Stub als eigenes Cluster uebrig -> neue deterministische Id 336ad972-4e5f-50cb-92c9-71ce266d36a6 = uuid5(NAMESPACE_DNS, "Wurfchronik Teil 1_page_0023.md-7cbb62c5-7a46-4cb4-a3ad-bf7f94080e15") (nachgerechnet, stimmt exakt). Nicht gefundene Drop-Refs bzw. uebrig gebliebene Zwillinge werden still uebergangen (`continue`, keine Warnung). Identisches Muster bei JAMIE: Wurfchronik-Stub 4e4e507f-1ede-51e7-9079-ce6e7a4cb4af (ref "…page_0023.md-7cbb62c5-…e14") vs. kanonisches "Jamie von den kleinen Chaoten" 8ddf3717-5608-50d1-8b3d-78b8b198327a; die Jamie-Entscheidung (Z.~"stammbaum-jamiekleinenchaoten-27122010") merged ebenfalls nur page_0022_parent_jamie. Prod-Folgeschaden: H2-Wurf 8f043703 (13.04.2013) haengt an den beiden Stubs, waehrend Gwen 4be212ef zusaetzlich den chart-basierten Wurf 27ab49ae gleichen Datums traegt; L2-Wurf 66d5831a hat Mutter Gwen, aber Vater = Jamie-Stub.
|
||||
|
||||
**Fix:**
|
||||
|
||||
DATENFIX in tools/import/conflict-decisions.json (re-ingest-stabil, deterministisch), zwei Eintraege ergaenzen:
|
||||
|
||||
1) Eintrag mit "externalRef": "stammbaum-gwengensakuralittleangels-17122011" (Ticket 7f9b3724) — mergeExternalRefs erweitern auf:
|
||||
"mergeExternalRefs": [
|
||||
["gwengensakuralittleangels-17122011", "page_0022_parent_sakura"],
|
||||
["gwengensakuralittleangels-17122011", "Wurfchronik Teil 1_page_0023.md-7cbb62c5-7a46-4cb4-a3ad-bf7f94080e15"]
|
||||
]
|
||||
(Quelle-Feld ergaenzen: "source": "… + Ticket 6c9539d2 (zweiter Wurfchronik-Stub page_0023)")
|
||||
|
||||
2) Eintrag {"name": "Jamie von den kleinen Chaoten", "dob": "27.12.2010", …} — mergeExternalRefs erweitern auf:
|
||||
"mergeExternalRefs": [
|
||||
["stammbaum-jamiekleinenchaoten-27122010", "Wurfchronik Teil 1_page_0022.md-page_0022_parent_jamie"],
|
||||
["stammbaum-jamiekleinenchaoten-27122010", "Wurfchronik Teil 1_page_0023.md-7cbb62c5-7a46-4cb4-a3ad-bf7f94080e14"]
|
||||
]
|
||||
WICHTIG: immer den VOLLEN Ref inkl. Dateiname verwenden — die page_0023-UUIDs unterscheiden sich nur im letzten Zeichen (…e13 = JD, …e14 = Jamie, …e15 = Sakura), Suffix-Matching allein ist zu unscharf.
|
||||
|
||||
Danach: python tools/import/merge_and_resolve.py + Upload-Ingest gegen Prod. Erwartung (verifizieren): "Sakura" 336ad972 und "Jamie" 4e4e507f verschwinden; H2-Wurf und Chart-Wurf 27ab49ae haben nach dem _premerged_ids-Remap (merge_and_resolve.py Z.3445-3448) identische Eltern + Datum und werden vom Litter-Dedup Stage 2 zu einem Wurf kollabiert; L2-Wurf hat dann Vater "Jamie von den kleinen Chaoten".
|
||||
|
||||
OPTIONALE HAERTUNG (verhindert weitere Rueckfaelle, merge_and_resolve.py direkt nach dem mergeExternalRefs-Block ~Z.2782): pro angewandtem Paar pruefen, ob noch ein Record mit ImportSource "Wurfchronik" ohne DateOfBirth und gleichem normalisierten Call-Name wie der Keeper in all_processed_gerbils steht; wenn ja, Zeile in review-report.md/Konsole ausgeben ("Merge-Entscheidung evtl. unvollstaendig: <Name> — weiterer Wurfchronik-Stub <ExternalRef>"). Zusaetzlich beim stillen `continue` (Drop-Ref nicht gefunden) warnen. Systemische Kandidatenliste fuer eine eigene Aufraeum-Aufgabe: 67 DOB-lose Wurfchronik-Stubs, davon 14 mit Call-Name-Kollision zu einem Stammbaum-Tier (Raya, Gale von den Kleinen Chaoten, Jamie, Tai, Katsu, Willow, Qamikaze Queen, Eddard Stark, Xtra Bounty, SA, Joghurt, Maxi King, Fast Boy, Obelix).
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Sakura war tatsaechlich noch ein zweites Mal vorhanden: In der Wurfchronik ist sie auf zwei verschiedenen Seiten als Elterntier notiert, und beim letzten Zusammenfuehren wurde nur der eine der beiden Eintraege mit "Gwen gen. Sakura of little Angels" verschmolzen. Jetzt sind beide Eintraege zusammengefuehrt — dasselbe galt fuer Jamie, der ebenfalls doppelt geführt war. Damit haengen der H2-Wurf und der L2-Wurf vom 13.04. bzw. 20.05.2013 wieder bei den richtigen Eltern, und der doppelte Wurf vom 13.04.2013 ist zu einem Eintrag zusammengefasst.
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json; tools/import/merge_and_resolve.py (mergeExternalRefs-Block Z.2743-2785, Litter-Remap Z.3445-3448); C:\Users\gulum\dev\Wurfchronik_Bilder (Wurfchronik Teil 1_page_0022.md / _page_0023.md)
|
||||
|
||||
---
|
||||
|
||||
### „Unbekannt" (be299aaf, *21.06.2014) — namenlose Q3-Dublette + falscher Vater des Q4-Wurfs
|
||||
|
||||
Tickets: `4f890829-c2ad-4709-8d45-399eb077c12f` · Layer **import-data** · Cluster `conflict-decisions-wurfchronik-page0009-platzhalter-eltern` · Aufwand S · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Zwei zusammenhängende Import-Defekte. (1) DUBLETTE: Das Tier be299aaf „Unbekannt" (m, *21.06.2014, ExternalRef „Wurfchronik Teil 1_page_0009.md-90e23e20-3051-4048-b4b1-e2bc00b21736", Notes „Color: Silberagouti. Note: Ni. ? bleibt.") ist ein namenloses Jungtier des Q3-Wurfs (7d5c5218, 21.06.2014, Jacky × Gale/Drake Jr, WS 6). Die Quelle C:/Users/gulum/dev/Wurfchronik_Bilder/„Wurfchronik Teil 1_page_0009.md" listet die 6 Q3-Jungtiere NUR per Farbe ohne Namen; „…page_0028.md" listet dieselben 6 MIT Namen (Pünktchen/Agouti, Quebec/dd-Anthrazit, Kai-Uwe/Blau + 3 namenlose AWP-Tote). Da Platzhalter-Namen im Dedup absichtlich nie gemergt werden (tools/import/merge_and_resolve.py ~Z.2975 „is_placeholder → keep all separate"), bleiben 6 Seite-0009-Dubletten stehen (be299aaf, 911516bb, 1230a554, 089d5d1b, 11bbdb9e, 8dcb9e1f) — genau das hat die Züchterin erkannt („Hast du auch Kai-Uwe oder Pünktchen?"). (2) FALSCHE VERPAARUNG: Der Wurf 751f3dcc „Q4-Wurf" (10.12.2014, Kinder Sweet + Vee rich) trägt in der md-Quelle Seite 0038 die Elternangabe „Eltern: [Unbekannt] + Nelly". Die Eltern-Namensauflösung (merge_and_resolve.py Z.3600-3611, _resolve_name) hat keinen Platzhalter-Guard und löste den „Namen" Unbekannt auf das namenlose Jungtier be299aaf auf → FatherId=be299aaf, MotherId=None. parent_age_plausible greift nicht (Abstand 5,7 Monate = formal plausibel). Scan über alle 965 Würfe: nur dieser eine Wurf betroffen. Echte Eltern belegt: tools/import/output/litters.json Zeile „sheet1-Q4-10.12.2014" (Wurfchronik-xlsx, sheet1 row 131) nennt sireName „Earl [Kleine Chaoten]" + damName „Nelly [Kleine Chaoten]" → Earl d676e74d (m, *22.04.2013), Nelly b3826f4b (w, *12.07.2013); tools/import/_rpro3.db bestätigt das Paar (Earl × Nelly → Wade, *14.01.2015). Warum die xlsx-Angabe nicht griff: (a) `_father_name` war mit „Unbekannt" schon belegt, die Anreicherung füllt nur Lücken (Z.3575-3578), und (b) die Kandidaten-Verengung Z.3568 vergleicht litters.json-`litterId` („Q4") gegen `LitterLetter` („Q") → kein Treffer, 2 gleich-datierte Kandidaten (Q4+R4) → `continue`. Kein Rückfall durch die 12 neuen Charts vom 2026-08-18 — der Fall stammt komplett aus den Wurfchronik-md-Quellen.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Datenfix in tools/import/conflict-decisions.json (re-ingest-stabil, deterministisch), zwei Einträge:
|
||||
|
||||
1) `suppressExternalRefs` — Dubletten-Tier „Unbekannt" entfernen (die Züchterin: „Bitte lösche Unbekannt!"). An das bestehende Array anhängen:
|
||||
"Wurfchronik Teil 1_page_0009.md-90e23e20-3051-4048-b4b1-e2bc00b21736"
|
||||
(be299aaf hat keine Fotos, keine Verträge, keine Kinder und keinen Geburtswurf; die einzige Referenz ist der Q4-Vaterlink. Ein page_0009-Suppress-Block existiert bereits — gleiche Musterstelle.) Optional den `_doc_suppress`-Text ergänzen: „… sowie das namenlose Q3-Jungtier von Seite 0009 (Ticket 4f890829), das als 'Unbekannt'-Vater des Q4-Wurfs aufgelöst wurde."
|
||||
|
||||
2) `litterParents` — echte Eltern des Q4-Wurfs setzen (läuft Z.4098 VOR der Suppression Z.4423, daher kein Dangling-Link):
|
||||
{
|
||||
"litterName": "Q4-Wurf",
|
||||
"date": "10.12.2014",
|
||||
"father": "Earl",
|
||||
"fatherDob": "22.04.2013",
|
||||
"mother": "Nelly",
|
||||
"motherDob": "12.07.2013",
|
||||
"source": "Ticket 4f890829: Wurfchronik-xlsx sheet1 row 131 (Earl [Kleine Chaoten] x Nelly [Kleine Chaoten]); rpro3 bestaetigt das Paar (Wade *14.01.2015). Ersetzt den faelschlich aufgeloesten Platzhalter-Vater 'Unbekannt'."
|
||||
}
|
||||
|
||||
Danach: `python tools/import/extract.py` (optional, unverändert) → `python tools/import/merge_and_resolve.py` → prüfen: (a) kein Gerbil mit Id be299aaf mehr im Payload, (b) Q4-Wurf 751f3dcc hat FatherId d676e74d / MotherId b3826f4b, (c) `suppressExternalRefs: 1 Gerbil(s) … entfernt` in der Konsole → dann Upload-Ingest gegen Prod (`POST /api/import/ingest-resolved/upload`). Der Upsert-Ingest löscht die nun fehlende importierte Zeile automatisch als „stale"; das Ticket behält seine lose gerbilId (FK-frei) und bleibt lesbar.
|
||||
|
||||
OPTIONALE Härtung (separat verifizieren, nicht für dieses Ticket nötig):
|
||||
- merge_and_resolve.py direkt vor der xlsx-Anreicherungs-Schleife (Z.3565): Platzhalter-Elternnamen leeren, damit sie nie auf ein Tier auflösen und die xlsx-Angabe greifen kann —
|
||||
for l in resolved_litters:
|
||||
for _k in ("_father_name", "_mother_name"):
|
||||
_n = "".join(c for c in (l.get(_k) or "").lower() if c.isalnum())
|
||||
if _n in ("unbekannt", "unknown", "unbenannt", "nn", "keineangabe", "ka", "na"):
|
||||
l[_k] = ""
|
||||
- merge_and_resolve.py Z.3568: Verengung auf das Wurf-Namens-Präfix statt `LitterLetter` (z. B. `(l.get("Name") or "").split("-")[0].upper()`), damit gleich-datierte Würfe (Q4/R4) ihre xlsx-Eltern bekommen — würde u. a. auch R4-Wurf dbba6874 (Arrow × Kia) automatisch füllen; größerer Blast-Radius, eigener Verifikationslauf.
|
||||
- Die 5 restlichen namenlosen page_0009-Q3-Dubletten (911516bb, 1230a554, 089d5d1b, 11bbdb9e, 8dcb9e1f) sind derselbe Fehler, wurden aber von der Züchterin nicht explizit benannt → separat anfragen, nicht mit durchlöschen.
|
||||
Restkosmetik: Die Wurf-Notiz „Eltern: [Unbekannt] + Nelly" bleibt als Quelltext-Zitat stehen (kein Override-Mechanismus für Wurf-Notizen).
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Du hattest recht: das Tier „Unbekannt" (geboren 21.06.2014) war eine Dublette — es ist eines der Jungtiere aus dem Q3-Wurf vom 21.06.2014, das in deiner Wurfchronik auf einer Seite nur mit Farbe („Silberagouti, bleibt, Ni.?") und ohne Namen steht, während dieselben Jungtiere auf einer anderen Seite mit Namen aufgeführt sind — darunter genau Kai-Uwe und Pünktchen (und Quebec). Der Eintrag „Unbekannt" ist jetzt gelöscht. Er war außerdem versehentlich als Vater des Q4-Wurfs vom 10.12.2014 eingetragen, weil dort in der Quelle nur „Unbekannt + Nelly" stand und das Programm diesen Platzhalter für einen echten Tiernamen gehalten hat. Laut deiner Wurfchronik sind die Eltern dieses Wurfs Earl und Nelly — das ist jetzt so eingetragen, Sweet und Vee rich haben damit ihre richtigen Eltern.
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json; tools/import/merge_and_resolve.py; Wurf Q4-Wurf 751f3dcc (10.12.2014, Sweet + Vee rich); Tier be299aaf 'Unbekannt'; Q3-Wurf 7d5c5218 (21.06.2014)
|
||||
|
||||
---
|
||||
|
||||
### Merle (2023-06-18): Herkunft leer + noch als Zuchttier im Bestand (Abgabe 22.12.2024 fehlt)
|
||||
|
||||
Tickets: `7037f5d8-e759-47f2-9f1c-778614f10f06` · Layer **import-data** · Cluster `conflict-decisions-resolutions-merge-and-resolve` · Aufwand M · Konfidenz medium · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Zwei Ursachen, beide in conflict-decisions.json / merge_and_resolve.py. (1) BESTAND: Merle (Prod-Id 9df4890f-4959-548f-aa96-c9ee8c759e03, dob 2023-06-18, extref stammbaum-merle-18062023) hat Status "Breeding", ReceiverContactId=null, GoHomeDate=null. Die Abgabe, die die Züchterin bereits in Ticket a8f11ac0 (2026-07-13) gemeldet hat ("abgegeben an Swen Pulinckx am 22.12.24, dort in Schneewittchen umbenannt"), wurde nie in conflict-decisions.json übernommen. Die Bestandsansicht (gerbil-manager-web/src/pages/GerbilsPage.tsx, Z.60 + Z.158) filtert genau auf isResident==true AND status=='Breeding' → Merle steht dort zu Recht drin, solange kein Abnehmer/Abgabedatum gesetzt ist. isResident=true ist regelkonform (eigener Wurf "Wurf von Smoke + Merle" 12.07.2024, ShowInChronicle=true) → nicht die Residenz, sondern der Status ist falsch. (2) HERKUNFT: Merle.OriginBreeder ist null, die Akte zeigt "Herkunft: —" (GerbilDetailPage.tsx Z.545-551). Regression durch den Chart-Import vom 2026-08-18: der Override-Block in tools/import/merge_and_resolve.py (Z.3198-3325) wendet pro Tier NUR EINE Decision an (first match: exakter externalRef → endswith → (name,dob) → (name,"")). Der addAnimals-Stub "decision-merle" ist mit dem neuen Chart-Datensatz "Stammbaum von Pukas Kids.xlsx" verschmolzen (daher auch die neue Id; die Ticket-gerbilId ce05fad7 liefert 404) → Merle matcht jetzt resolutions[150] (name+dob) und resolutions[136] (name-only, originBreeder "Clan of Black Forest") fällt still weg. Zusätzlich leakt der tote name-only-Eintrag über den (call-name,"")-Branch auf den Namensvetter "Merle of Samsimar" (43603fcc, dob 2012-09-01), der in Prod deshalb fälschlich originBreeder "Clan of Black Forest" trägt. (3) resolutions unterstützen kein goHomeDate → das Abgabedatum 22.12.2024 lässt sich derzeit gar nicht data-driven setzen.
|
||||
|
||||
**Fix:**
|
||||
|
||||
A) tools/import/conflict-decisions.json — resolutions[136] (der name-only "Merle"-Eintrag mit "externalRef": "decision-merle") KOMPLETT LÖSCHEN: er greift bei Merle nicht mehr und setzt stattdessen fälschlich die Herkunft von "Merle of Samsimar".
|
||||
|
||||
B) tools/import/conflict-decisions.json — resolutions[150] durch diesen konsolidierten Eintrag ERSETZEN (eine Decision pro Tier, gekeyt auf name+dob UND aktuellen externalRef):
|
||||
{
|
||||
"name": "Merle",
|
||||
"dob": "18.06.2023",
|
||||
"externalRef": "stammbaum-merle-18062023",
|
||||
"isResident": true,
|
||||
"originBreeder": "Zucht der Kleinen Chaoten",
|
||||
"receiver": "Swen Pulinckx",
|
||||
"goHomeDate": "22.12.2024",
|
||||
"notes": "Tochter von Akane von den Kleinen Chaoten × Bonaparte von den Schlossmäusen, geboren bei Clan of Black Forest (Akane war dort nur im Zuchttier-Austausch). Herkunft laut Züchterin: Zucht der Kleinen Chaoten. Am 01.04.2024 zurück zu den Kleinen Chaoten (Gewicht 62,1 g), Zuchttier mit Smoke (Würfe 12.07.2024 und 19.08.2024). Am 22.12.2024 zusammen mit ihrer Tochter Speedy an Swen Pulinckx abgegeben und dort in „Schneewittchen“ umbenannt. Weitere Daten siehe Stammbaum Pukas Kids.",
|
||||
"decision": "Ticket 7037f5d8: Herkunft = Zucht der Kleinen Chaoten (Züchterin-Angabe, ersetzt die frühere Angabe „Clan of Black Forest“). Merle bleibt Zuchttier (isResident=true, eigener Wurf mit Smoke in der Wurfchronik), ist aber seit 22.12.2024 abgegeben → Status GivenAway + Abnehmer + Abgabedatum, damit sie nicht mehr in der Bestandsansicht (isResident && status=Breeding) auftaucht. Konsolidiert die alte name-only-Decision, die nach dem Chart-Import auf „Merle of Samsimar“ geleakt hat.",
|
||||
"source": "Ticket 7037f5d8 (Züchterin 2026-07-31) + Ticket a8f11ac0 + Ticket 00715df9 + Ticket 36a3fcde"
|
||||
}
|
||||
Hinweis: "genotype" ist nicht nötig — der Chart liefert schon "Aa c[chm]c[chm] D- ee[-] GG P- Spsp" (deckt sich mit ihrer Angabe), und der genotype-Override greift ohnehin nur bei leerem Feld. Der Kontakt "Swen Pulinckx" existiert noch nicht und wird von _resolve_contact_id_by_name automatisch als Receiver-Kontakt erzeugt.
|
||||
|
||||
C) tools/import/merge_and_resolve.py — goHomeDate-Override ergänzen (bisher nicht unterstützt):
|
||||
1. in der has_ovr-Bedingung (~Z.3211-3215) ergänzen: ... or d.get("dateOfDeath") or d.get("goHomeDate") ...
|
||||
2. direkt NACH dem dateOfDeath-Block (~Z.3290, vor "if d.get(\"correctDob\")") einfügen:
|
||||
# goHomeDate: Abgabedatum aus einem Züchterin-Ticket (nur setzen, wenn leer);
|
||||
# zieht Status GivenAway nach, sofern das Tier nicht verstorben ist.
|
||||
if d.get("goHomeDate") and not g.get("GoHomeDate"):
|
||||
_gh = parse_date(d["goHomeDate"])
|
||||
if _gh:
|
||||
g["GoHomeDate"] = _gh
|
||||
if g.get("Status") != "Deceased":
|
||||
g["Status"] = "GivenAway"
|
||||
applied = True
|
||||
(GoHomeDate wird von IngestResolvedService.cs Z.130/146 mit-upserted, "GivenAway" verhindert außerdem, dass die 6-Jahres-Sterbe-Heuristik greift.)
|
||||
|
||||
D) Danach: python tools/import/merge_and_resolve.py, plausibilisieren (Merle 9df4890f: OriginBreeder "Zucht der Kleinen Chaoten", Status GivenAway, GoHomeDate 2024-12-22, ReceiverContactId gesetzt, IsResident true; "Merle of Samsimar" 43603fcc: OriginBreeder wieder null), python test_merge_resolve.py, dann Upload-Ingest gegen Prod.
|
||||
|
||||
Optionale Nacharbeit (kein Blocker): die drei Merle-Tickets (00715df9, a8f11ac0, 7037f5d8) zeigen auf die tote gerbilId ce05fad7 → Links in der Ticketliste laufen ins Leere; Umhängen auf 9df4890f wäre ein reiner Feedback-Update (keine Importdaten).
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Merle ist jetzt richtig eingetragen: Als Herkunft steht „Zucht der Kleinen Chaoten“ in ihrer Akte, und ihre Abgabe ist erfasst — abgegeben am 22.12.2024 an Swen Pulinckx (dort „Schneewittchen“). Dadurch verschwindet sie aus der Bestandsliste der Zuchttiere und taucht nur noch unter den abgegebenen Tieren auf; ihr Wurf mit Smoke bleibt natürlich in ihrer Akte und in der Wurfchronik. Warum es beim letzten Mal nicht gehalten hat: durch die neu eingelesenen Stammbäume (u. a. „Pukas Kids“) hat Merle einen Datensatz mit Geburtsdatum bekommen, und meine alte Notiz zu ihr passte danach nicht mehr auf sie — sie landete versehentlich bei der viel älteren „Merle of Samsimar“, die deshalb eine falsche Herkunft trug. Beides ist jetzt zusammengeführt und dauerhaft an Namen + Geburtsdatum festgemacht. Offen ist noch ihr zweiter Wurf mit Smoke vom 19.08.2024 (mit Tochter Speedy) — dazu warte ich noch auf deine Antwort im älteren Ticket.
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json; tools/import/merge_and_resolve.py; Prod-Daten: Merle 9df4890f-4959-548f-aa96-c9ee8c759e03; Prod-Daten: Merle of Samsimar 43603fcc-9384-5637-a0cb-48dfe36cea5a; gerbil-manager-web/src/pages/GerbilsPage.tsx (nur Analyse, keine Änderung)
|
||||
|
||||
---
|
||||
|
||||
### JackJack (b6d8b3ef) + A-Wurf 18.02.2010 (29f4bae9): Saphir wird als Platin errechnet — C-Locus-Zygotie fehlt im Farbkatalog
|
||||
|
||||
Tickets: `bde4ec70-d082-43ad-8343-ce82ed9eb39a, f89e95ad-de02-4d35-b2ec-7fa150146458` · Layer **genetics-engine** · Cluster `genetik-c-locus-zygotie-saphir-platin` · Aufwand M · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Katalog-Kollision in der Genetik-Engine: `gerbil-manager-web/src/genetics/catalog.ts` Z.67 (Platin), Z.81 (Saphir) und Z.86 (Platin-Hell) tragen ALLE die identischen Tokens {A:'a',C:'C',D:'D',E:'E',G:'G',P:'p'}. `locusToken()` reduziert den C-Locus auf das dominante Allel, d.h. CC, Cc[chm] und Cc[h] liefern alle den Token 'C' — also gewinnt beim "first match wins" immer die erste Zeile (Platin), Saphir und Platin-Hell sind unerreichbare Synonyme. Reproduziert lokal: `genotype.farbschlag_from_genotype_string('aa Cc[chm] DD E- G- pp Spsp')` == 'Platin' (UI hängt den Sp-Modifier an → "Platin Schecke"). Die Züchterin hat autoritativ geklärt (Ticket f89e95ad, Status Answered): "Saphir ist aa Cc[chm] D- E- G- pp und Platin ist aa CC D- E- G- pp. Es ist nicht dasselbe!" — der C-Locus muss also ZYGOTIE-genau matchen. Belege: Prod JackJack (b6d8b3ef) hat Genotyp 'aa Cc[chm] DD E- G- pp Spsp', gespeicherte ColorVariety ...000025 = Saphir (also schon korrekt) und Notiz "Saphir-Sp" — nur die errechnete Farbe lügt; GerbilOverride vorhanden (protectedFields name/spottingType/genotype/isDeaf, 2026-07-21), der Importer-Stand heißt noch "JackSack" mit Gencode 'aa CC DD EE GG pp Spsp rere'. Zweite, unabhängige Ursache für f89e95ad: die Mutter des A-Wurfs (Kuke, e4898ccc) hat in Prod weiterhin `genotype = null`, deshalb rechnet die WurfDetailPage überhaupt keine erwarteten Farbschläge; rpro3 belegt Kuke = Anthrazit-Schecke, 'aa CC DD Ee gg Pp Spsp'. Verschärfend (Ursache der Fehl-Gencodes im Import): merge_and_resolve.resolve_color_and_genotype schreibt bei einem Farb-LABEL "…Schecke" ohne Gencode den CanonicalGenotype der ColorVariety aus colorVarietySeed.backend.json — solange Saphirs Canonical 'aa CC …' lautet, bekommt ein "Saphir-Sp"-Tier den Platin-Gencode.
|
||||
|
||||
**Fix:**
|
||||
|
||||
CODE-FIX (Engine, kein Live-DB-Patch; nutzt die bereits vorhandene '/'-Paar-Token-Konvention von Siam/Zobel-Hell, daher KEINE Umsortierung des Katalogs → sortOrder und ColorVariety-GUIDs bleiben stabil):
|
||||
|
||||
(1) `gerbil-manager-web/src/genetics/catalog.ts` — `matches()` (direkt nach `locusToken`) ersetzen durch:
|
||||
```ts
|
||||
/**
|
||||
* GEN-6 (Tickets bde4ec70 / f89e95ad): ein Token darf ein EXAKTES Allelpaar
|
||||
* "x/y" (ungeordnet) verlangen statt des exprimierten dominanten Allels.
|
||||
* Nötig, weil sich Platin / Saphir / Platin-Hell NUR in der C-Zygotie
|
||||
* unterscheiden (Züchterin, autoritativ: "Saphir ist aa Cc[chm] D- E- G- pp
|
||||
* und Platin ist aa CC D- E- G- pp. Es ist nicht dasselbe!"):
|
||||
* aa CC D- E- G- pp -> Platin
|
||||
* aa C/c[chm] D- E- G- pp -> Saphir
|
||||
* aa C/c[h] D- E- G- pp -> Platin-Hell (GEN-3g: "-Hell" == c[h]-Allel)
|
||||
* representativeGenotype() versteht die "x/y"-Form bereits (GEN-3f).
|
||||
*/
|
||||
function pairMatches(g: Genotype, locus: LocusKey, token: string): boolean {
|
||||
const [a, b] = resolvedPair(g, locus)
|
||||
const [x, y] = token.split('/') as [string, string]
|
||||
return (a === x && b === y) || (a === y && b === x)
|
||||
}
|
||||
|
||||
function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
||||
return (Object.keys(entry.tokens) as LocusKey[]).every((locus) => {
|
||||
const token = entry.tokens[locus]!
|
||||
return token.includes('/') ? pairMatches(g, locus, token) : locusToken(g, locus) === token
|
||||
})
|
||||
}
|
||||
```
|
||||
und die drei Einträge (nur der C-Token ändert sich, Reihenfolge bleibt):
|
||||
- Z.67: `{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C/C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },`
|
||||
- Z.81: `{ name: 'Saphir', tokens: { A: 'a', C: 'C/cchm', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },`
|
||||
- Z.86: `{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C/ch', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },`
|
||||
(Platin-Hell ist die konsistente Anwendung der schon dokumentierten breeder-Regel GEN-3g und trifft aktuell 0 Tiere; wenn der Haupt-Loop maximal konservativ sein will, kann Schritt für Platin-Hell entfallen — dann bei Platin `C: 'C'` lassen und nur Saphir auf `'C/cchm'` setzen, Saphir muss dann aber VOR Platin stehen.)
|
||||
|
||||
(2) `cd gerbil-manager-web && npm run gen:catalog` — regeneriert colorVarietySeed.generated.json (Saphir → `aa Cc[chm] DD EE GG pp spsp rere`) und colorVarietySeed.backend.json (`aa Ccchm DD EE GG pp spsp rere`). Ohne das ist `src/genetics/__tests__/catalog-drift.test.ts` rot.
|
||||
|
||||
(3) `tools/import/genotype.py` — Mirror; `_matches` ersetzen:
|
||||
```python
|
||||
def _pair_matches(mapped, locus, token):
|
||||
a, b = _resolve_allele_pair(locus, mapped.get(locus, ["?", "?"]))
|
||||
x, y = token.split("/")
|
||||
return (a == x and b == y) or (a == y and b == x)
|
||||
|
||||
|
||||
def _matches(mapped, tokens):
|
||||
return all(
|
||||
_pair_matches(mapped, locus, tok) if "/" in tok else _locus_token(mapped, locus) == tok
|
||||
for locus, tok in tokens.items()
|
||||
)
|
||||
```
|
||||
und in `_BASE_COLORS`: `("Platin", {"A":"a","C":"C/C","D":"D","E":"E","G":"G","P":"p"})`, `("Saphir", {"A":"a","C":"C/cchm","D":"D","E":"E","G":"G","P":"p"})`, `("Platin-Hell", {"A":"a","C":"C/ch","D":"D","E":"E","G":"G","P":"p"})`.
|
||||
|
||||
(4) `GerbilManagerWebAPI/ApplicationContext.cs` → `SeedColorVarieties`: Zeile `("Saphir", "aa CC DD EE GG pp spsp rere", 24)` → `("Saphir", "aa Ccchm DD EE GG pp spsp rere", 24)` und `("Platin-Hell", "aa CC DD EE GG pp spsp rere", 27)` → `("Platin-Hell", "aa Cch DD EE GG pp spsp rere", 27)`; Platin bleibt. Dann `dotnet ef migrations add ReseedColorVarietiesGen6Saphir --project GerbilManagerWebAPI` (erzeugt UpdateData für Id `00000000-0000-0000-0000-000000000025` und `...000000000028`, Muster wie ReseedColorVarietiesAR5). Kein C#-Genetik-Mirror vorhanden (Rpro3ImportService verknüpft nur über Farbnamen) → nichts weiter im Backend.
|
||||
|
||||
(5) Regressionstests PFLICHT (Genetik = korrektheitskritisch):
|
||||
- `src/genetics/__tests__/genetics.test.ts`: `name('aa CC DD EE GG pp spsp rere') === 'Platin'` (Z.164/236 bleiben grün), `name('aa Cc[chm] DD EE GG pp spsp rere') === 'Saphir'`, `name('aa Cc[chm] DD E- G- pp Spsp') === 'Saphir Schecke'` (JackJack), `name('aa Cc[h] DD EE GG pp spsp rere') === 'Platin-Hell'`, plus breed(Blacky `aa Cc[chm] DD EE GG Pp spsp` × Kuke `aa CC DD Ee gg Pp Spsp`) enthält 'Saphir' UND 'Saphir Schecke'.
|
||||
- `tools/import/test_genotype.py`: `farbschlag_from_genotype_string('aa Cc[chm] DD EE GG pp') == 'Saphir'`, `('aa CC DD EE GG pp') == 'Platin'`, `('aa Cc[h] DD EE GG pp') == 'Platin-Hell'`.
|
||||
|
||||
DATEN-FIX (nur für Ticket f89e95ad, damit der Wurf überhaupt Erwartungen rechnet) — die BESTEHENDE Resolution in `tools/import/conflict-decisions.json` (`externalRef: "decision-kruke"`) um zwei Felder ergänzen; sie sagt selbst, dass Farbe/Genotyp auf diese Klärung wartet:
|
||||
```json
|
||||
{
|
||||
"name": "Kruke",
|
||||
"isResident": true,
|
||||
"decision": "'Kruke' war ein Wurfchronik-Tippfehler; korrekt 'Kuke' (rpro3). Bestandstier. Todesdatum 06.04.2011 (Ausbruch-Unfall, rpro3). Farbe/Genotyp jetzt gesetzt: rpro3 'Kuke' (M.Knoss) = Anthrazit-Schecke, Gencode aa CC DD Ee gg Pp Spsp; die Zuechterin hat Saphir/Platin geklaert (Ticket f89e95ad, 2026-08). KEIN Merge mit dem Ahnen-Record 'Kuke von Marion Knoss' — der Merge nullte die Mutter-Verknuepfung aller Blacky-x-Kuke-Wuerfe.",
|
||||
"source": "KI-Triage 2026-07-12 — Ticket 34220307-23bc-420c-80f8-d273615caffd + b7016e5f-9985-4f17-a3f6-2048289958aa + Tickets e0a0c304/a50116bf; Gencode-Nachtrag KI-Triage 2026-08-19 (Ticket f89e95ad, rpro3 _rpro3.db)",
|
||||
"externalRef": "decision-kruke",
|
||||
"dateOfDeath": "06.04.2011",
|
||||
"renameTo": "Kuke",
|
||||
"genotype": "aa CC DD Ee gg Pp Spsp",
|
||||
"farbschlag": "Anthrazit"
|
||||
}
|
||||
```
|
||||
Hinweise: `merge_and_resolve.py` Z.3302/3305 setzen `genotype`/`farbschlag` nur, wenn das Feld leer ist → re-ingest-stabil (Kuke hat beides null). `farbschlag` matcht EXAKT einen Katalognamen via `variety_map`, deshalb "Anthrazit" ohne "-Schecke" — die Scheckung steckt im Sp-Locus des Gencodes. Danach `python tools/import/extract.py` + `extract_contracts.py` + `merge_and_resolve.py`, dann Upload-Ingest gegen Prod (`POST /api/import/ingest-resolved/upload`). Der Engine-Fix selbst braucht KEINEN Re-Ingest (Anzeige wird client-seitig gerechnet), aber ein Re-Ingest nach (2) korrigiert zusätzlich die 5 heute als Platin geführten C-het-Tiere in der gespeicherten ColorVariety.
|
||||
|
||||
Verifikation vor "fertig": `npx tsc --noEmit`, `npx vitest run`, `npx eslint src/genetics`, `python tools/import/test_genotype.py`, `dotnet build && dotnet test`; Stichprobe: JackJack zeigt "Saphir Schecke", A-Wurf listet Saphir + Saphir Schecke.
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Saphir und Platin sind jetzt zwei verschiedene Farben — genau wie du gesagt hast. Bisher hat das Programm beide gleich behandelt und deshalb bei JackJack "Platin Schecke" errechnet, obwohl er mit Cc[chm] eine Saphir-Schecke ist. Ab jetzt gilt: mit zwei vollen C ist es Platin, mit einem c[chm] daneben ist es Saphir (und mit einem c[h] daneben Platin-Hell). JackJack wird deshalb wieder als Saphir Schecke angezeigt, und bei fünf weiteren Tieren (Ina, Tommy, Stich, Sansa, Fenris) steht nun ebenfalls Saphir statt Platin. Beim A-Wurf vom 18.02.2010 fehlte außerdem der Gencode von Kuke — ich habe ihn aus deinen RennerPro-Unterlagen ergänzt (Anthrazit-Schecke, aa CC DD Ee gg Pp Spsp). Damit tauchen bei den erwarteten Farbschlägen des Wurfs jetzt auch Saphir und Saphir Schecke auf.
|
||||
|
||||
**Betroffen:** gerbil-manager-web/src/genetics/catalog.ts; gerbil-manager-web/src/genetics/colorVarietySeed.generated.json; gerbil-manager-web/src/genetics/colorVarietySeed.backend.json; gerbil-manager-web/src/genetics/__tests__/genetics.test.ts; gerbil-manager-web/src/genetics/__tests__/catalog-drift.test.ts; tools/import/genotype.py; tools/import/test_genotype.py; tools/import/conflict-decisions.json; GerbilManagerWebAPI/ApplicationContext.cs; GerbilManagerWebAPI/Migrations (neu: ReseedColorVarietiesGen6Saphir)
|
||||
|
||||
---
|
||||
|
||||
### Kuke (prod e4898ccc): fehlender Zuchtname + alter Name „Kruke" in 5 Wurf-Notizen
|
||||
|
||||
Tickets: `24006ccd-c07c-4617-a016-cfa7587ac2aa, ea803c3d-33f5-4d3b-b05a-fb2bca8bb893` · Layer **import-logic** · Cluster `conflict-decisions+merge_and_resolve-rename` · Aufwand M · Konfidenz high · Aktion handover-to-implementation
|
||||
|
||||
**Ursache:** Zwei getrennte Ursachen, beide im Importer.
|
||||
|
||||
(1) Ticket ea803c3d („überall Kuke"): `renameTo` GREIFT — prod-Name ist „Kuke". Der alte Name steht nur noch in den aus der Wurfchronik übernommenen Wurf-Notizen: B-Wurf 2010 (f0b1fd6f) „Blacky + Kruke v. 17.04.2010; WS=2", D-Wurf 2010 (1eb3e1fb), E-Wurf 2010 (53b55c8c), F-Wurf 2010 (10ddf880), I-Wurf (f4b2b9be) „Eltern: Blacky + Kruke". Grund: `renameTo` (merge_and_resolve.py ~Z.3272-3282) schreibt nur `g["Name"]`, es gibt keine Propagation in Freitext-Notizen. Prod-weiter Scan (2451 Gerbils, 966 Litters, 886 Contacts) + resolved_import.json: kein weiteres sichtbares „Kruke"; intern bleiben nur ExternalRef „decision-kruke" und NameSearch „kruke" (NameSearch wird von keinem Endpoint abgefragt → irrelevant).
|
||||
|
||||
(2) Ticket 24006ccd („Zuchtname fehlt"): Das Tier existiert ZWEIMAL. (a) resident e4898ccc = addAnimals-Stub „Kruke" → renameTo „Kuke", ohne OriginContact, Genotype/ColorVariety null; (b) Chart-Ahnenrecord 6d9f9d66 „Kuke von Marion Knoss" (ExternalRef stammbaum-kukemarionknoss-18092009, gleiches DOB 18.09.2009, gleicher Todestag 06.04.2011, OriginContact 84628989 Marion Knoss, Genotype „aa CC DD Ee Uwuw[d] Pp Spsp" = aa CC DD Ee Gg Pp Spsp, ColorVariety …0007 Schwarz). Der Zuchtname steht also nur am Zwilling, und die Frontend-Regel (gerbil-manager-web/src/format/gerbilName.ts: Suffix nur für eigengezüchtete Tiere) hängt fremden Tieren nichts an → der Zuchtname muss Teil des Namens sein. Ihr eigener Chart-Name lautet exakt „Kuke von Marion Knoss" (tools/import/output/animals.json; Charts Danako/Hana/Jin/Unit/Uriana/Wildfire), rpro3-Herkunft „M.Knoss GG", Eltern Marc x Sara.
|
||||
|
||||
KEIN Rückfall durch die 12 neuen Charts vom 2026-08-18: der Zwilling existierte schon (die neuen Charts haben nur weitere sourceFiles ergänzt). Der Merge war 2026-07-12 bewusst ausgelassen worden („der Merge nullte die Mutter-Verknüpfung aller Blacky-x-Kuke-Würfe", conflict-decisions.json Z.986) — mit keep=decision-kruke ist genau dieser Fehlermodus heute abgedeckt.
|
||||
|
||||
**Fix:**
|
||||
|
||||
A) DATEN — tools/import/conflict-decisions.json, `resolutions`[114] (der Eintrag mit "name": "Kruke", Z.983-991) komplett ersetzen durch:
|
||||
|
||||
{
|
||||
"name": "Kruke",
|
||||
"isResident": true,
|
||||
"decision": "'Kruke' war ein Wurfchronik-Tippfehler; korrekt 'Kuke von Marion Knoss' (so heisst sie in ihren eigenen Stammbaeumen Danako/Hana/Jin/Unit/Uriana/Wildfire; rpro3-Herkunft 'M.Knoss GG', Eltern Marc x Sara). Bestandstier, Todesdatum 06.04.2011 (Ausbruch-Unfall, rpro3). Der Chart-Ahnenrecord wird jetzt HIER hineingemergt (keep = decision-kruke): der Keeper behaelt bis zum Parent-Resolver den Quellnamen 'Kruke', renameTo setzt danach _pre_rename_name (Namensindex) und die Chart-Wuerfe des gedroppten Zwillings werden ueber _premerged_ids umgebogen -> die Mutter-Verknuepfung der Blacky-x-Kuke-Wuerfe bleibt erhalten (das war 2026-07-12 der Grund, den Merge auszulassen).",
|
||||
"source": "KI-Triage 2026-08-19 - Tickets 24006ccd (Zuchtname) + ea803c3d (ueberall 'Kuke'); zuvor e0a0c304 / a50116bf / f89e95ad",
|
||||
"externalRef": "decision-kruke",
|
||||
"dateOfDeath": "06.04.2011",
|
||||
"originBreeder": "Marion Knoss",
|
||||
"renameTo": "Kuke von Marion Knoss",
|
||||
"mergeExternalRefs": [["decision-kruke", "stammbaum-kukemarionknoss-18092009"]]
|
||||
},
|
||||
|
||||
(mergeExternalRefs wird aus `resolutions` gelesen — merge_and_resolve.py Z.2760; Match per ExternalRef-Suffix (endswith), keep = pair[0].)
|
||||
|
||||
B) CODE — tools/import/merge_and_resolve.py: finaler Notiz-Sweep direkt VOR `# 6. Save final output JSON payload` (aktuell ~Z.4462, hinter der Herkunft-Normalisierung) einfügen:
|
||||
|
||||
# ── Umbenennungs-Sweep in Freitext-Notizen (Tickets ea803c3d/24006ccd, Kruke→Kuke) ──
|
||||
# `renameTo` korrigiert nur das Namensfeld; die aus der Wurfchronik übernommenen
|
||||
# Wurf-Notizen ("Blacky + Kruke v. 21.09.2010; WS=4") tragen den alten Namen weiter,
|
||||
# und die Züchterin sieht ihn dort. Finaler Anzeige-Sweep NACH aller Logik (Notes
|
||||
# werden vorher fürs Dedup-Scoring gelesen): Quell-Name → Rufname des neuen Namens.
|
||||
_rename_pairs = []
|
||||
for g in resolved_gerbils:
|
||||
_old = (g.get("_pre_rename_name") or "").strip()
|
||||
_new = (g.get("Name") or "").strip()
|
||||
if _old and _new and _old != _new:
|
||||
_new_call = get_call_name(_new) or _new
|
||||
if normalize_name(_old) != normalize_name(_new_call):
|
||||
_rename_pairs.append((_old, _new_call))
|
||||
_notes_fixed = 0
|
||||
for _old, _new_call in _rename_pairs:
|
||||
_rx = re.compile(r"\b" + re.escape(_old) + r"\b")
|
||||
for _rec in list(resolved_litters) + list(resolved_gerbils):
|
||||
_val = _rec.get("Notes")
|
||||
if _val and _rx.search(_val):
|
||||
_rec["Notes"] = _rx.sub(_new_call, _val)
|
||||
_notes_fixed += 1
|
||||
if _notes_fixed:
|
||||
print(f"renameTo-Notiz-Sweep: {_notes_fixed} Notiz(en) auf den korrigierten Namen gesetzt")
|
||||
|
||||
Erwartetes Ergebnis: „Blacky + Kuke v. 21.09.2010; WS=4" usw. (Rufname, nicht der volle Zuchtname — Notizen bleiben lesbar).
|
||||
|
||||
C) TEST — tools/import/test_merge_resolve.py: Regressionstest, dass (i) ein Gerbil mit `_pre_rename_name` den alten Namen nicht mehr in Litter.Notes/Gerbil.Notes stehen lässt, (ii) wortgenau ersetzt wird (kein Treffer in „Krukelinde"/Teilwörtern), (iii) der Rufname (nicht das Zucht-Suffix) eingesetzt wird.
|
||||
|
||||
D) NACH dem Ingest verifizieren (prod, read-only): GET /api/gerbils/e4898ccc-1896-5258-b1a9-4c89d30cf428 → name „Kuke von Marion Knoss", originContactId 84628989-4725-54c5-a113-4e5ad03dc1e2; GET /api/gerbils?filter=name=*Kuke → nur noch EIN Treffer; die 5 Würfe (f0b1fd6f, 1eb3e1fb, 53b55c8c, 10ddf880, f4b2b9be) haben motherId=e4898ccc UND Notizen ohne „Kruke".
|
||||
|
||||
E) NEBENWIRKUNG (bitte an Ticket f89e95ad weiterreichen, NICHT hier entscheiden): Der Merge füllt Genotype („aa CC DD Ee Uwuw[d] Pp Spsp"; Uw/uw[d] ist laut genotype.py/genotype.ts ein Alias von G/g → aa CC DD Ee Gg Pp Spsp) und ColorVarietyId (…0007 „Schwarz") aus dem Chart. Positiv: der Datenblocker von f89e95ad (Mutter-Genotyp null → keine erwarteten Farbschläge im A-Wurf) fällt weg. Aber rpro3 sagt „Anthrazit-Schecke / aa CC DD Ee gg Pp Spsp" (G-Locus gg statt Gg), und „Schwarz" passt nicht zu Ee/Spsp. ACHTUNG Ordering: `genotype`/`farbschlag` aus conflict-decisions greifen nur bei LEEREM Feld (merge_and_resolve.py ~Z.3302-3308) — nach dem Merge also No-Op. Wenn f89e95ad einen Wert festnageln soll, muss dieser Override gewinnen dürfen (bzw. der Merge-Gap-Fill die per Decision gesetzten Felder überspringen).
|
||||
|
||||
**Changelog-Entwurf für die Züchterin:** Kuke heißt jetzt überall richtig — und mit vollem Namen: „Kuke von Marion Knoss", genau wie in deinen Stammbäumen. Zwei Dinge waren schuld: (1) Kuke war noch zweimal angelegt — einmal als dein Zuchttier (ohne Zuchtnamen) und einmal als Ahnin mit dem vollen Namen aus den Stammbäumen. Die beiden sind jetzt zu einem Tier zusammengeführt; dabei bleiben ihre Würfe mit Blacky und ihre Verknüpfungen erhalten, und ihre Herkunft (Marion Knoss) hängt jetzt direkt am Tier. (2) In fünf Wurf-Notizen stand noch der alte Schreibfehler „Kruke" (B-, D-, E-, F- und I-Wurf 2010) — dort steht jetzt „Kuke". Damit das nie wieder passiert, übernimmt der Import eine Namenskorrektur ab sofort automatisch auch in alle Notizen. Ihre Farbe und ihr Erbbild klären wir noch im separaten Punkt zum A-Wurf (Anthrazit-Schecke bzw. Saphir/Platin).
|
||||
|
||||
**Betroffen:** tools/import/conflict-decisions.json (resolutions[114] 'Kruke'); tools/import/merge_and_resolve.py (Notiz-Sweep vor dem Payload-Dump; mergeExternalRefs); tools/import/test_merge_resolve.py (Regressionstest); Prod-Daten: Gerbil e4898ccc + Dublette 6d9f9d66, Würfe f0b1fd6f/1eb3e1fb/53b55c8c/10ddf880/f4b2b9be; Folgeticket f89e95ad (Genotyp/Farbschlag der Mutter)
|
||||
|
||||
---
|
||||
|
||||
@@ -19,6 +19,10 @@ RUN printf 'server {\n\
|
||||
listen 80;\n\
|
||||
root /usr/share/nginx/html;\n\
|
||||
index index.html;\n\
|
||||
\n\
|
||||
# Upload-Groesse: erlaubt grosse /api-Uploads (RennmausPro-Backups, Foto-Anhaenge,\n\
|
||||
# resolved_import.json fuer den Prod-Re-Ingest via /api/import/ingest-resolved/upload).\n\
|
||||
client_max_body_size 50m;\n\
|
||||
\n\
|
||||
# API-Aufrufe: /api/* -> API-Container /* (Praefix wird entfernt)\n\
|
||||
location /api/ {\n\
|
||||
|
||||
@@ -414,6 +414,44 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 200, result)
|
||||
}
|
||||
|
||||
// GEPRÜFTE TIERE: „vollständig korrekt"-Markierung / Schutz (/verified-gerbils).
|
||||
if (path === '/verified-gerbils' && method === 'GET') {
|
||||
return json(route, 200, [...db.verified].reverse())
|
||||
}
|
||||
const verMatch = path.match(/^\/verified-gerbils\/([^/]+)$/)
|
||||
if (verMatch) {
|
||||
const gid = decodeURIComponent(verMatch[1])
|
||||
const vIdx = db.verified.findIndex((v) => v.gerbilId === gid)
|
||||
if (method === 'GET') {
|
||||
return vIdx >= 0 ? json(route, 200, db.verified[vIdx]) : json(route, 404, { title: 'Not Found' })
|
||||
}
|
||||
if (method === 'POST') {
|
||||
const g = db.gerbils.find((x) => x.id === gid)
|
||||
if (!g) return json(route, 404, { title: 'Not Found' })
|
||||
const body = (request.postDataJSON() ?? {}) as { note?: string | null }
|
||||
const row = {
|
||||
gerbilId: gid,
|
||||
entityName: g.name ?? null,
|
||||
isVerified: true,
|
||||
status: 'unchanged',
|
||||
verifiedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
note: body.note ?? null,
|
||||
protectedFields: ['name', 'gender', 'dateOfBirth', 'genotype'],
|
||||
importDiff: [],
|
||||
}
|
||||
if (vIdx >= 0) db.verified[vIdx] = row
|
||||
else db.verified.push(row)
|
||||
return json(route, 200, row)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
if (vIdx < 0) return json(route, 404, { title: 'Not Found' })
|
||||
db.verified.splice(vIdx, 1)
|
||||
return json(route, 204)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
|
||||
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
|
||||
if (path === '/feedback') {
|
||||
if (method === 'POST') {
|
||||
@@ -431,12 +469,71 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
fixNote: null,
|
||||
agentContext: null,
|
||||
thread: [],
|
||||
reopenedAt: null,
|
||||
deletedAt: null,
|
||||
category: null,
|
||||
helpful: null,
|
||||
attachments: [],
|
||||
}
|
||||
db.feedback.push(created)
|
||||
return json(route, 201, created)
|
||||
}
|
||||
if (method === 'GET') {
|
||||
return json(route, 200, [...db.feedback].reverse())
|
||||
// attachments immer als Array liefern (Mock-Daten haben das Feld evtl. nicht).
|
||||
const rows = [...db.feedback].reverse().map((f) => ({ ...f, attachments: f.attachments ?? [] }))
|
||||
return json(route, 200, rows)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
// WEB-PUSH: im Mock deaktiviert (keine VAPID-Schlüssel) → PushToggle blendet sich aus.
|
||||
if (path === '/push/vapid-public-key' && method === 'GET') {
|
||||
return json(route, 200, { enabled: false, publicKey: null })
|
||||
}
|
||||
if ((path === '/push/subscribe' || path === '/push/unsubscribe') && method === 'POST') {
|
||||
return json(route, 200, {})
|
||||
}
|
||||
// FEEDBACK-ANHÄNGE: hochladen (POST /feedback/{id}/attachments).
|
||||
const attUpload = path.match(/^\/feedback\/([^/]+)\/attachments$/)
|
||||
if (attUpload && method === 'POST') {
|
||||
const fid = decodeURIComponent(attUpload[1])
|
||||
const row = db.feedback.find((f) => f.id === fid)
|
||||
if (!row) return json(route, 404, { title: 'Not Found' })
|
||||
const body = request.postDataJSON() as { fileName?: string; contentType?: string; dataBase64?: string }
|
||||
const meta = {
|
||||
id: newId('att'),
|
||||
fileName: body.fileName ?? 'datei',
|
||||
contentType: body.contentType ?? 'application/octet-stream',
|
||||
size: (body.dataBase64 ?? '').length,
|
||||
}
|
||||
const list = (row.attachments as unknown[] | undefined) ?? []
|
||||
list.push(meta)
|
||||
row.attachments = list
|
||||
return json(route, 201, meta)
|
||||
}
|
||||
// FEEDBACK-ANHÄNGE: löschen (DELETE /feedback/attachments/{attId}).
|
||||
const attDelete = path.match(/^\/feedback\/attachments\/([^/]+)$/)
|
||||
if (attDelete && method === 'DELETE') {
|
||||
const attId = decodeURIComponent(attDelete[1])
|
||||
for (const f of db.feedback) {
|
||||
const list = (f.attachments as { id: string }[] | undefined) ?? []
|
||||
const i = list.findIndex((a) => a.id === attId)
|
||||
if (i >= 0) {
|
||||
list.splice(i, 1)
|
||||
f.attachments = list
|
||||
return json(route, 204)
|
||||
}
|
||||
}
|
||||
return json(route, 404, { title: 'Not Found' })
|
||||
}
|
||||
// FEEDBACK-TICKETS: Wiederherstellen aus dem Papierkorb (Soft-Delete aufheben).
|
||||
const restoreMatch = path.match(/^\/feedback\/([^/]+)\/restore$/)
|
||||
if (restoreMatch) {
|
||||
const fid = decodeURIComponent(restoreMatch[1])
|
||||
const row = db.feedback.find((f) => f.id === fid)
|
||||
if (!row) return json(route, 404, { title: 'Not Found' })
|
||||
if (method === 'POST') {
|
||||
row.deletedAt = null
|
||||
return json(route, 200, row)
|
||||
}
|
||||
return json(route, 405)
|
||||
}
|
||||
@@ -454,8 +551,13 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
answer?: string
|
||||
fixNote?: string
|
||||
agentContext?: string
|
||||
category?: string
|
||||
helpful?: boolean
|
||||
}
|
||||
const row = db.feedback[idx]
|
||||
// Zustand vor den Mutationen (für die Wiederöffnen-Erkennung).
|
||||
const wasResolved = row.status === 'Resolved'
|
||||
const hadOpenRueckfrage = typeof row.question === 'string' && !!(row.question as string).trim()
|
||||
if (typeof body.message === 'string') {
|
||||
if (!body.message.trim()) return json(route, 400, 'Message darf nicht leer sein.')
|
||||
row.message = body.message.trim()
|
||||
@@ -504,6 +606,10 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
? s
|
||||
: 'Open'
|
||||
row.resolvedAt = resolved ? (row.resolvedAt ?? new Date().toISOString()) : null
|
||||
// „Wieder geöffnet am" nur, wenn ein echt gelöstes Ticket OHNE offene Rückfrage
|
||||
// wieder geöffnet wird (s. Backend).
|
||||
if (!resolved && wasResolved && !hadOpenRueckfrage)
|
||||
row.reopenedAt = new Date().toISOString()
|
||||
}
|
||||
// Changelog (fixNote) — laienverständlich, sichtbar.
|
||||
if (typeof body.fixNote === 'string') {
|
||||
@@ -515,10 +621,17 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
const c = body.agentContext.trim()
|
||||
row.agentContext = c.length === 0 ? null : c
|
||||
}
|
||||
if (typeof body.category === 'string') {
|
||||
const cat = body.category.trim()
|
||||
row.category = cat.length === 0 ? null : cat
|
||||
}
|
||||
if (typeof body.helpful === 'boolean') row.helpful = body.helpful
|
||||
return json(route, 200, row)
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
db.feedback.splice(idx, 1)
|
||||
// Soft-Delete: in den Papierkorb verschieben (nicht entfernen).
|
||||
const target = db.feedback[idx]
|
||||
if (!target.deletedAt) target.deletedAt = new Date().toISOString()
|
||||
return json(route, 204)
|
||||
}
|
||||
return json(route, 405)
|
||||
@@ -823,6 +936,79 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
||||
return json(route, 200, results)
|
||||
}
|
||||
|
||||
// QOL-ELTERN: Eltern direkt an der Tier-Akte setzen (PUT /gerbils/:id/parents).
|
||||
// Spiegelt das Backend: Eltern werden am GEBURTSWURF gespeichert; fehlt der Wurf, wird
|
||||
// ein bestehender mit gleichen Eltern + gleichem Datum verknüpft, sonst einer angelegt
|
||||
// (showInChronicle=false → nicht in der Wurfchronik).
|
||||
const parentsMatch = path.match(/^\/gerbils\/([^/]+)\/parents$/)
|
||||
if (parentsMatch) {
|
||||
if (method !== 'PUT') return json(route, 405)
|
||||
const gid = decodeURIComponent(parentsMatch[1])
|
||||
const g = db.gerbils.find((x) => x.id === gid)
|
||||
if (!g) return json(route, 404, { title: 'Not Found' })
|
||||
const body = (request.postDataJSON() ?? {}) as {
|
||||
fatherId?: string | null
|
||||
motherId?: string | null
|
||||
}
|
||||
const fatherId = body.fatherId ?? null
|
||||
const motherId = body.motherId ?? null
|
||||
const father = fatherId ? db.gerbils.find((x) => x.id === fatherId) : null
|
||||
const mother = motherId ? db.gerbils.find((x) => x.id === motherId) : null
|
||||
if (father?.gender === 'female' || mother?.gender === 'male') {
|
||||
return json(route, 400, {
|
||||
code: 'InvalidParentGender',
|
||||
fatherGender: father?.gender ?? 'unknown',
|
||||
motherGender: mother?.gender ?? 'unknown',
|
||||
})
|
||||
}
|
||||
let litter = g.litterId ? (db.litters.find((l) => l.id === g.litterId) ?? null) : null
|
||||
let litterCreated = false
|
||||
let litterAttached = false
|
||||
if (!litter && !fatherId && !motherId) {
|
||||
return json(route, 200, {
|
||||
litterId: null, litterName: null, litterCreated: false, litterAttached: false,
|
||||
siblingCount: 0, fatherId: null, motherId: null,
|
||||
})
|
||||
}
|
||||
if (!litter) {
|
||||
if (g.dateOfBirth) {
|
||||
litter =
|
||||
db.litters.find(
|
||||
(l) => l.fatherId === fatherId && l.motherId === motherId && l.date === g.dateOfBirth,
|
||||
) ?? null
|
||||
litterAttached = litter !== null
|
||||
}
|
||||
if (!litter) {
|
||||
litter = {
|
||||
id: newId('litter'),
|
||||
name: `Wurf von ${father?.name ?? '—'} + ${mother?.name ?? '—'}`,
|
||||
date: g.dateOfBirth ?? '',
|
||||
totalBorn: null,
|
||||
expectedGoHomeDate: null,
|
||||
notes: null,
|
||||
fatherId,
|
||||
motherId,
|
||||
showInChronicle: false,
|
||||
}
|
||||
db.litters.push(litter)
|
||||
litterCreated = true
|
||||
}
|
||||
g.litterId = litter.id
|
||||
}
|
||||
litter.fatherId = fatherId
|
||||
litter.motherId = motherId
|
||||
const siblingCount = db.gerbils.filter((x) => x.litterId === litter!.id && x.id !== gid).length
|
||||
return json(route, 200, {
|
||||
litterId: litter.id,
|
||||
litterName: litter.name,
|
||||
litterCreated,
|
||||
litterAttached,
|
||||
siblingCount,
|
||||
fatherId,
|
||||
motherId,
|
||||
})
|
||||
}
|
||||
|
||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||
const col = m ? collections[m[1]] : undefined
|
||||
|
||||
@@ -80,6 +80,8 @@ export interface MockDb {
|
||||
namesConfigured: boolean
|
||||
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
|
||||
feedback: Record<string, unknown>[]
|
||||
// GEPRÜFTE TIERE: „vollständig korrekt"-Markierungen / Schutz (/verified-gerbils)
|
||||
verified: Record<string, unknown>[]
|
||||
// RPRO3: RennmausPro-III-Import — feste Auswertung + Import-Ergebnis für die UI-Specs.
|
||||
rpro3: {
|
||||
analyze: Record<string, unknown>
|
||||
@@ -166,6 +168,10 @@ export function seedDb(): MockDb {
|
||||
gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'),
|
||||
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
|
||||
gerbil('karlsson', 'Karlsson', 'male', '2022-02-02', 'w-karlsson', 'cv-blau'),
|
||||
// FARBSCHLAG-MISMATCH: eingetragen „Schwarz Schecke", Gencode ergibt Agouti Schecke
|
||||
// → der Hinweis „Weicht vom eingetragenen Farbschlag ab" MUSS hier erscheinen.
|
||||
gerbil('mismatch-maus', 'Mismatch Maus', 'female', '2023-02-02', null, 'cv-schwarz-schecke',
|
||||
'AA CC DD EE GG PP Spsp rere'),
|
||||
gerbil('smilla', 'Smilla', 'female', '2022-09-30', null, 'cv-himalaya'),
|
||||
gerbil('anton', 'Anton', 'male', '2019-07-07', 'w-anton', 'cv-agouti'),
|
||||
gerbil('greta', 'Greta', 'female', '2019-05-23', null, 'cv-schwarz'),
|
||||
@@ -177,7 +183,15 @@ export function seedDb(): MockDb {
|
||||
// Statistik: Verstorbene + Abgegebene für Verluste/Bestandskurve
|
||||
{ ...gerbil('willi', 'Willi', 'male', '2019-09-09', null, 'cv-schwarz'), status: 'Deceased', dateOfDeath: '2022-04-04' },
|
||||
{ ...gerbil('pauli', 'Pauli', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-huber' },
|
||||
{ ...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'), status: 'Deceased', dateOfDeath: '2023-08-15' },
|
||||
// Ticket 24522f5f (JackJack): abgegeben UND später beim Abnehmer verstorben —
|
||||
// Status Deceased, aber Abnehmer + Abgabedatum gesetzt. Die Akte muss beides zeigen.
|
||||
{
|
||||
...gerbil('rosa', 'Rosa', 'female', '2020-02-02', null, 'cv-gold'),
|
||||
status: 'Deceased',
|
||||
dateOfDeath: '2023-08-15',
|
||||
receiverContactId: 'con-huber',
|
||||
goHomeDate: '2022-01-15',
|
||||
},
|
||||
{ ...gerbil('pauli-breeder', 'Pauli Breeder', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-meier' },
|
||||
{ ...gerbil('pauli-neither', 'Pauli Neither', 'male', '2023-05-01', null, 'cv-himalaya'), status: 'GivenAway', goHomeDate: '2023-07-15', receiverContactId: 'con-neither' },
|
||||
// ABGABE: zwei Tiere zur Abgabe im Quarantänebecken (enc-leer ist leer, kein Konflikt
|
||||
@@ -222,10 +236,10 @@ export function seedDb(): MockDb {
|
||||
]
|
||||
|
||||
const litters: Litter[] = [
|
||||
{ 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-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u', showInChronicle: true },
|
||||
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus', showInChronicle: true },
|
||||
{
|
||||
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-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1, stillborn: 1, showInChronicle: true,
|
||||
// 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'],
|
||||
@@ -239,13 +253,16 @@ export function seedDb(): MockDb {
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ 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' },
|
||||
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja', showInChronicle: true },
|
||||
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla', showInChronicle: true },
|
||||
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta', showInChronicle: true },
|
||||
// Anton ist auch Karlssons Vater -> gemeinsamer Vorfahre (Inzucht-Demo)
|
||||
{ id: 'w-karlsson', name: 'Wurf C', date: '2022-02-02', totalBorn: 5, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'frieda' },
|
||||
{ id: 'w-anton', name: 'Wurf A', date: '2019-07-07', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'emil', motherId: 'hilde' },
|
||||
{ id: 'w-emil', name: 'Wurf E', date: '2017-03-03', totalBorn: 2, expectedGoHomeDate: null, notes: null, fatherId: 'max', motherId: null },
|
||||
{ id: 'w-karlsson', name: 'Wurf C', date: '2022-02-02', totalBorn: 5, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'frieda', showInChronicle: true },
|
||||
{ id: 'w-anton', name: 'Wurf A', date: '2019-07-07', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'emil', motherId: 'hilde', showInChronicle: true },
|
||||
{ id: 'w-emil', name: 'Wurf E', date: '2017-03-03', totalBorn: 2, expectedGoHomeDate: null, notes: null, fatherId: 'max', motherId: null, showInChronicle: true },
|
||||
// WURFCHRONIK-FILTER: dieser Wurf ist NICHT in der Wurfchronik (showInChronicle: false)
|
||||
// -> taucht in der Wurf-Liste nicht auf, aber auf Fridolins Tier-Detailseite (parentLitters) weiterhin.
|
||||
{ id: 'w-versteckt', name: 'Wurf V', date: '2024-02-02', totalBorn: 2, expectedGoHomeDate: null, notes: null, fatherId: 'fridolin', motherId: 'luna', showInChronicle: false },
|
||||
]
|
||||
|
||||
const enclosures: Enclosure[] = [
|
||||
@@ -426,6 +443,8 @@ export function seedDb(): MockDb {
|
||||
],
|
||||
saleAdConfigured: true,
|
||||
namesConfigured: true,
|
||||
// GEPRÜFTE TIERE: startet leer; die Verify-Specs markieren über POST.
|
||||
verified: [],
|
||||
// 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: [
|
||||
@@ -442,6 +461,7 @@ export function seedDb(): MockDb {
|
||||
userAgent: null,
|
||||
createdAt: '2026-06-10T09:00:00Z',
|
||||
status: 'Resolved',
|
||||
category: 'Stammbaum',
|
||||
resolvedAt: '2026-06-12T14:00:00Z',
|
||||
question: null,
|
||||
answer: null,
|
||||
@@ -480,6 +500,29 @@ export function seedDb(): MockDb {
|
||||
{ role: 'breeder', text: 'Im Juni.', at: '2026-06-13T18:00:00Z' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Beantwortetes Ticket (Answered): die KI hat noch NICHT reagiert. Der Erst-Text ist
|
||||
// jetzt gesperrt (kein „Bearbeiten"), nur die neueste Antwort ist noch änderbar.
|
||||
id: 'feedback-answered',
|
||||
message: 'Bei Karlsson stimmt der Farbschlag nicht.',
|
||||
context: 'gerbil-detail',
|
||||
gerbilId: 'karlsson',
|
||||
litterId: null,
|
||||
contactId: null,
|
||||
entityName: 'Karlsson',
|
||||
url: 'http://localhost:5173/rennmaeuse/karlsson',
|
||||
clientTimestamp: '2026-06-16T09:00:00Z',
|
||||
userAgent: null,
|
||||
createdAt: '2026-06-16T09:00:00Z',
|
||||
status: 'Answered',
|
||||
resolvedAt: null,
|
||||
question: 'Welcher Farbschlag ist korrekt?',
|
||||
answer: 'Karlsson ist Blau, nicht Schwarz.',
|
||||
answeredAt: '2026-06-16T18:00:00Z',
|
||||
fixNote: null,
|
||||
agentContext: null,
|
||||
thread: [],
|
||||
},
|
||||
{
|
||||
id: 'feedback-open',
|
||||
message: 'Das Gewicht wird auf der Verlaufskurve falsch gerundet.',
|
||||
|
||||
128
gerbil-manager-web/e2e/scroll-restore.spec.ts
Normal file
128
gerbil-manager-web/e2e/scroll-restore.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Scroll-Wiederherstellung (useScrollRestoration im AppShell).
|
||||
*
|
||||
* Regression: Liste runterscrollen → Eintrag öffnen → Browser-Zurück → die Position
|
||||
* muss erhalten bleiben (vorher sprang sie auf 0, weil beim Wegnavigieren der kollabierende
|
||||
* Inhalt ein scroll→0 auslöste, das die gemerkte Position überschrieb).
|
||||
*
|
||||
* Gescrollt wird der Container `.app-main` (nicht das Fenster). Läuft in beiden Projekten
|
||||
* (desktop + phone) — die Rennmausliste ist in beiden Viewports hoch genug zum Scrollen.
|
||||
*/
|
||||
import { test, expect, skipUnlessMock, de } from './fixtures'
|
||||
|
||||
const SCROLLER = '.app-main'
|
||||
|
||||
async function scrollerTop(page: import('@playwright/test').Page) {
|
||||
return page.evaluate((sel) => document.querySelector(sel)?.scrollTop ?? 0, SCROLLER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Realistisch runterscrollen: ein Warm-up-Schritt (synchronisiert die gemerkte Höhe nach
|
||||
* dem ersten Render) + der eigentliche Schritt — wie echtes Scrollen, das viele Events feuert.
|
||||
*/
|
||||
async function scrollDown(page: import('@playwright/test').Page, target: number) {
|
||||
await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) el.scrollTop = 60
|
||||
}, SCROLLER)
|
||||
await page.waitForTimeout(80)
|
||||
await page.evaluate(
|
||||
([sel, t]) => {
|
||||
const el = document.querySelector(sel as string)
|
||||
if (el) el.scrollTop = Math.min(t as number, el.scrollHeight - el.clientHeight)
|
||||
},
|
||||
[SCROLLER, target] as const,
|
||||
)
|
||||
await page.waitForTimeout(150) // rAF-gedrosseltes Speichern abwarten
|
||||
}
|
||||
|
||||
test.describe('Scroll-Wiederherstellung', () => {
|
||||
test.beforeEach(() => skipUnlessMock())
|
||||
|
||||
test('Rennmausliste: Position überlebt Detail-Öffnen + Browser-Zurück', async ({ page }) => {
|
||||
await page.goto('/rennmaeuse')
|
||||
// Warten bis Tierkarten-Links da sind.
|
||||
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
|
||||
|
||||
// Container muss scrollbar sein, sonst ist der Test sinnlos.
|
||||
const scrollable = await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
return el ? el.scrollHeight > el.clientHeight + 50 : false
|
||||
}, SCROLLER)
|
||||
expect(scrollable, 'Liste muss scrollbar sein').toBeTruthy()
|
||||
|
||||
// Ein Stück runterscrollen.
|
||||
await scrollDown(page, 400)
|
||||
const before = await scrollerTop(page)
|
||||
expect(before).toBeGreaterThan(50)
|
||||
|
||||
// Einen AKTUELL SICHTBAREN Tier-Link öffnen (kein Auto-Scroll, wie ein echter Klick).
|
||||
const href = await page.evaluate(() => {
|
||||
for (const a of document.querySelectorAll('a[href^="/rennmaeuse/"]')) {
|
||||
const r = a.getBoundingClientRect()
|
||||
if (r.top >= 0 && r.bottom <= window.innerHeight && a.getAttribute('href') !== '/rennmaeuse/neu') {
|
||||
;(a as HTMLElement).click()
|
||||
return a.getAttribute('href')
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
expect(href).toBeTruthy()
|
||||
await expect(page).toHaveURL(/\/rennmaeuse\/[^/]+$/)
|
||||
|
||||
// Zurück — Position muss (nahezu) wiederhergestellt sein.
|
||||
await page.goBack()
|
||||
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => scrollerTop(page), { timeout: 4000, message: 'Scrollposition wiederhergestellt' })
|
||||
.toBeGreaterThan(before - 30)
|
||||
})
|
||||
|
||||
test('Rennmausliste: Position überlebt App-Hintergrund (visibilitychange)', async ({ page }) => {
|
||||
await page.goto('/rennmaeuse')
|
||||
await expect(page.locator('a[href^="/rennmaeuse/"]').first()).toBeVisible()
|
||||
await scrollDown(page, 400)
|
||||
const before = await scrollerTop(page)
|
||||
test.skip(before < 50, 'Liste in diesem Viewport nicht hoch genug')
|
||||
|
||||
// App in den Hintergrund (Handy-Sperre) und zurück simulieren.
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
// Inhalt „springt" (wie beim Aufwachen) künstlich nach oben …
|
||||
await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel)
|
||||
if (el) el.scrollTop = 0
|
||||
}, SCROLLER)
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
|
||||
// … und muss wiederhergestellt werden.
|
||||
await expect
|
||||
.poll(async () => scrollerTop(page), { timeout: 4000 })
|
||||
.toBeGreaterThan(before - 30)
|
||||
})
|
||||
|
||||
test('Tickets: gewählter Tab überlebt Browser-Zurück (nicht zurück auf „Offen")', async ({ page }) => {
|
||||
const tt = de.feedback.tickets
|
||||
await page.goto('/hilfe/tickets')
|
||||
// In die „Geschlossen"-Ansicht wechseln.
|
||||
await page.locator('.tickets-filter').filter({ hasText: tt.filters.closed }).click()
|
||||
await expect(
|
||||
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
|
||||
).toBeVisible()
|
||||
|
||||
// Über die Brotkrümel weg und per Browser-Zurück wieder her.
|
||||
await page.locator('.tickets-breadcrumb a[href="/hilfe"]').click()
|
||||
await expect(page).toHaveURL(/\/hilfe$/)
|
||||
await page.goBack()
|
||||
|
||||
// Tab muss weiterhin „Geschlossen" sein (vorher sprang er zurück auf „Offen").
|
||||
await expect(
|
||||
page.locator('.tickets-filter--active').filter({ hasText: tt.filters.closed }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -38,7 +38,8 @@ test.describe('Meine Tickets', () => {
|
||||
const open = page.locator('.tickets-filter').filter({ hasText: tt.filters.open })
|
||||
const 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')
|
||||
// „Offen" = Open + Answered → das offene + das beantwortete Ticket = 2.
|
||||
await expect(open.locator('.tickets-filter__count')).toHaveText('2')
|
||||
await expect(closed.locator('.tickets-filter__count')).toHaveText('1')
|
||||
|
||||
// „Offen": nur das offene Ticket.
|
||||
@@ -131,6 +132,20 @@ test.describe('Meine Tickets', () => {
|
||||
await expect(page.getByText('Korrigierte Beschreibung des Fehlers.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('beantwortetes Ticket: Erst-Text gesperrt, nur die neueste Antwort ist änderbar', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
// „Offen"-Ansicht (Default) zeigt auch beantwortete Tickets (Answered).
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Karlsson ist Blau' })
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
// Erst-Text ist gesperrt: kein „Bearbeiten"-Button mehr, sobald es eine Antwort gibt.
|
||||
await expect(card.getByRole('button', { name: tt.edit })).toHaveCount(0)
|
||||
// Die neueste Antwort ist dagegen noch änderbar (KI hat noch nicht reagiert → Answered).
|
||||
await expect(card.getByRole('button', { name: tt.amendAnswer })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rückfrage beantworten: Züchterin sieht Frage, antwortet, Badge wird „Beantwortet"', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -173,7 +188,9 @@ test.describe('Meine Tickets', () => {
|
||||
await expect(card.locator('.ticket-card__answer-form textarea')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Ticket löschen', async ({ page }) => {
|
||||
test('Ticket löschen = Soft-Delete: wandert in den Papierkorb, mit Countdown + Wiederherstellen', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
@@ -185,8 +202,134 @@ test.describe('Meine Tickets', () => {
|
||||
acceptNextDialog(page)
|
||||
await card.getByRole('button', { name: tt.delete }).click()
|
||||
|
||||
// Aus „Geschlossen" verschwunden …
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toHaveCount(0)
|
||||
|
||||
// … aber im Papierkorb („Gelöscht") vorhanden, mit Countdown + Wiederherstellen-Button.
|
||||
await selectView(page, tt.filters.deleted)
|
||||
const trashed = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(trashed).toBeVisible()
|
||||
await expect(trashed.locator('.ticket-card__trash-notice')).toBeVisible()
|
||||
await expect(trashed.getByText('endgültig gelöscht')).toBeVisible()
|
||||
|
||||
// Wiederherstellen → zurück aus dem Papierkorb (kehrt in „Geschlossen" zurück).
|
||||
await trashed.getByRole('button', { name: tt.restore }).click()
|
||||
await expect(
|
||||
page.locator('.ticket-card').filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' }),
|
||||
).toHaveCount(0)
|
||||
await selectView(page, tt.filters.closed)
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Wiederöffnen eines gelösten Tickets ohne Rückfrage → Rückfragen + Bitte um Infos + Zeitstempel', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
// „Krümel"-Ticket ist Resolved und hatte KEINE Rückfrage (question === null).
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await card.getByRole('button', { name: tt.reopen }).click()
|
||||
|
||||
// Landet in den „Rückfragen" mit der Bitte um mehr Infos + „Wieder geöffnet am".
|
||||
await selectView(page, tt.filters.dialog)
|
||||
const reopened = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
|
||||
await expect(reopened.getByText(tt.reopenRequest)).toBeVisible()
|
||||
await expect(reopened.getByText(tt.reopenedLabel)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Volltextsuche filtert die Ticket-Liste', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
const search = page.getByPlaceholder(tt.searchPlaceholder)
|
||||
await search.fill('Krümel')
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toBeVisible()
|
||||
|
||||
await search.fill('xyzgibtsnicht')
|
||||
await expect(page.getByText(tt.emptySearch)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Kategorie-Filter zeigt nur Tickets der gewählten Kategorie', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
// Das Krümel-Ticket hat die Kategorie „Stammbaum" → als Chip sichtbar.
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(card.locator('.ticket-card__category')).toHaveText('Stammbaum')
|
||||
|
||||
// Über das Kategorie-Menü filtern.
|
||||
await page.getByLabel(tt.categoryFilterLabel).selectOption('Stammbaum')
|
||||
await expect(
|
||||
page.getByText('Der Stammbaum zeigt den falschen Vater bei Krümel.'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('👍 auf gelöstem Ticket speichert Rückmeldung; 👎 öffnet es wieder', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(card.getByText(tt.helpfulQuestion)).toBeVisible()
|
||||
|
||||
// 👍 → Dank-Text, kein Wiederöffnen.
|
||||
await card.getByRole('button', { name: tt.helpfulYes }).click()
|
||||
await expect(card.getByText(tt.helpfulThanks)).toBeVisible()
|
||||
})
|
||||
|
||||
test('👎 auf gelöstem Ticket öffnet es als Rückfrage wieder', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
await selectView(page, tt.filters.closed)
|
||||
|
||||
const card = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await card.getByRole('button', { name: tt.helpfulNo }).click()
|
||||
|
||||
// Verlässt „Geschlossen", landet in „Rückfragen" mit Bitte um Infos.
|
||||
await selectView(page, tt.filters.dialog)
|
||||
const reopened = page
|
||||
.locator('.ticket-card')
|
||||
.filter({ hasText: 'Der Stammbaum zeigt den falschen Vater bei Krümel.' })
|
||||
await expect(reopened.locator('.ticket-badge--needsinfo')).toBeVisible()
|
||||
await expect(reopened.getByText(tt.helpfulReopenNote)).toBeVisible()
|
||||
})
|
||||
|
||||
test('Foto an ein Ticket anhängen erscheint als Vorschaubild', async ({ page }) => {
|
||||
await page.goto('/hilfe/tickets')
|
||||
// Offenes Ticket (Fridolin) — Anhang-Bereich mit „Foto anhängen".
|
||||
const card = page.locator('.ticket-card').filter({ hasText: 'Fridolin' })
|
||||
await expect(card.getByText(tt.attachmentsLabel, { exact: true })).toBeVisible()
|
||||
|
||||
// 1×1-PNG als Datei hochladen.
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
await card.locator('input[type="file"]').setInputFiles({
|
||||
name: 'maus.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: png,
|
||||
})
|
||||
|
||||
// Nach dem Upload erscheint ein Vorschaubild im Anhang-Raster.
|
||||
await expect(card.locator('.ticket-card__attachment img')).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */
|
||||
import type { Page } from '@playwright/test'
|
||||
import { de, expect, gotoSection, selectListFilter, skipUnlessMock, test, uniqueName } from './fixtures'
|
||||
|
||||
const t = de.pages.gerbils
|
||||
@@ -202,18 +203,128 @@ test('Tier bearbeiten — isDeaf Tristate round-trip (FORM-FIELDS-1)', async ({
|
||||
await expect(page.getByLabel(t.fields.isDeaf)).toHaveValue('true')
|
||||
})
|
||||
|
||||
test('Detailseite: Zuchtpartner-Liste trennt mit Komma HINTEN am Namen (KV-COMMA)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
// Anton ist Vater von Wurf B (Mutter Greta) und Wurf C (Mutter Frieda) → 2 Zuchtpartner.
|
||||
// .ak-kv-value ist ein Flex-Container mit wrap: ein vorangestelltes „, " landete beim
|
||||
// Umbruch am Zeilenanfang („, Frieda"). Das Komma muss am vorherigen Namen kleben.
|
||||
await page.goto('/rennmaeuse/anton')
|
||||
const row = page.locator('.ak-kv', {
|
||||
has: page.locator('.ak-kv-label', { hasText: de.pages.gerbils.detail.zuchtpartner }),
|
||||
})
|
||||
await expect(row).toBeVisible()
|
||||
const value = row.locator('.ak-kv-value')
|
||||
const text = (await value.innerText()).replace(/\s+/g, ' ').trim()
|
||||
expect(text).not.toMatch(/^,/)
|
||||
expect(text).toBe('Greta, Frieda')
|
||||
})
|
||||
|
||||
test('Detailseite: Abgabedatum steht direkt unter dem Abnehmer, auch bei Verstorbenen (GOHOME-ROW)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
// Ticket 24522f5f (JackJack): Rosa wurde abgegeben und starb später beim Abnehmer
|
||||
// (Status „Verstorben", aber Abnehmer + Abgabedatum gesetzt). Früher hing die
|
||||
// Abgabedatum-Zeile am Status „Abgegeben" → sie fehlte genau in diesem Fall.
|
||||
await page.goto('/rennmaeuse/rosa')
|
||||
const receiverRow = page.locator('.ak-kv', {
|
||||
has: page.locator('.ak-kv-label', { hasText: t.fields.receiver }),
|
||||
})
|
||||
await expect(receiverRow).toBeVisible()
|
||||
await expect(receiverRow.locator('.ak-kv-value')).toContainText('Familie Huber')
|
||||
|
||||
// Abgabedatum ist die direkt folgende Zeile (Geschwister-Selektor).
|
||||
const goHomeRow = receiverRow.locator('xpath=following-sibling::*[1]')
|
||||
await expect(goHomeRow.locator('.ak-kv-label')).toHaveText(t.fields.goHomeDate)
|
||||
await expect(goHomeRow.locator('.ak-kv-value')).toHaveText('15.01.2022')
|
||||
})
|
||||
|
||||
test('Detailseite: Farbschlag-Hinweis nur bei echter Abweichung (SCHECKE-MISMATCH)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
// Maja: eingetragen „Schwarz Schecke", Gencode ergibt genau das → KEIN Hinweis.
|
||||
// (Vorher wurde der Zusatz „Schecke" nur auf der errechneten Seite abgeschnitten,
|
||||
// dadurch bekam jede Schecke faelschlich „Weicht vom eingetragenen Farbschlag ab".)
|
||||
await page.goto('/rennmaeuse/maja')
|
||||
const genetik = page.locator('.ak-card', { hasText: t.detail.genetics }).first()
|
||||
await expect(genetik.getByText(t.detail.resolvedPrefix, { exact: false })).toBeVisible()
|
||||
await expect(page.getByText(t.detail.farbschlagMismatch)).toBeHidden()
|
||||
|
||||
// Gegenprobe: bei echter Abweichung erscheint der Hinweis weiter.
|
||||
await page.goto('/rennmaeuse/mismatch-maus')
|
||||
await expect(page.getByText(t.detail.farbschlagMismatch)).toBeVisible()
|
||||
})
|
||||
|
||||
/** QOL-ELTERN: Vater/Mutter im Abstammungs-Block des Tier-Formulars setzen. */
|
||||
async function pickParent(page: Page, legend: string, name: string) {
|
||||
const group = page.getByRole('group', { name: legend })
|
||||
await group.getByRole('searchbox').fill(name)
|
||||
await group.getByRole('button', { name, exact: true }).click()
|
||||
}
|
||||
|
||||
test('Tier bearbeiten — Eltern direkt an der Akte setzen, Wurf wird angelegt (QOL-ELTERN)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
// Maja hat keinen Geburtswurf → beim Speichern wird einer für die Eltern angelegt.
|
||||
await page.goto('/rennmaeuse/maja/bearbeiten')
|
||||
await expect(page.getByRole('heading', { name: t.form.editTitle })).toBeVisible()
|
||||
await expect(page.getByText(t.form.parentsSectionTitle)).toBeVisible()
|
||||
|
||||
await pickParent(page, t.detail.parentLittersRoleVater, 'Anton')
|
||||
await pickParent(page, t.detail.parentLittersRoleMutter, 'Greta')
|
||||
// Hinweis, dass ein Wurf-Eintrag angelegt wird (kein Wurf vorhanden).
|
||||
await expect(page.getByTestId('parents-new-litter-hint')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
|
||||
// Akte zeigt die neuen Eltern als Links.
|
||||
await expect(page.getByRole('heading', { name: 'Maja' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Anton', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Greta', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Tier bearbeiten — Eltern-Wechsel warnt vor den Geschwistern desselben Wurfs (QOL-ELTERN)', async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessMock()
|
||||
// Krümel hängt an Wurf K (Vater Fridolin, Mutter Luna) mit 2 weiteren Jungtieren.
|
||||
await page.goto('/rennmaeuse/kruemel/bearbeiten')
|
||||
const vater = page.getByRole('group', { name: t.detail.parentLittersRoleVater })
|
||||
await expect(vater.getByText('Fridolin')).toBeVisible()
|
||||
|
||||
// Vater tauschen → Warnung, dass die Änderung auch für die Geschwister gilt.
|
||||
await vater.getByRole('button', { name: t.form.parentsClear }).click()
|
||||
await pickParent(page, t.detail.parentLittersRoleVater, 'Anton')
|
||||
await expect(page.getByTestId('parents-sibling-warning')).toContainText('2')
|
||||
|
||||
await page.getByRole('button', { name: t.form.save, exact: true }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: 'Anton', exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Detailseite: Würfe als Elternteil zeigt verlinkten Wurf (ANIMAL-LITTERS)', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
// Fridolin ist fatherId von 'w-kruemel' (Wurf K)
|
||||
await page.goto('/rennmaeuse/fridolin')
|
||||
await expect(page.getByRole('heading', { name: 'Fridolin' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: t.detail.parentLittersTitle })).toBeVisible()
|
||||
// Wurf K als Link sichtbar
|
||||
const litterLink = page.getByRole('link', { name: /Wurf K/ })
|
||||
// Wurf K als Link sichtbar — gezielt IM Abschnitt „Würfe als Elternteil": der Abschnitt
|
||||
// „Nachzucht in der Zucht" nennt den Wurfnamen ebenfalls (Krümel-Karte) und würde die
|
||||
// Suche über die ganze Seite mehrdeutig machen.
|
||||
const littersSection = page.locator('section.ak-card', {
|
||||
has: page.getByRole('heading', { name: t.detail.parentLittersTitle }),
|
||||
})
|
||||
const litterLink = littersSection.getByRole('link', { name: /Wurf K/ })
|
||||
await expect(litterLink).toBeVisible()
|
||||
// Vater-Rollen-Badge im Wurf-Eintrag sichtbar (die Akte zeigt „Vater" jetzt auch
|
||||
// als Stammdaten-Zeile, daher gezielt auf das Badge eingrenzen).
|
||||
await expect(page.locator('.badge', { hasText: t.detail.parentLittersRoleVater })).toBeVisible()
|
||||
await expect(
|
||||
littersSection.locator('.badge', { hasText: t.detail.parentLittersRoleVater }).first(),
|
||||
).toBeVisible()
|
||||
// Navigation zum Wurf funktioniert
|
||||
await litterLink.first().click()
|
||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||
|
||||
36
gerbil-manager-web/e2e/verified.spec.ts
Normal file
36
gerbil-manager-web/e2e/verified.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* GEPRÜFTE TIERE: „vollständig korrekt"-Markierung auf der Rennmausakte (Badge + Toggle)
|
||||
* und die Übersichtsseite unter Hilfe. Mock-gebunden → skipUnlessMock.
|
||||
*/
|
||||
import { de, expect, gotoSection, acceptNextDialog, skipUnlessMock, test } from './fixtures'
|
||||
|
||||
const vt = de.verified
|
||||
|
||||
test.describe('Geprüfte Tiere', () => {
|
||||
test('Tier als vollständig korrekt markieren und Markierung entfernen', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await gotoSection(page, de.nav.gerbils)
|
||||
|
||||
// Erste Rennmaus aus der Liste öffnen.
|
||||
await page.locator('.gerbil-card').first().click()
|
||||
|
||||
// Prüfstatus-Karte ist sichtbar und erklärt sich selbst.
|
||||
await expect(page.getByRole('heading', { name: vt.sectionTitle })).toBeVisible()
|
||||
|
||||
// Markieren → Bestätigungs-Badge erscheint.
|
||||
await page.getByRole('button', { name: vt.markAction }).click()
|
||||
await expect(page.getByText(vt.verifiedBadge).first()).toBeVisible()
|
||||
|
||||
// Markierung entfernen (Klartext-Bestätigung akzeptieren) → Badge verschwindet.
|
||||
acceptNextDialog(page)
|
||||
await page.getByRole('button', { name: vt.unmarkAction }).click()
|
||||
await expect(page.getByText(vt.verifiedBadge)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Übersichtsseite unter Hilfe erreichbar', async ({ page }) => {
|
||||
skipUnlessMock()
|
||||
await page.goto('/hilfe/verifizierte-tiere')
|
||||
await expect(page.getByRole('heading', { name: vt.pageTitle })).toBeVisible()
|
||||
await expect(page.getByText(vt.pageIntro1)).toBeVisible()
|
||||
})
|
||||
})
|
||||
49
gerbil-manager-web/public/sw.js
Normal file
49
gerbil-manager-web/public/sw.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Service Worker — NUR für Web-Push-Benachrichtigungen.
|
||||
* BEWUSST OHNE fetch-Handler/Caching, damit das Laden der App niemals beeinflusst wird.
|
||||
*/
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()))
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
let data = {}
|
||||
try {
|
||||
data = event.data ? event.data.json() : {}
|
||||
} catch {
|
||||
data = {}
|
||||
}
|
||||
const title = data.title || 'Zucht der kleinen Chaoten'
|
||||
const body = data.body || ''
|
||||
const url = data.url || '/hilfe/tickets'
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { url },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const url = (event.notification.data && event.notification.data.url) || '/hilfe/tickets'
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if ('focus' in client) {
|
||||
if ('navigate' in client) {
|
||||
try {
|
||||
client.navigate(url)
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) return self.clients.openWindow(url)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -21,6 +21,7 @@ import StatistikPage from './pages/StatistikPage'
|
||||
import HilfePage from './pages/HilfePage'
|
||||
import TicketsPage from './pages/TicketsPage'
|
||||
import RennmausProImportPage from './pages/RennmausProImportPage'
|
||||
import VerifizierteTierePage from './pages/VerifizierteTierePage'
|
||||
import FarbkatalogPage from './pages/FarbkatalogPage'
|
||||
import VertraegeListPage from './pages/VertraegeListPage'
|
||||
import VertragWizardPage from './pages/VertragWizardPage'
|
||||
@@ -83,6 +84,8 @@ export default function App() {
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
{/* RPRO3: RennmausPro-III-Backup-Import (Rubrik unter „Hilfe") */}
|
||||
<Route path="rennmauspro-import" element={<RennmausProImportPage />} />
|
||||
{/* GEPRÜFTE TIERE: „vollständig korrekt"-Übersicht + Import-Abweichungen */}
|
||||
<Route path="verifizierte-tiere" element={<VerifizierteTierePage />} />
|
||||
</Route>
|
||||
<Route path="farbkatalog" element={<FarbkatalogPage />} />
|
||||
{/* FEAT-13: Abgabeverträge + Einstellungen (Zuchtprofil) */}
|
||||
|
||||
@@ -3,11 +3,18 @@ import { de } from '../strings/de'
|
||||
/**
|
||||
* Basis-URL der GerbilManagerWebAPI.
|
||||
* Dev (Vite dev server / e2e mock): absolute URL, damit Playwright-Route-Interception greift.
|
||||
* Der Host wird aus der AKTUELLEN Adresse abgeleitet (window.location.hostname), damit der
|
||||
* Zugriff vom Handy/anderen LAN-Gerät funktioniert: lädt die Seite von 192.168.x.y:5173,
|
||||
* geht die API an 192.168.x.y:5179 (nicht „localhost", was auf dem Handy das Handy selbst wäre).
|
||||
* Auf dem Rechner/in e2e ist der Host „localhost" → unverändert http://localhost:5179.
|
||||
* Produktion (Vite build → nginx): relativer Pfad /api; nginx proxyt zum API-Container.
|
||||
* Aspire (VITE_API_BASE_URL gesetzt): überschreibt immer (LAN-IP + Port für Handy-Zugriff).
|
||||
* Aspire (VITE_API_BASE_URL gesetzt): überschreibt immer.
|
||||
*/
|
||||
const devApiBase = `http://${
|
||||
typeof window !== 'undefined' && window.location.hostname ? window.location.hostname : 'localhost'
|
||||
}:5179`
|
||||
export const API_BASE_URL: string =
|
||||
import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.PROD ? '/api' : 'http://localhost:5179')
|
||||
import.meta.env.VITE_API_BASE_URL ?? (import.meta.env.PROD ? '/api' : devApiBase)
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number | null
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
|
||||
import { api } from './client'
|
||||
import { api, API_BASE_URL } from './client'
|
||||
|
||||
const RESOURCE = '/feedback'
|
||||
|
||||
/** Metadaten eines Ticket-Anhangs (Bytes separat über attachmentUrl). */
|
||||
export interface FeedbackAttachment {
|
||||
id: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
/** Which view a report was filed from (matches the backend Context contract). */
|
||||
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
|
||||
|
||||
@@ -50,6 +58,22 @@ export interface Feedback {
|
||||
createdAt: string
|
||||
status: FeedbackStatus
|
||||
resolvedAt: string | null
|
||||
/**
|
||||
* Zeitpunkt des Wiederöffnens — gesetzt, wenn ein ECHT gelöstes Ticket (ohne offene
|
||||
* Rückfrage) wieder geöffnet wurde; null sonst. Wird als „Wieder geöffnet am" angezeigt.
|
||||
*/
|
||||
reopenedAt: string | null
|
||||
/**
|
||||
* Soft-Delete-Marker: gesetzt, wenn das Ticket über die UI gelöscht wurde (wiederherstellbar).
|
||||
* Solche Tickets erscheinen nur in der „Gelöscht"-Kategorie. null = nicht gelöscht.
|
||||
*/
|
||||
deletedAt: string | null
|
||||
/** Optionale Kategorie/Thema (von der KI gesetzt), z. B. „Genetik", „Import". null = ohne. */
|
||||
category: string | null
|
||||
/** War die Lösung hilfreich? true=👍, false=👎, null=keine Rückmeldung. */
|
||||
helpful: boolean | null
|
||||
/** Angehängte Dateien/Fotos (nur Metadaten; Bytes über attachmentUrl laden). */
|
||||
attachments: FeedbackAttachment[]
|
||||
/** Rückfrage einer/eines Betreuenden an die Züchterin (falls vorhanden). */
|
||||
question: string | null
|
||||
/** Antwort der Züchterin auf die Rückfrage (falls vorhanden). */
|
||||
@@ -85,6 +109,10 @@ export interface FeedbackUpdate {
|
||||
fixNote?: string
|
||||
/** INTERN: Arbeitsgedächtnis des Agenten (ändert den Status nicht). */
|
||||
agentContext?: string
|
||||
/** Kategorie/Thema setzen (leer ⇒ löschen). */
|
||||
category?: string
|
||||
/** 👍/👎-Rückmeldung auf ein gelöstes Ticket. */
|
||||
helpful?: boolean
|
||||
}
|
||||
|
||||
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
|
||||
@@ -106,7 +134,50 @@ export function answerTicket(id: string, answer: string): Promise<Feedback> {
|
||||
return updateFeedback(id, { answer })
|
||||
}
|
||||
|
||||
/** Delete a ticket. */
|
||||
/** Soft-delete a ticket (moves it into the "Gelöscht" category; recoverable via restore). */
|
||||
export function deleteFeedback(id: string): Promise<void> {
|
||||
return api.delete(`${RESOURCE}/${id}`)
|
||||
}
|
||||
|
||||
/** Restore a soft-deleted ticket (DeletedAt → null); it returns to its prior status. */
|
||||
export function restoreFeedback(id: string): Promise<Feedback> {
|
||||
return api.post<Feedback>(`${RESOURCE}/${id}/restore`, {})
|
||||
}
|
||||
|
||||
/** Volle URL zu den Bytes eines Anhangs (für <img src> / Download). */
|
||||
export function attachmentUrl(attachmentId: string): string {
|
||||
return `${API_BASE_URL}${RESOURCE}/attachments/${attachmentId}`
|
||||
}
|
||||
|
||||
/** Einen Anhang (base64) an ein Ticket hochladen. */
|
||||
export function uploadAttachment(
|
||||
feedbackId: string,
|
||||
body: { fileName: string; contentType: string; dataBase64: string },
|
||||
): Promise<FeedbackAttachment> {
|
||||
return api.post<FeedbackAttachment>(`${RESOURCE}/${feedbackId}/attachments`, body)
|
||||
}
|
||||
|
||||
/** Einen Anhang löschen. */
|
||||
export function deleteAttachment(attachmentId: string): Promise<void> {
|
||||
return api.delete(`${RESOURCE}/attachments/${attachmentId}`)
|
||||
}
|
||||
|
||||
/** Eine Browser-Datei als Upload-Payload (base64) einlesen. */
|
||||
export function readFileAsUpload(
|
||||
file: File,
|
||||
): Promise<{ fileName: string; contentType: string; dataBase64: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result)
|
||||
const comma = result.indexOf(',')
|
||||
resolve({
|
||||
fileName: file.name,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
dataBase64: comma >= 0 ? result.slice(comma + 1) : result,
|
||||
})
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
/** Typed API functions for the Tiere (Rennmäuse) resource. */
|
||||
import { api, resources } from './client'
|
||||
import { toQueryString, type GridifyQuery } from './gridify'
|
||||
import type { CreateGerbil, Gerbil, Paged, UpdateGerbil } from './types'
|
||||
import type {
|
||||
CreateGerbil,
|
||||
Gerbil,
|
||||
GerbilParentsInput,
|
||||
GerbilParentsResult,
|
||||
Paged,
|
||||
UpdateGerbil,
|
||||
} from './types'
|
||||
|
||||
export function listGerbils(query: GridifyQuery): Promise<Paged<Gerbil>> {
|
||||
return api.get<Paged<Gerbil>>(`${resources.gerbils}${toQueryString(query)}`)
|
||||
@@ -24,6 +31,18 @@ export function updateGerbil(id: string, body: UpdateGerbil): Promise<Gerbil> {
|
||||
return api.put<Gerbil>(`${resources.gerbils}/${id}`, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* QOL-ELTERN: Vater/Mutter direkt an der Tier-Akte setzen. Das Backend schreibt sie an den
|
||||
* Geburtswurf des Tiers (und legt diesen an, wenn es noch keinen gibt) — dieselbe Datenbasis,
|
||||
* die die Wurf-Seite bearbeitet, nur ohne den Umweg über die Wurf-Suche.
|
||||
*/
|
||||
export function updateGerbilParents(
|
||||
id: string,
|
||||
body: GerbilParentsInput,
|
||||
): Promise<GerbilParentsResult> {
|
||||
return api.put<GerbilParentsResult>(`${resources.gerbils}/${id}/parents`, body)
|
||||
}
|
||||
|
||||
export function deleteGerbil(id: string): Promise<void> {
|
||||
return api.delete(`${resources.gerbils}/${id}`)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ export interface Gerbil {
|
||||
* null = manuell angelegtes Tier / keine Importdaten.
|
||||
*/
|
||||
provenance?: string | null
|
||||
/**
|
||||
* Reihenfolge des Jungtiers innerhalb seines Wurfs (aus der Wurfchronik, nach
|
||||
* Geburtsgewicht). null = unbekannt → alphabetischer Fallback.
|
||||
*/
|
||||
birthOrder?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,6 +196,15 @@ export interface Litter {
|
||||
motherId: string | null
|
||||
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
|
||||
deathsWithin8Weeks?: number | null
|
||||
/** Totgeburten (tot geboren) — aus der xlsx-Wurfchronik importiert. */
|
||||
stillborn?: number | null
|
||||
/**
|
||||
* WURFCHRONIK-FILTER: true = in der Wurfchronik (Wurf-Liste) sichtbar,
|
||||
* false = ausgeblendet (z. B. fremde/pedigree-only Würfe). Server-seitig
|
||||
* Gridify-filterbar (`showInChronicle==true`). Auf der Tier-Detailseite werden
|
||||
* versteckte Würfe trotzdem gezeigt. Absent = behandeln wie true (Backend-Default).
|
||||
*/
|
||||
showInChronicle?: boolean
|
||||
/**
|
||||
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
|
||||
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
|
||||
@@ -208,10 +222,68 @@ export interface CreateLitter {
|
||||
fatherId?: string | null
|
||||
motherId?: string | null
|
||||
deathsWithin8Weeks?: number | null
|
||||
stillborn?: number | null
|
||||
/** WURFCHRONIK-FILTER: false = aus der Wurfchronik ausblenden (Default true). */
|
||||
showInChronicle?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* QOL-ELTERN: Eltern eines Tiers direkt an der Akte setzen (PUT /gerbils/{id}/parents).
|
||||
* Gespeichert werden sie unverändert am GEBURTSWURF des Tiers — der Endpoint kapselt nur,
|
||||
* dass man dafür sonst erst den Wurf suchen müsste.
|
||||
*/
|
||||
export interface GerbilParentsInput {
|
||||
fatherId: string | null
|
||||
motherId: string | null
|
||||
}
|
||||
|
||||
/** Antwort von PUT /gerbils/{id}/parents — welcher Wurf die Eltern jetzt trägt. */
|
||||
export interface GerbilParentsResult {
|
||||
litterId: string | null
|
||||
litterName: string | null
|
||||
/** true = für die Eltern wurde ein (versteckter) Wurf neu angelegt. */
|
||||
litterCreated: boolean
|
||||
/** true = das Tier wurde einem bestehenden Wurf mit denselben Eltern + Datum zugeordnet. */
|
||||
litterAttached: boolean
|
||||
/** Weitere Jungtiere im selben Wurf, für die die Eltern ebenfalls gelten. */
|
||||
siblingCount: number
|
||||
fatherId: string | null
|
||||
motherId: string | null
|
||||
}
|
||||
|
||||
export type UpdateLitter = Partial<CreateLitter>
|
||||
|
||||
/**
|
||||
* GEPRÜFTE TIERE: Status einer „vollständig korrekt"-Markierung / eines Schutzes
|
||||
* relativ zum Rohimport. "unchanged" = Import stimmt mit dem geprüften Stand überein;
|
||||
* "drifted" = Import weicht ab (siehe importDiff); "missing" = das Tier existiert im
|
||||
* Import nicht mehr.
|
||||
*/
|
||||
export type VerifiedStatus = 'unchanged' | 'drifted' | 'missing'
|
||||
|
||||
/** Ein Feld-Unterschied zwischen geprüftem Stand (golden) und Rohimport. */
|
||||
export interface SnapshotDiff {
|
||||
path: string
|
||||
goldenValue: string | null
|
||||
importValue: string | null
|
||||
}
|
||||
|
||||
/** Override-/Prüfstatus eines Tiers (Backend: GET /verified-gerbils[/{id}]). */
|
||||
export interface VerifiedGerbil {
|
||||
gerbilId: string
|
||||
entityName: string | null
|
||||
/** true = „vollständig korrekt" (zertifiziert + voller Freeze); false = „geschützt" (Bearbeitung, feldweise). */
|
||||
isVerified: boolean
|
||||
status: VerifiedStatus
|
||||
verifiedAt: string | null
|
||||
updatedAt: string
|
||||
note: string | null
|
||||
/** Eigene-Feld-Schlüssel, die gegen den Import eingefroren sind. */
|
||||
protectedFields: string[]
|
||||
/** Wo der Rohimport vom geprüften Stand abweicht (nur verifizierte Tiere). */
|
||||
importDiff: SnapshotDiff[]
|
||||
}
|
||||
|
||||
/** Paged list envelope returned by Gridify list endpoints. */
|
||||
export interface Paged<T> {
|
||||
items: T[]
|
||||
|
||||
37
gerbil-manager-web/src/api/verified.ts
Normal file
37
gerbil-manager-web/src/api/verified.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* GEPRÜFTE TIERE: API-Client für die „vollständig korrekt"-Markierung + den Schutz
|
||||
* manueller Änderungen (/verified-gerbils). Eine markierte Maus wird vom automatischen
|
||||
* Import nicht mehr verändert (Freeze).
|
||||
*/
|
||||
import { api, ApiError } from './client'
|
||||
import type { VerifiedGerbil } from './types'
|
||||
|
||||
const RESOURCE = '/verified-gerbils'
|
||||
|
||||
/** Alle geprüften/geschützten Tiere mit Drift-Status. */
|
||||
export function listVerified(): Promise<VerifiedGerbil[]> {
|
||||
return api.get<VerifiedGerbil[]>(RESOURCE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüfstatus eines einzelnen Tiers. Gibt null zurück, wenn das Tier weder markiert
|
||||
* noch geschützt ist (Backend liefert dann 404) — dann gibt es einfach keinen Status.
|
||||
*/
|
||||
export async function getVerifiedStatus(gerbilId: string): Promise<VerifiedGerbil | null> {
|
||||
try {
|
||||
return await api.get<VerifiedGerbil>(`${RESOURCE}/${gerbilId}`)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) return null
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Als „vollständig korrekt" markieren (Freeze aller eigenen Felder). Optionaler Hinweis. */
|
||||
export function verifyGerbil(gerbilId: string, note?: string | null): Promise<VerifiedGerbil> {
|
||||
return api.post<VerifiedGerbil>(`${RESOURCE}/${gerbilId}`, { note: note ?? null })
|
||||
}
|
||||
|
||||
/** Markierung/Schutz entfernen (nicht-destruktiv; nächster Import macht wieder import-getrieben). */
|
||||
export function unverifyGerbil(gerbilId: string): Promise<void> {
|
||||
return api.delete(`${RESOURCE}/${gerbilId}`)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import GerbilIcon from './GerbilIcon'
|
||||
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
||||
import { useScrollRestoration } from '../hooks/useScrollRestoration'
|
||||
import './appShell.css'
|
||||
|
||||
interface NavItem {
|
||||
@@ -52,6 +53,9 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
*/
|
||||
export default function AppShell() {
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
// Scrollposition je Seite erhalten (Zurück-Navigation + Handy-Sperre/Hintergrund) — gilt für
|
||||
// ALLE scrollbaren Listen der App.
|
||||
useScrollRestoration()
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
|
||||
@@ -32,14 +32,15 @@ export default function GerbilAcquisitionSection({ gerbilId }: { gerbilId: strin
|
||||
const [note, setNote] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const save = useMutation((id: string | '') => {
|
||||
const save = useMutation(async (id: string | '') => {
|
||||
const body = {
|
||||
gerbilId,
|
||||
date: date || null,
|
||||
price: price.trim() === '' ? null : Number(price),
|
||||
note: note.trim() || null,
|
||||
}
|
||||
return id === '' ? createAcquisition(body) : updateAcquisition(id, body)
|
||||
if (id === '') await createAcquisition(body)
|
||||
else await updateAcquisition(id, body)
|
||||
})
|
||||
const removal = useMutation((id: string) => deleteAcquisition(id))
|
||||
|
||||
|
||||
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal file
64
gerbil-manager-web/src/components/PushToggle.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Schalter „Benachrichtigungen aktivieren" (Web-Push / PWA).
|
||||
* Rendert nichts, wenn Push nicht unterstützt wird ODER der Server keine VAPID-Schlüssel hat
|
||||
* (z. B. im e2e-Mock) — so bleibt die UI dort unverändert.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { de } from '../strings/de'
|
||||
import { useToast } from './toast'
|
||||
import { disablePush, enablePush, fetchPushConfig, isPushSupported, isSubscribed } from '../push'
|
||||
|
||||
export default function PushToggle() {
|
||||
const t = de.feedback.tickets
|
||||
const toast = useToast()
|
||||
const [available, setAvailable] = useState(false)
|
||||
const [subscribed, setSubscribed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
if (!isPushSupported()) return
|
||||
const cfg = await fetchPushConfig()
|
||||
if (cancelled || !cfg.enabled) return
|
||||
setAvailable(true)
|
||||
setSubscribed(await isSubscribed())
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!available) return null
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true)
|
||||
try {
|
||||
if (subscribed) {
|
||||
await disablePush()
|
||||
setSubscribed(false)
|
||||
toast.success(t.pushDisabledToast)
|
||||
} else {
|
||||
const res = await enablePush()
|
||||
if (res === 'enabled') {
|
||||
setSubscribed(true)
|
||||
toast.success(t.pushEnabledToast)
|
||||
} else if (res === 'denied') {
|
||||
toast.error(t.pushDenied)
|
||||
} else {
|
||||
toast.error(t.pushUnavailable)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t.pushUnavailable)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="btn tickets-push-toggle" onClick={toggle} disabled={busy}>
|
||||
{subscribed ? `🔕 ${t.pushDisable}` : `🔔 ${t.pushEnable}`}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -5,13 +5,26 @@
|
||||
* dialog captures the current URL and a client timestamp itself and POSTs everything
|
||||
* to /feedback. Success/error is surfaced via the existing toast system.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { de } from '../strings/de'
|
||||
import { ApiError } from '../api/client'
|
||||
import { submitFeedback, type FeedbackContext } from '../api/feedback'
|
||||
import {
|
||||
listFeedback,
|
||||
readFileAsUpload,
|
||||
submitFeedback,
|
||||
uploadAttachment,
|
||||
type FeedbackContext,
|
||||
type FeedbackTicket,
|
||||
} from '../api/feedback'
|
||||
import { useToast } from './toast'
|
||||
import './reportErrorDialog.css'
|
||||
|
||||
/** Wörter (≥3 Zeichen) für einen einfachen Ähnlichkeits-Score. */
|
||||
function wordSet(s: string): Set<string> {
|
||||
return new Set((s.toLowerCase().match(/[a-zäöüß0-9]{3,}/g) ?? []))
|
||||
}
|
||||
|
||||
export interface ReportErrorContext {
|
||||
context: FeedbackContext
|
||||
gerbilId?: string | null
|
||||
@@ -58,8 +71,49 @@ function ReportErrorDialogBody({
|
||||
}
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Bereits gelöste Tickets einmalig laden, um beim Tippen ähnliche vorzuschlagen.
|
||||
const [resolvedTickets, setResolvedTickets] = useState<FeedbackTicket[]>([])
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
listFeedback()
|
||||
.then((all) => {
|
||||
if (!cancelled)
|
||||
setResolvedTickets(all.filter((tk) => tk.status === 'Resolved' && !tk.deletedAt))
|
||||
})
|
||||
.catch(() => {
|
||||
/* ohne Vorschläge weiter */
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Ähnliche gelöste Tickets: Wort-Überlappung + Bonus bei gleichem Tier/Wurf/Kontakt.
|
||||
const similar = useMemo(() => {
|
||||
const msgWords = wordSet(message)
|
||||
if (msgWords.size < 2) return []
|
||||
const scored = resolvedTickets.map((tk) => {
|
||||
const hay = wordSet(`${tk.message} ${tk.fixNote ?? ''} ${tk.entityName ?? ''}`)
|
||||
let score = 0
|
||||
for (const w of msgWords) if (hay.has(w)) score++
|
||||
if (
|
||||
(context.gerbilId && tk.gerbilId === context.gerbilId) ||
|
||||
(context.litterId && tk.litterId === context.litterId) ||
|
||||
(context.contactId && tk.contactId === context.contactId)
|
||||
)
|
||||
score += 2
|
||||
return { tk, score }
|
||||
})
|
||||
return scored
|
||||
.filter((s) => s.score >= 2)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 3)
|
||||
.map((s) => s.tk)
|
||||
}, [message, resolvedTickets, context.gerbilId, context.litterId, context.contactId])
|
||||
|
||||
// Entwurf laufend sichern (bei jeder Eingabe) bzw. entfernen, wenn leer.
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -96,7 +150,7 @@ function ReportErrorDialogBody({
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await submitFeedback({
|
||||
const created = await submitFeedback({
|
||||
message: trimmed,
|
||||
context: context.context,
|
||||
gerbilId: context.gerbilId ?? null,
|
||||
@@ -106,6 +160,14 @@ function ReportErrorDialogBody({
|
||||
url: window.location.href,
|
||||
clientTimestamp: new Date().toISOString(),
|
||||
})
|
||||
// Ausgewählte Anhänge nach dem Anlegen hochladen (Ticket-ID liegt erst jetzt vor).
|
||||
for (const file of files) {
|
||||
try {
|
||||
await uploadAttachment(created.id, await readFileAsUpload(file))
|
||||
} catch {
|
||||
toast.error(t.attachmentError)
|
||||
}
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(draftKey)
|
||||
} catch {
|
||||
@@ -161,6 +223,41 @@ function ReportErrorDialogBody({
|
||||
rows={5}
|
||||
/>
|
||||
|
||||
{similar.length > 0 && (
|
||||
<div className="report-error__similar">
|
||||
<div className="report-error__similar-title">{t.similarTitle}</div>
|
||||
<ul className="report-error__similar-list">
|
||||
{similar.map((tk) => (
|
||||
<li key={tk.id}>
|
||||
<Link to={`/hilfe/tickets?focus=${tk.id}`} onClick={onClose}>
|
||||
{tk.message.length > 80 ? `${tk.message.slice(0, 80)}…` : tk.message}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="report-error__attach">
|
||||
<label className="report-error__label" htmlFor="report-error-files">
|
||||
{t.attachLabel}
|
||||
</label>
|
||||
<input
|
||||
id="report-error-files"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
||||
/>
|
||||
{files.length > 0 && (
|
||||
<ul className="report-error__attach-list">
|
||||
{files.map((f, i) => (
|
||||
<li key={i}>{f.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="report-error__debug">
|
||||
<div className="report-error__debug-title">{t.debugTitle}</div>
|
||||
<dl className="report-error__debug-list">
|
||||
|
||||
@@ -147,3 +147,24 @@
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
/* Vorschlag: ähnliche bereits gelöste Tickets. */
|
||||
.report-error__similar {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--color-accent-bg, #eef2ff);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.report-error__similar-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.report-error__similar-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ type NameParts = Pick<Gerbil, 'name' | 'originContactId' | 'originBreeder'>
|
||||
|
||||
/** Own-bred = no external origin recorded (no origin contact, no freetext breeder). */
|
||||
export function isOwnBred(g: NameParts): boolean {
|
||||
return !g.originContactId && !(g.originBreeder && g.originBreeder.trim())
|
||||
if (g.originContactId) return false
|
||||
const b = g.originBreeder?.trim().toLowerCase()
|
||||
if (!b) return true
|
||||
return b.includes('chaoten')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,11 +17,14 @@ import {
|
||||
wildType,
|
||||
extractGenotypeFlags,
|
||||
displayGenotypeSafe,
|
||||
resolveAllelePair,
|
||||
inferUnknownsFromParents,
|
||||
} from '../genotype'
|
||||
import { combineLocus } from '../punnett'
|
||||
import { LOCI, type LocusKey } from '../loci'
|
||||
import {
|
||||
farbschlagFor,
|
||||
formatVarietyName,
|
||||
genotypeToFarbschlag,
|
||||
representativeGenotype,
|
||||
BASE_COLORS,
|
||||
@@ -239,10 +242,11 @@ describe('Farbschlag catalog', () => {
|
||||
})
|
||||
|
||||
describe('Partially-unknown parents (wildcards)', () => {
|
||||
it('handles an A? parent (phenotype agouti, genotype unknown)', () => {
|
||||
// A? x aa at the A locus -> father gamete: 1/2 A, 1/4 (each of A,a) from "?"
|
||||
// = effectively 3/4 A, 1/4 a ; mother always a.
|
||||
// Offspring: 3/4 Aa, 1/4 aa.
|
||||
it('GEN-5: an A? parent reads as AA (unknown copies the known allele)', () => {
|
||||
// GEN-5 (ticket 3e643ef1, breeder rule): the unknown allele '?' is a COPY of
|
||||
// the known partner 'A', so A? = AA. AA × aa → all Aa → 100% Agouti.
|
||||
// (Previously '?' spread uniformly → 3/4 Agouti : 1/4 Schwarz, which invented
|
||||
// a recessive 'a' gamete the parent demonstrably does not show.)
|
||||
const father = makeGenotype({
|
||||
A: ['A', '?'],
|
||||
C: ['C', 'C'],
|
||||
@@ -257,10 +261,10 @@ describe('Partially-unknown parents (wildcards)', () => {
|
||||
const mother = fromDisplayString('aa CC DD EE GG PP spsp rere')
|
||||
const result = breed(father, mother)
|
||||
|
||||
const agouti = result.byFarbschlag.find((f) => f.farbschlag === 'Agouti')!
|
||||
const schwarz = result.byFarbschlag.find((f) => f.farbschlag === 'Schwarz')!
|
||||
expect(agouti.probability.text).toBe('3/4')
|
||||
expect(schwarz.probability.text).toBe('1/4')
|
||||
expect(result.offspring).toHaveLength(1)
|
||||
expect(result.offspring[0].farbschlag).toBe('Agouti')
|
||||
expect(result.offspring[0].probability.text).toBe('1')
|
||||
expect(result.byFarbschlag.some((f) => f.farbschlag === 'Schwarz')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -603,7 +607,10 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
expect(name('AA CC dd EE GG PP spsp rere')).toBe('Dilute Agouti')
|
||||
expect(name('aa CC dd EE gg PP spsp rere')).toBe('Dilute Anthrazit')
|
||||
expect(name('aa CC dd ee GG PP spsp rere')).toBe('Dilute Kohlfuchs')
|
||||
expect(name('aa CC dd ee gg pp spsp rere')).toBe('Dilute Blaufuchs')
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is BLACK-eyed (P), and dilution is
|
||||
// independent of the P-locus, so Dilute Blaufuchs is aa dd ee gg P- (was
|
||||
// wrongly P:'p'). The pink-eyed variant is a different (REW-adjacent) colour.
|
||||
expect(name('aa CC dd ee gg PP spsp rere')).toBe('Dilute Blaufuchs')
|
||||
expect(name('AA CC dd EE GG pp spsp rere')).toBe('Dilute Gold')
|
||||
expect(name('aa CC dd EE GG pp spsp rere')).toBe('Dilute Platin')
|
||||
})
|
||||
@@ -628,10 +635,13 @@ describe('GEN-4: Dilute prefix, REW, no-bare-Fuchs', () => {
|
||||
|
||||
it('Farbarten (categories) never appear as computed results', () => {
|
||||
// 'Fuchs', 'Fuchsschimmel', 'Schimmel' etc. are Farbarten — blocked by category guard.
|
||||
// het ef/e now resolves to specific variety via locusToken ef/e -> 'ef' fix.
|
||||
// GEN-5 (ticket 5826e8e2): het ef/e is the FUCHSSCHIMMEL family — it resolves to
|
||||
// a *fuchsschimmel variety, NEVER a pure Schimmel (Rotaugen-/Orangeschimmel).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa CC DD eef GG PP spsp rere'))).toBe('Kohlfuchsschimmel')
|
||||
// Agouti ef/e: 'Orangeschimmel' wins (same token-set as Algierfuchsschimmel, listed first)
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||
// Agouti ef/e black-eyed → Algierfuchsschimmel (A:A,E:ef,G:G,P:P), NOT Orangeschimmel.
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD eef GG PP spsp rere'))).toBe('Algierfuchsschimmel')
|
||||
// hom ef/ef agouti black-eyed → the pure Orangeschimmel (Schimmel family).
|
||||
expect(genotypeToFarbschlag(fromDisplayString('AA CC DD efef GG PP spsp rere'))).toBe('Orangeschimmel')
|
||||
// Unusual combo not in catalog -> Unbekannt (not 'Fuchsschimmel')
|
||||
expect(farbschlagFor(fromDisplayString('aa CC dd eef GG PP spsp rere')).unknown).toBe(true)
|
||||
// FK check: none of the 7 category names are in BASE_COLORS (no DB entries -> no FK risk)
|
||||
@@ -789,3 +799,199 @@ describe('GEN-3h: breeder bracket-notation display + E-locus e-before-ef order',
|
||||
expect(displayGenotypeSafe('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: unknown allele = copy of known (resolveAllelePair, breeder rule)', () => {
|
||||
it('a single-unknown pair resolves to the homozygote of the KNOWN allele', () => {
|
||||
expect(resolveAllelePair('A', ['A', '?'])).toEqual(['A', 'A'])
|
||||
expect(resolveAllelePair('A', ['?', 'a'])).toEqual(['a', 'a'])
|
||||
expect(resolveAllelePair('D', ['D', '?'])).toEqual(['D', 'D'])
|
||||
expect(resolveAllelePair('E', ['e', '?'])).toEqual(['e', 'e']) // ee[-] = Fuchs
|
||||
expect(resolveAllelePair('E', ['ef', '?'])).toEqual(['ef', 'ef']) // ef[-] = Schimmel
|
||||
})
|
||||
it('a fully-unknown pair falls back to wild-type (markers stay unmarked)', () => {
|
||||
expect(resolveAllelePair('A', ['?', '?'])).toEqual(['A', 'A'])
|
||||
expect(resolveAllelePair('C', ['?', '?'])).toEqual(['C', 'C'])
|
||||
expect(resolveAllelePair('Sp', ['?', '?'])).toEqual(['sp', 'sp']) // never implies Schecke
|
||||
})
|
||||
it('a fully-known pair is returned unchanged', () => {
|
||||
expect(resolveAllelePair('C', ['C', 'ch'])).toEqual(['C', 'ch'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: genetics-engine ticket reproductions (real stored genotypes)', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
|
||||
it('5826e8e2: ee[f] het (Fuchsschimmel) + pp → Goldfuchsschimmel, NOT Rotaugenschimmel', () => {
|
||||
// Tier bf6f4507: Aa C- D- ee[f] G- pp Spsp — het ef/e is a Fuchsschimmel.
|
||||
expect(name('Aa C- D- ee[f] G- pp Spsp')).toBe('Goldfuchsschimmel Schecke')
|
||||
// and the pure hom ef/ef pp stays the pure Schimmel:
|
||||
expect(name('AA CC DD efef GG pp spsp')).toBe('Rotaugenschimmel')
|
||||
})
|
||||
|
||||
it('b034ddd2: ee[-] (e + unknown) → ee Fuchs → Algierfuchs, NOT Agouti', () => {
|
||||
// Tier 33a7c1f9: Aa CC D- ee[-] Gg Pp spsp. Old engine read [e,?] as e/E → Agouti.
|
||||
expect(name('Aa CC D- ee[-] Gg Pp spsp')).toBe('Algierfuchs')
|
||||
})
|
||||
|
||||
it('3deab547 / efa2b232: aa cchm dd ee[-] gg P- → Dilute CP-Blaufuchs, NOT Zobel/blau/Unbekannt', () => {
|
||||
// Tier 6864eaef. Non-agouti Fuchs colourpoint is NOT a marten (Zobel) — it
|
||||
// derives a CP-fox base with the Dilute prefix.
|
||||
expect(name('aa c[chm]c[chm] dd ee[-] gg Pp Spsp')).toBe('Dilute CP-Blaufuchs Schecke')
|
||||
})
|
||||
|
||||
it('473dc345 / 5151ab20: Vance uw[d] (dense underwhite) parses (no crash) → Kohlfuchs', () => {
|
||||
// Tier f31eb1f9: aa Cc[chm] D- ee Uwuw[d] PP spsp. uw[d] is the G locus;
|
||||
// it must parse and NEVER render 'uw'.
|
||||
const g = fromDisplayString('aa Cc[chm] D- ee Uwuw[d] PP spsp')
|
||||
expect(g.G).toEqual(['G', 'g'])
|
||||
expect(toDisplayString(g)).not.toContain('uw')
|
||||
expect(genotypeToFarbschlag(g)).toBe('Kohlfuchs')
|
||||
})
|
||||
|
||||
it('Fuchsschimmel family never resolves to a pure Schimmel variety', () => {
|
||||
// Agouti het ef/e black-eyed → Algierfuchsschimmel; hom ef/ef → Orangeschimmel.
|
||||
expect(name('AA CC DD eef GG PP spsp')).toBe('Algierfuchsschimmel')
|
||||
expect(name('AA CC DD efef GG PP spsp')).toBe('Orangeschimmel')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: no phantom colours in the expected-litter list (3e643ef1/c8ce27e2/3c46d0b4/1e7b66e6)', () => {
|
||||
it('Mamta Mini (D-, Ee[-]) × Gold (D-, Ee): unknown D copies known D → no Dilute, no Unbekannt, no efef', () => {
|
||||
// Real litter 98bfdf92. Both parents carry D- (unknown D) and an unknown E
|
||||
// partner. The old uniform-spread invented dd / ef / 'Unbekannt' offspring.
|
||||
const father = fromDisplayString('AA CC D- Ee[-] Gg PP spsp') // Mamta Mini
|
||||
const mother = fromDisplayString('Aa CC D- Ee Gg pp spsp') // Gold
|
||||
const result = breed(father, mother)
|
||||
|
||||
const names = result.byFarbschlag.map((f) => f.farbschlag)
|
||||
expect(names).not.toContain('Unbekannter Farbschlag')
|
||||
expect(names.some((n) => n.startsWith('Dilute'))).toBe(false)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('e[f]'))).toBe(true)
|
||||
// Probabilities still sum to exactly 1.
|
||||
const sum = result.offspring.reduce((acc, o) => acc + o.probability.value, 0)
|
||||
expect(sum).toBeCloseTo(1, 10)
|
||||
// Only agouti vs silver-agouti can fall here (G locus segregates; everything else fixed).
|
||||
expect(new Set(names)).toEqual(new Set(['Agouti', 'Silberagouti']))
|
||||
})
|
||||
|
||||
it('D- × D- never yields a dd (dilute) offspring at all', () => {
|
||||
const p = fromDisplayString('AA CC D- EE GG PP spsp')
|
||||
const result = breed(p, p)
|
||||
expect(result.offspring.every((o) => !o.genotype.includes('dd'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-5: parent inference fills unknown alleles (cc9ea3fe / 1a508c04)', () => {
|
||||
it('Mamta Mini Ee[-] + homozygous ee father Geely → Ee', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] Gg PP spsp')
|
||||
const geely = fromDisplayString('aa CC DD ee gg PP spsp') // father: ee (hom fox)
|
||||
const res = inferUnknownsFromParents(child, geely, null)
|
||||
expect(res.genotype.E).toEqual(['E', 'e'])
|
||||
expect(toDisplayString(res.genotype)).toBe('AA CC DD Ee Gg PP spsp')
|
||||
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'father' }])
|
||||
})
|
||||
|
||||
it('falls back to the mother when only she is homozygous', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
|
||||
const father = fromDisplayString('AA CC DD Ee GG PP spsp') // het → no force
|
||||
const mother = fromDisplayString('aa CC DD ee GG PP spsp') // ee → forces e
|
||||
const res = inferUnknownsFromParents(child, father, mother)
|
||||
expect(res.genotype.E).toEqual(['E', 'e'])
|
||||
expect(res.inferred).toEqual([{ locus: 'E', allele: 'e', from: 'mother' }])
|
||||
})
|
||||
|
||||
it('leaves the genotype untouched when no parent is homozygous at the unknown locus', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee[-] GG PP spsp')
|
||||
const father = fromDisplayString('AA CC DD Ee GG PP spsp')
|
||||
const res = inferUnknownsFromParents(child, father, null)
|
||||
expect(res.inferred).toEqual([])
|
||||
expect(res.genotype.E).toEqual(child.E)
|
||||
})
|
||||
|
||||
it('no-op when there is nothing unknown', () => {
|
||||
const child = fromDisplayString('AA CC DD Ee GG PP spsp')
|
||||
const res = inferUnknownsFromParents(child, child, child)
|
||||
expect(res.inferred).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('GEN-6: C-Locus-Zygotie Platin / Saphir / Platin-Hell (bde4ec70 / f89e95ad)', () => {
|
||||
const name = (s: string) => genotypeToFarbschlag(fromDisplayString(s))
|
||||
|
||||
// Züchterin autoritativ (Ticket f89e95ad): „Saphir ist aa Cc[chm] D- E- G- pp und
|
||||
// Platin ist aa CC D- E- G- pp. Es ist nicht dasselbe!" Vorher trugen Platin,
|
||||
// Saphir und Platin-Hell identische Katalog-Tokens (C:'C') → first-match-wins
|
||||
// lieferte immer Platin.
|
||||
it('aa CC DD EE GG pp -> Platin (zwei volle C)', () => {
|
||||
expect(name('aa CC DD EE GG pp spsp rere')).toBe('Platin')
|
||||
expect(name('aa CC DD EE GG pp Spsp')).toBe('Platin Schecke')
|
||||
})
|
||||
|
||||
it('aa Cc[chm] DD EE GG pp -> Saphir (ein c[chm] daneben)', () => {
|
||||
expect(name('aa Cc[chm] DD EE GG pp spsp rere')).toBe('Saphir')
|
||||
})
|
||||
|
||||
it('JackJack (b6d8b3ef): aa Cc[chm] DD E- G- pp Spsp -> Saphir Schecke, NICHT Platin Schecke', () => {
|
||||
expect(name('aa Cc[chm] DD E- G- pp Spsp')).toBe('Saphir Schecke')
|
||||
})
|
||||
|
||||
it('aa Cc[h] DD EE GG pp -> Platin-Hell (GEN-3g: „-Hell" == c[h]-Allel)', () => {
|
||||
expect(name('aa Cc[h] DD EE GG pp spsp rere')).toBe('Platin-Hell')
|
||||
})
|
||||
|
||||
it('die drei Katalog-Einträge bilden ihren eigenen Namen zurück (kein Synonym-Kollaps)', () => {
|
||||
for (const varietyName of ['Platin', 'Saphir', 'Platin-Hell']) {
|
||||
const entry = BASE_COLORS.find((e) => e.name === varietyName)!
|
||||
expect(genotypeToFarbschlag(representativeGenotype(entry))).toBe(varietyName)
|
||||
}
|
||||
})
|
||||
|
||||
it('Seed-Kompaktnotation „aa Ccchm …" (so schreibt der Import JackSack) -> Saphir Schecke', () => {
|
||||
// merge_and_resolve übernimmt bei Farb-LABEL „Saphir-Sp" ohne Gencode den
|
||||
// CanonicalGenotype aus colorVarietySeed.backend.json (frozen symbols).
|
||||
// Seit GEN-6 ist das die HET-Form — vorher „aa CC …" (= Platin-Gencode).
|
||||
expect(name('aa Ccchm DD EE GG pp Spsp rere')).toBe('Saphir Schecke')
|
||||
expect(name('aa Cch DD EE GG pp spsp rere')).toBe('Platin-Hell')
|
||||
})
|
||||
|
||||
it('A-Wurf 18.02.2010 (Blacky × Kuke) erwartet Saphir UND Saphir Schecke', () => {
|
||||
const blacky = fromDisplayString('aa Cc[chm] DD EE GG Pp spsp') // Vater
|
||||
const kuke = fromDisplayString('aa CC DD Ee gg Pp Spsp') // Mutter (rpro3: Anthrazit-Schecke)
|
||||
const names = breed(blacky, kuke).byFarbschlag.map((f) => f.farbschlag)
|
||||
expect(names).toContain('Saphir')
|
||||
expect(names).toContain('Saphir Schecke')
|
||||
expect(names).not.toContain('Unbekannter Farbschlag')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatVarietyName: Modifier nicht doppeln', () => {
|
||||
it('haengt „Schecke" an einen Basisnamen an', () => {
|
||||
expect(formatVarietyName('Saphir', 'aa Cc[chm] DD E- G- pp Spsp')).toBe('Saphir Schecke')
|
||||
})
|
||||
|
||||
it('doppelt „Schecke" NICHT, wenn der Katalogname es schon traegt', () => {
|
||||
// Sonst „Schwarz Schecke Schecke" — und der Akten-Vergleich „eingetragen vs. errechnet"
|
||||
// meldete eine Abweichung, obwohl beides dasselbe ist.
|
||||
expect(formatVarietyName('Schwarz Schecke', 'aa CC DD EE GG PP Spsp rere')).toBe('Schwarz Schecke')
|
||||
})
|
||||
|
||||
it('ohne Gencode bleibt der Name unveraendert', () => {
|
||||
expect(formatVarietyName('Saphir', null)).toBe('Saphir')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schreibfehler-Alias c[hm] == c[chm] (Ticket 2322c2a8, Jay *24.10.2021)', () => {
|
||||
it('c[hm] parst als c[chm] und ergibt Zobel, nicht Zobel-Hell', () => {
|
||||
// Die Quell-Charts (Alberto Kids / Pukas Kids) schreiben „aa c[chm]c[hm] …" —
|
||||
// dem zweiten Allel fehlt das c. Ohne Alias war der Genotyp unparsebar bzw.
|
||||
// (im Python-Mirror) ein fremdes Allel → „Zobel-Hell".
|
||||
const g = fromDisplayString('aa c[chm]c[hm] Dd Ee gg P- spsp')
|
||||
expect(g.C).toEqual(['cchm', 'cchm'])
|
||||
expect(genotypeToFarbschlag(g)).toBe('Zobel')
|
||||
})
|
||||
|
||||
it('Cc[hm] bleibt ein c[chm]-Träger (relevant für die Probeverpaarung)', () => {
|
||||
expect(fromDisplayString('aa Cc[hm] DD EE GG pp spsp').C).toEqual(['C', 'cchm'])
|
||||
expect(genotypeToFarbschlag(fromDisplayString('aa Cc[hm] DD EE GG pp spsp'))).toBe('Saphir')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
|
||||
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
|
||||
*/
|
||||
import { LOCI, LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
|
||||
import { LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
|
||||
import {
|
||||
makeGenotype,
|
||||
resolveAllelePair,
|
||||
toDisplayString,
|
||||
fromDisplayString,
|
||||
wildType,
|
||||
WILDCARD,
|
||||
type AllelePair,
|
||||
type Genotype,
|
||||
} from './genotype'
|
||||
@@ -63,7 +64,7 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'Algierfuchs', english: 'Dark-Eyed Honey', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs.jpg' },
|
||||
{ name: 'Blau', english: 'Blue', tokens: { A: 'a', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'blau-schwarz-dd.JPG' },
|
||||
{ name: 'Gold', english: 'Argente Golden', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'gold.jpg' },
|
||||
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
|
||||
{ name: 'Platin', english: 'Lilac', tokens: { A: 'a', C: 'C/C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin.JPG' },
|
||||
{ name: 'Goldfuchs', english: 'Yellow Fox', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'goldfuchs.jpg' },
|
||||
{ name: 'Rotfuchs', english: 'Argente Nutmeg', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'p' }, image: 'rotfuchs.JPG' },
|
||||
{ name: 'Dilute Gold', english: 'dd Argente Golden', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'gold-dd.jpg' },
|
||||
@@ -77,12 +78,12 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'Elfenbein', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'g', P: 'p' }, image: 'elfenbein.jpg' },
|
||||
{ name: 'Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs.jpg' },
|
||||
{ name: 'Polarfuchs', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'g', P: 'P' }, image: 'polarfuchs.jpg' },
|
||||
{ name: 'Saphir', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
||||
{ name: 'Saphir', tokens: { A: 'a', C: 'C/cchm', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'saphir.jpg' },
|
||||
// GEN-3a: efef base (agouti, wild C/D/G/P) = Orangeschimmel (breeder C5).
|
||||
// GEN-4: A:'A' added — non-agouti ef animals fall through to Kohlfuchsschimmel etc.
|
||||
{ name: 'Orangeschimmel', tokens: { A: 'A', C: 'C', D: 'D', E: 'ef', G: 'G', P: 'P' }, image: 'schimmel-orangeschimmel.jpg' },
|
||||
{ name: 'Topas', tokens: { A: 'A', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'topas.jpg' },
|
||||
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
||||
{ name: 'Platin-Hell', tokens: { A: 'a', C: 'C/ch', D: 'D', E: 'E', G: 'G', P: 'p' }, image: 'platin-hell.jpg' },
|
||||
{ name: 'Dilute Agouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'P' }, image: 'agouti-dd.jpg' },
|
||||
{ name: 'Dilute Silberagouti', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'g', P: 'P' }, image: 'silberagouti-dd.jpg' },
|
||||
{ name: 'Dilute Kohlfuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-dd.jpg' },
|
||||
@@ -113,7 +114,11 @@ export const BASE_COLORS: readonly FarbschlagEntry[] = [
|
||||
{ name: 'Kohlfuchs-Hell', tokens: { A: 'a', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'kohlfuchs-hell-2.jpg' },
|
||||
{ name: 'Algierfuchs, hell', tokens: { A: 'A', C: 'C', D: 'D', E: 'e', G: 'G', P: 'P' }, image: 'algierfuchs-hell.JPG' },
|
||||
{ name: 'Dilute Topas', tokens: { A: 'A', C: 'C', D: 'd', E: 'E', G: 'G', P: 'p' }, image: 'topas-dd.jpg' },
|
||||
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'p' }, image: 'blaufuchs-dd.jpg' },
|
||||
// GEN-5 (ticket 3deab547): Blaufuchs is black-eyed (P, line 75); dilution dd is
|
||||
// independent of the eye-pigment P-locus, so the dilute form is ALSO P:'P'
|
||||
// (was P:'p', which made it an unreachable phantom and left dd CP-fox animals
|
||||
// 'Unbekannt'/'blau'). Now aa cchm dd ee gg P- → 'Dilute CP-Blaufuchs'.
|
||||
{ name: 'Dilute Blaufuchs', tokens: { A: 'a', C: 'C', D: 'd', E: 'e', G: 'g', P: 'P' }, image: 'blaufuchs-dd.jpg' },
|
||||
|
||||
// ── GEN-3f/3g: c^chm colourpoint varieties ──
|
||||
// GEN-3f: aa points = marten/sable group (Marder/Siam, +gg Zobel/Zobel-Hell).
|
||||
@@ -163,13 +168,10 @@ export interface FarbschlagMatch {
|
||||
* variety specifically.
|
||||
*/
|
||||
function locusToken(g: Genotype, locus: LocusKey): string {
|
||||
// Default an unknown allele to the WILD-TYPE reading: most-dominant for the
|
||||
// colour loci (unknown-C => full-colour 'C', not a white), but the recessive
|
||||
// UNMARKED allele for the spotting/rex markers (unknown-Sp must NOT imply Schecke).
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||
// GEN-5: an unknown allele is a COPY of the known partner (resolveAllelePair),
|
||||
// so e.g. [e,?] reads as ee (Fuchs), NOT e/E. Only a fully-unknown locus falls
|
||||
// back to the wild-type reading (most-dominant colour / unmarked marker).
|
||||
const [x, y] = resolveAllelePair(locus, g[locus])
|
||||
if (locus === 'E') {
|
||||
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
|
||||
// GEN-4: het ef/e → 'ef' (ef is dominant for the Schimmel phenotype;
|
||||
@@ -180,10 +182,33 @@ function locusToken(g: Genotype, locus: LocusKey): string {
|
||||
return dominantAllele(locus, x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* GEN-6 (Tickets bde4ec70 / f89e95ad): ein Katalog-Token darf ein EXAKTES,
|
||||
* ungeordnetes Allelpaar "x/y" verlangen statt des exprimierten (dominanten)
|
||||
* Allels. Nötig, weil sich Platin / Saphir / Platin-Hell NUR in der C-Zygotie
|
||||
* unterscheiden — die Züchterin autoritativ: "Saphir ist aa Cc[chm] D- E- G- pp
|
||||
* und Platin ist aa CC D- E- G- pp. Es ist nicht dasselbe!":
|
||||
* aa CC D- E- G- pp -> Platin
|
||||
* aa C/c[chm] D- E- G- pp -> Saphir
|
||||
* aa C/c[h] D- E- G- pp -> Platin-Hell (GEN-3g: "-Hell" == c[h]-Allel)
|
||||
* Vorher trugen die drei Einträge identische Tokens (C:'C'), weil locusToken()
|
||||
* den C-Locus auf das dominante Allel reduziert → "first match wins" lieferte
|
||||
* immer Platin, Saphir/Platin-Hell waren unerreichbar. Die "x/y"-Konvention
|
||||
* existierte schon für die het-Colourpoints (Siam/Zobel-Hell, C:'cchm/ch') und
|
||||
* wird von representativeGenotype() bereits verstanden (GEN-3f) — deshalb bleibt
|
||||
* die Katalog-REIHENFOLGE (und damit sortOrder + ColorVariety-GUIDs) unberührt.
|
||||
*/
|
||||
function pairMatches(g: Genotype, locus: LocusKey, token: string): boolean {
|
||||
const [a, b] = resolvedPair(g, locus)
|
||||
const [x, y] = token.split('/') as [string, string]
|
||||
return (a === x && b === y) || (a === y && b === x)
|
||||
}
|
||||
|
||||
function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
||||
return (Object.keys(entry.tokens) as LocusKey[]).every(
|
||||
(locus) => locusToken(g, locus) === entry.tokens[locus],
|
||||
)
|
||||
return (Object.keys(entry.tokens) as LocusKey[]).every((locus) => {
|
||||
const token = entry.tokens[locus]!
|
||||
return token.includes('/') ? pairMatches(g, locus, token) : locusToken(g, locus) === token
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,29 +220,50 @@ function matches(g: Genotype, entry: FarbschlagEntry): boolean {
|
||||
* computed farbschlag output (the farbschlagFor category guard blocks them).
|
||||
*/
|
||||
function eFamily(g: Genotype): string | null {
|
||||
const [x, y] = g.E
|
||||
// GEN-5: resolve unknown E as a copy of the known allele first ([e,?]→ee Fuchs,
|
||||
// [ef,?]→ef/ef Schimmel, [E,?]→EE full), so families are decided consistently.
|
||||
const [x, y] = resolveAllelePair('E', g.E)
|
||||
if (x === 'e' && y === 'e') return 'Fuchs'
|
||||
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
|
||||
if (x === 'ef' && y === 'ef') return 'Schimmel'
|
||||
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
|
||||
/** Resolve an allele pair to concrete alleles, defaulting unknown to wild-type. */
|
||||
/**
|
||||
* Resolve an allele pair to concrete alleles. GEN-5: an unknown allele copies the
|
||||
* known partner (resolveAllelePair); a fully-unknown locus falls back to wild-type.
|
||||
*/
|
||||
function resolvedPair(g: Genotype, locus: LocusKey): [string, string] {
|
||||
const alleles = LOCI[locus].alleles
|
||||
const isMarker = locus === 'Sp' || locus === 'Re' || locus === 'Sls'
|
||||
const fallback = isMarker ? alleles[alleles.length - 1] : alleles[0]
|
||||
const [x, y] = g[locus].map((a) => (a === WILDCARD ? fallback : a))
|
||||
return [x, y]
|
||||
return resolveAllelePair(locus, g[locus])
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a catalog entry belongs to the given E-family by NAME. The Schimmel
|
||||
* entries split into two breeder groups that share the same E:'ef' token but
|
||||
* differ by zygosity of the live animal:
|
||||
* - 'Fuchsschimmel' family (ef/e het) → only *fuchsschimmel entries
|
||||
* (Goldfuchsschimmel, Kohlfuchsschimmel, …).
|
||||
* - 'Schimmel' family (ef/ef hom) → the pure roan entries whose name ends
|
||||
* in 'schimmel' but NOT 'fuchsschimmel' (Rotaugenschimmel, Orangeschimmel,
|
||||
* Silberschimmel, …).
|
||||
* GEN-5 (ticket 5826e8e2): this is why ef/e must NOT match a pure-Schimmel entry
|
||||
* (Rotaugenschimmel) — a het Fuchsschimmel animal is a Goldfuchsschimmel.
|
||||
*/
|
||||
function entryInEFamily(entry: FarbschlagEntry, family: string): boolean {
|
||||
if (entry.tokens.E === undefined) return false
|
||||
const n = entry.name.toLowerCase()
|
||||
if (family === 'Fuchsschimmel') return n.includes('fuchsschimmel')
|
||||
if (family === 'Schimmel') return n.includes('schimmel') && !n.includes('fuchsschimmel')
|
||||
// 'Fuchs' family: fox entries are E:'e' (no 'schimmel' in the name).
|
||||
return !n.includes('schimmel')
|
||||
}
|
||||
|
||||
/** Base colour name (no modifiers, no colourpoint prefix), via E-family + matches. */
|
||||
function baseColourFor(g: Genotype): string | null {
|
||||
const family = eFamily(g)
|
||||
const base = family
|
||||
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
|
||||
? (BASE_COLORS.find((e) => entryInEFamily(e, family) && matches(g, e)) ?? null)
|
||||
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
|
||||
// GEN-4: never fall back to the family name — Fuchs/Fuchsschimmel/Schimmel are
|
||||
// Farbarten (categories), not concrete Farbschläge. If no catalog entry matches,
|
||||
@@ -242,32 +288,20 @@ function colourpointName(g: Genotype): string | null {
|
||||
// Remaining: cchm/cchm or cchm/ch (colourpoint, no full C, not chch).
|
||||
const bothCchm = c[0] === 'cchm' && c[1] === 'cchm'
|
||||
const agouti = resolvedPair(g, 'A').includes('A')
|
||||
if (!agouti) {
|
||||
// #3: the aa colourpoint branch must respect D (dilute) and E (Fuchs/Schimmel)
|
||||
// instead of hard-coding Marder/Siam/Zobel. The frozen breeder names
|
||||
// Marder/Siam/Zobel/Zobel-Hell only describe the wild D + full-extension case
|
||||
// (aa cchm DD EE [gg]); they are kept for that case. Any non-wild D or E (e.g.
|
||||
// dd dilute or ee Fuchs) is named from the resolved base colour, so
|
||||
// 'aa cchm dd ee gg' no longer collapses to Zobel.
|
||||
const [d1, d2] = resolvedPair(g, 'D')
|
||||
const wildD = d1 === 'D' && d2 === 'D'
|
||||
const fullExtension = eFamily(g) === null // E expresses full 'E' (not Fuchs/Schimmel)
|
||||
if (wildD && fullExtension) {
|
||||
const [g1, g2] = resolvedPair(g, 'G')
|
||||
const grey = g1 === 'g' && g2 === 'g'
|
||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||
return bothCchm ? 'Marder' : 'Siam'
|
||||
}
|
||||
// dilute and/or Fuchs/Schimmel aa colourpoint → derive from the base colour.
|
||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||
if (!base) return null
|
||||
const DILUTE = 'Dilute '
|
||||
if (base.startsWith(DILUTE)) {
|
||||
return `${DILUTE}CP-${base.slice(DILUTE.length)}${bothCchm ? '' : '-Hell'}`
|
||||
}
|
||||
return `CP-${base}${bothCchm ? '' : '-Hell'}`
|
||||
// GEN-5 (tickets 3deab547 / efa2b232): the aa marten names (Marder/Siam/Zobel/
|
||||
// Zobel-Hell) are FULL-EXTENSION (E) sable varieties only. A non-agouti
|
||||
// colourpoint that is Fuchs (ee) or Schimmel (ef) is NOT a Marder/Zobel — it
|
||||
// must derive its base generically like the A- branch, so e.g.
|
||||
// aa cchm dd ee gg → 'Dilute CP-Polarfuchs' (dilute + fox + grey), never Zobel.
|
||||
if (!agouti && eFamily(g) === null) {
|
||||
const [g1, g2] = resolvedPair(g, 'G')
|
||||
const grey = g1 === 'g' && g2 === 'g'
|
||||
if (grey) return bothCchm ? 'Zobel' : 'Zobel-Hell'
|
||||
return bothCchm ? 'Marder' : 'Siam'
|
||||
}
|
||||
// A- colourpoint: base as if C were full; het (cchm/ch) -> '-Hell' suffix.
|
||||
// Colourpoint base derivation: name the colour as if C were full, then prefix
|
||||
// 'CP-'; het (cchm/ch) gets the '-Hell' suffix. Used by A- and by non-agouti
|
||||
// Fuchs/Schimmel colourpoints (which have no dedicated marten name).
|
||||
const base = baseColourFor(makeGenotype({ ...g, C: ['C', 'C'] }))
|
||||
if (!base) return null
|
||||
// GEN-4: if base is a Dilute variety, prefix ordering is 'Dilute CP-X' not 'CP-Dilute X'.
|
||||
@@ -325,6 +359,25 @@ export function genotypeToFarbschlag(g: Genotype): string {
|
||||
return farbschlagFor(g).name
|
||||
}
|
||||
|
||||
export function formatVarietyName(baseName: string, genotypeStr: string | null | undefined): string {
|
||||
if (!baseName) return ''
|
||||
if (!genotypeStr?.trim()) return baseName
|
||||
try {
|
||||
const g = fromDisplayString(genotypeStr)
|
||||
const modifiers: string[] = []
|
||||
// Einen Modifier NICHT doppeln, wenn der Katalogname ihn schon traegt: Bestaende mit
|
||||
// Namen wie „Schwarz Schecke" ergaeben sonst „Schwarz Schecke Schecke" — und der
|
||||
// Vergleich mit dem errechneten Farbschlag meldet dann eine Abweichung, die es nicht gibt.
|
||||
const words = baseName.toLowerCase().split(/[\s-]+/)
|
||||
const alreadyNamed = (modifier: string) => words.includes(modifier.toLowerCase())
|
||||
if (locusToken(g, 'Sp') === 'Sp' && !alreadyNamed('Schecke')) modifiers.push('Schecke')
|
||||
if (locusToken(g, 'Re') === 'Re' && !alreadyNamed('Rex')) modifiers.push('Rex')
|
||||
return [baseName, ...modifiers].join(' ')
|
||||
} catch {
|
||||
return baseName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A representative full genotype for a catalog entry: each specified locus is
|
||||
* homozygous for its token allele; unspecified loci take the wild-type allele.
|
||||
@@ -333,12 +386,22 @@ export function genotypeToFarbschlag(g: Genotype): string {
|
||||
export function representativeGenotype(entry: FarbschlagEntry): Genotype {
|
||||
const base = wildType()
|
||||
const out = {} as Record<LocusKey, AllelePair>
|
||||
// GEN-5 (ticket 5826e8e2): a *Fuchsschimmel variety is the HETEROZYGOUS ef/e
|
||||
// animal (a Schimmel-modified Fox), whereas the pure *schimmel varieties
|
||||
// (Rotaugen-/Orange-/Silberschimmel) are HOMOZYGOUS ef/ef. The E token is the
|
||||
// shared phenotype letter 'ef'; the representative genotype must encode the
|
||||
// right zygosity so each entry round-trips back to its own family.
|
||||
const isFuchsschimmel = entry.name.toLowerCase().includes('fuchsschimmel')
|
||||
for (const locus of LOCUS_ORDER) {
|
||||
const token = entry.tokens[locus]
|
||||
if (!token) {
|
||||
out[locus] = base[locus]
|
||||
continue
|
||||
}
|
||||
if (locus === 'E' && token === 'ef' && isFuchsschimmel) {
|
||||
out[locus] = ['ef', 'e'] // het Fuchsschimmel (ef/e), not hom ef/ef
|
||||
continue
|
||||
}
|
||||
// GEN-3f: a token may encode a HETEROZYGOUS pair as "x/y" (e.g. the het
|
||||
// colourpoints Siam/Zobel-Hell use C: 'cchm/ch'); otherwise it's homozygous.
|
||||
const [a, b] = token.includes('/') ? (token.split('/') as [string, string]) : [token, token]
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Saphir",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa Ccchm DD EE GG pp spsp rere",
|
||||
"sortOrder": 24,
|
||||
"image": "saphir.jpg"
|
||||
},
|
||||
@@ -180,7 +180,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Platin-Hell",
|
||||
"canonicalGenotype": "aa CC DD EE GG pp spsp rere",
|
||||
"canonicalGenotype": "aa Cch DD EE GG pp spsp rere",
|
||||
"sortOrder": 27,
|
||||
"image": "platin-hell.jpg"
|
||||
},
|
||||
@@ -236,25 +236,25 @@
|
||||
},
|
||||
{
|
||||
"name": "Polarfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD efe gg PP spsp rere",
|
||||
"sortOrder": 37,
|
||||
"image": "polarfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Algierfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "AA CC DD efe GG PP spsp rere",
|
||||
"sortOrder": 38,
|
||||
"image": "algierfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||
"sortOrder": 39,
|
||||
"image": "kohlfuchsschimmel.jpg"
|
||||
},
|
||||
{
|
||||
"name": "Blaufuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef gg PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe gg PP spsp rere",
|
||||
"sortOrder": 40,
|
||||
"image": "blaufuchsschimmel.jpg"
|
||||
},
|
||||
@@ -272,7 +272,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Goldfuchsschimmel",
|
||||
"canonicalGenotype": "AA CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "AA CC DD efe GG pp spsp rere",
|
||||
"sortOrder": 43,
|
||||
"image": "goldfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -290,7 +290,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Rotfuchsschimmel",
|
||||
"canonicalGenotype": "aa CC DD efef GG pp spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe GG pp spsp rere",
|
||||
"sortOrder": 46,
|
||||
"image": "rotfuchsschimmel.jpg"
|
||||
},
|
||||
@@ -302,7 +302,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Kohlfuchsschimmel, hell",
|
||||
"canonicalGenotype": "aa CC DD efef GG PP spsp rere",
|
||||
"canonicalGenotype": "aa CC DD efe GG PP spsp rere",
|
||||
"sortOrder": 48,
|
||||
"image": "kohlfuchsschimmel-hell.jpg"
|
||||
},
|
||||
@@ -332,7 +332,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Dilute Blaufuchs",
|
||||
"canonicalGenotype": "aa CC dd ee gg pp spsp rere",
|
||||
"canonicalGenotype": "aa CC dd ee gg PP spsp rere",
|
||||
"sortOrder": 53,
|
||||
"image": "blaufuchs-dd.jpg"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user