Compare commits

...

15 Commits

Author SHA1 Message Date
45c3f87f01 fix(akte): falscher Hinweis „Weicht vom eingetragenen Farbschlag ab"
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m16s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m38s
CI / Docker Build & Push (push) Successful in 1m32s
CI / Deploy auf TrueNAS (Custom App) (push) Successful in 9s
Beim Prod-Nachtest aufgefallen: Bei Schecken meldete die Akte eine Abweichung,
obwohl eingetragener und errechneter Farbschlag identisch angezeigt wurden
(JackJack: „Saphir Schecke" vs. „Saphir Schecke"). Zwei Ursachen:

- Der Vergleich schnitt „Schecke"/„Rex" nur auf der ERRECHNETEN Seite ab, nicht
  auf der eingetragenen (die den Zusatz über formatVarietyName trägt). Jetzt
  werden beide Seiten gleich normalisiert.
- formatVarietyName hängte den Modifier auch an, wenn der Katalogname ihn schon
  trägt („Schwarz Schecke" → „Schwarz Schecke Schecke").

Tests: 3 vitest-Fälle zu formatVarietyName, e2e SCHECKE-MISMATCH mit Gegenprobe
(Mock-Tier mit echter Abweichung zeigt den Hinweis weiter).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:22:59 +02:00
748ce4733f docs: Dauerwissen aus dem Ticket-Lauf + Ergebnis/Folgeaufgaben
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m12s
CI / Docker Build & Push (push) Has been cancelled
CI / Deploy auf TrueNAS (Custom App) (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
CLAUDE.md: C-Locus-Zygotie (Platin/Saphir/Platin-Hell, Paar-Token "x/y"),
c[hm]-Alias, die neuen Resolution-Schlüssel goHomeDate/spottingType und die
Präzedenz des genotype-Overrides, "pro Tier nur EINE Resolution" (inkl. der
Falle, dass Wurfchronik-Jungtiere zur Override-Zeit kein DOB haben), der
renameTo-Notiz-Sweep mit seinen Guards, der parse_date-Jahresguard und die
sternlosen Geburtsdaten in Stammbaum-Charts.

docs/ticket-triage.md: Ergebnis des Laufs (16/16 Resolved, 27/27 Prod-Checks)
und die acht bewusst offen gelassenen Folgeaufgaben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:16:00 +02:00
5a6d1c2541 fix(tickets): 16 Tickets der Züchterin — Genetik, Importer, Daten, Akte
All checks were successful
CI / Frontend Tests (Node/Vite) (push) Successful in 9m38s
CI / Backend Tests (.NET) (push) Successful in 1m14s
CI / Docker Build & Push (push) Successful in 1m33s
CI / Deploy auf TrueNAS (Custom App) (push) Successful in 8s
Genetik-Engine (bde4ec70, f89e95ad, 2322c2a8):
- Saphir/Platin/Platin-Hell unterscheiden sich NUR in der C-Zygotie und waren im
  Katalog identisch (locusToken reduziert auf das dominante Allel) → Saphir war
  unerreichbar. Token darf jetzt ein exaktes, ungeordnetes Allelpaar "x/y"
  verlangen: Platin C/C, Saphir C/cchm, Platin-Hell C/ch. TS- und Python-Mirror
  identisch, Seeds regeneriert, Migration ReseedColorVarietiesGen6Saphir.
- c[hm] wird als Alias auf c[chm] normalisiert (Tippfehler in zwei Charts) —
  vorher war Jays Gencode unparsebar und ergab "Zobel-Hell".
- 6 Tiere wechseln den errechneten Farbschlag (5x Platin→Saphir, Jay→Zobel).

Importer-Logik:
- renameTo-Notiz-Sweep: der alte Name blieb in Wurf-Notizen stehen ("Blacky +
  Kruke") — jetzt wortgenau ersetzt, mit vier Guards gegen Kollateralschaden.
- Neuer Resolution-Schlüssel goHomeDate (autoritativ, zieht GivenAway nach) und
  spottingType; Gencode-Overrides gewinnen jetzt bei präzisem Match (vorher
  wurden Entscheidungen stumm verschluckt, z. B. Eliza und Chris).
- parse_date verwirft implausible Jahre (JackJack hatte 1310-05-13).
- extract.py erkennt Stammbaum-Blöcke auch ohne Stern vor dem Geburtsdatum
  (Bijou bekam dadurch den Nachbar-Ast als Mutter).
- Backend-Ingest überträgt SpottingType (nur bei Payload-Wert).

Daten (conflict-decisions.json, re-ingest-stabil): Kuke-Merge + Zuchtname,
Jamie- und Sakura-Dubletten, Phantom-Tier "Unbekannt", Eltern von Kathlin,
Fast Boy/Ziwa und dem Q4-/TS-Wurf, Eliza-Gencode, Merle-Abgabe, JackJack.

Frontend (24522f5f): Abgabedatum steht in der Tier-Akte jetzt direkt unter dem
Abnehmer und hängt nicht mehr am Status.

Tests: +25 Checks test_merge_resolve, +7 test_genotype, +8 genetics.test.ts,
neuer Extract- und Ingest-Test, e2e GOHOME-ROW. Alles grün.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:32:28 +02:00
bf074696b4 docs(triage): Triage-Lauf 2026-08-19 — 16 offene Tickets in 13 Buendeln
Je Bundel ein Triage-Agent gegen Prod-API + lokale Quellen (Stammbaum-xlsx,
Wurfchronik, _rpro3.db, resolved_import.json). Ergebnis: alle 13 umsetzbar,
keine Rueckfrage noetig. Uebergreifend: Prod ist auf dem aktuellen
Importer-Stand, die beklagten Dubletten stecken schon in resolved_import.json
— die Juli-Entscheidungen greifen nach dem Chart-Import vom 2026-08-18 nicht
mehr (Wurfchronik-Elternstubs ohne Geburtsdatum, one-decision-per-animal).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 23:01:27 +02:00
fdad1633cd fix(akte): Komma-Trenner in Listen + Elternnamen in der Wurf-Liste
- Zuchtpartner/Nachzucht: .ak-kv-value ist ein Flex-Container mit wrap, jeder
  Eintrag ein eigenes Item. Das vorangestellte „, " landete beim Umbruch am
  Zeilenanfang („, Zora"). Das Komma klebt jetzt hinten am vorherigen Namen.
  Regressionstest KV-COMMA (schlaegt gegen den alten Code fehl).
- Wurf-Liste/Zuchtpaare: Elternnamen wurden nur aus den ersten 1000 Tieren
  aufgeloest (Prod hat >2400) → Eltern dahinter erschienen als „Unbekannt".
  Jetzt werden alle Tiere geladen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:42:59 +02:00
54e873c115 feat(tiere): Eltern direkt in der Tier-Akte bearbeiten (QOL)
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m15s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m37s
CI / Docker Build & Push (push) Successful in 7m36s
CI / Deploy auf TrueNAS (Custom App) (push) Successful in 8s
Im Tier-Formular gibt es jetzt den Block „Abstammung" mit Wurf-Auswahl und
Vater-/Mutter-Picker — die Eltern müssen nicht mehr über die Wurf-Seite
gesucht und dort editiert werden. Das Datenmodell bleibt unverändert: Eltern
hängen weiterhin am Geburtswurf.

- Backend: PUT /gerbils/{id}/parents schreibt Litter.FatherId/MotherId des
  Geburtswurfs. Ohne Wurf wird ein bestehender mit gleichem Elternpaar +
  gleichem Datum verknüpft, sonst ein Träger-Wurf angelegt
  ("Wurf von X + Y", ShowInChronicle=false, IsManual=true).
  Geschlechts-Regel wiederverwendet LitterEndpoints.ValidateParents,
  Selbstbezug (Tier als eigener Elternteil) wird abgewiesen.
- UI: Vorbelegung aus dem gewählten Wurf, Warnung mit Anzahl der Geschwister
  (Eltern gehören dem Wurf → Änderung gilt für alle), Hinweis wenn ein
  Wurf-Eintrag angelegt wird. Texte in de.ts.
- Nebenbei: Speichern nutzt im Edit-Modus die Route-Id (PUT /gerbils/{id}
  antwortet 204 ohne Body) und der vorher schon rote Spec-Locator
  „Würfe als Elternteil" ist auf den Abschnitt eingegrenzt.
- Tests: GerbilParentsTests (9), e2e tiere.spec (2 neu) + Mock-Route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 20:43:28 +02:00
7a2feac04d fix(ci): Deploy-Job repariert — kein rsync im Runner, Transfer per tar over ssh
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m17s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m37s
CI / Docker Build & Push (push) Successful in 4m32s
CI / Deploy auf TrueNAS (Custom App) (push) Successful in 9s
Der Deploy-Job war seit seiner Einführung rot (Runs 99–107). Zwei Ursachen:

1. Die Repo-Secrets NAS_SSH_HOST/NAS_SSH_KEY waren in Gitea nie angelegt, der
   Job baute also "ssh root@" mit leerer Key-Datei -> "Could not resolve
   hostname". Secrets sind jetzt gesetzt (neuer Deploy-Key
   gitea-ci-deploy@gerbilmanager, Pubkey in /root/.ssh/authorized_keys auf der
   NAS) — das ist Server-Konfiguration, hier ändert sich dadurch nichts.
2. Danach scheiterte der Transfer an "rsync: command not found": der Guard
   prüft nur `ssh`, und ssh ist im Runner-Image vorhanden, also lief der
   apt-get-Zweig nie und rsync fehlte. Der Transfer nutzt jetzt `tar | ssh`
   (tar existiert auf beiden Seiten, `-xof` erhält die Modi, die Deploy-Skripte
   bleiben ausführbar); rsync ist aus dem apt-Aufruf entfernt.

Transfer und truenas-deploy.sh vorab per SSH gegen die NAS verifiziert: Dateien
identisch übertragen, Redeploy erfolgreich, /api/health grün, Daten unverändert
(2451 Tiere, 965 Würfe). CLAUDE.md hält beide Stolperfallen fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 00:13:01 +02:00
0b99cfd2bd feat(import): neue Stammbäume (12 Charts) + litterChildren.add + EMF-Fotos überspringen
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m16s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m39s
CI / Docker Build & Push (push) Successful in 14m0s
CI / Deploy auf TrueNAS (Custom App) (push) Failing after 3s
Die Züchterin hat 24 neue/aktualisierte Stammbaum-xlsx geliefert (Ordner
"neuestammbäume"); sie liegen jetzt im kanonischen Quellverzeichnis
Sttammbäume (12 neue Charts, 7 aktualisierte, 3 identisch, das inhaltsgleiche
"Picus Son (2)" ausgelassen). Prod ist per Upload-Ingest aktualisiert:
2372 -> 2451 Tiere, 916 -> 965 Würfe, 432 -> 507 Fotos, 2198 -> 2275 Tiere
mit Geburtsdatum. Overrides/verified-Zeilen, manuelle Tiere und Tickets
haben den Ingest unverändert überlebt.

Zwei Datenfehler, die die neuen Charts aufgedeckt haben — datengetrieben und
re-ingest-stabil gefixt statt an der globalen Heuristik zu drehen:

- litterChildren kennt jetzt `add` [Name | {name, dob}] als Gegenstück zu
  `keep`: hängt ein Jungtier an DIESEN Wurf und entfernt den alten Wurf, wenn
  er dadurch kinderlos UND virtuell ist. Nötig, weil "Pukas Kids" Akanes
  Eltern komplett UNTER ihren Block setzt (N80 Roni = Vater, N81 Fumi =
  Mutter) — _reconstruct_parents griff eine Zeile zu hoch, paarte Irish
  Coffee (Bonapartes Mutter) mit Roni und riss Akane aus dem Z21-Wurf in
  einen Phantom-Wurf, der in der Wurfchronik auftauchte (Ticket 88389f8e).
- Merle: durch das neue Geburtsdatum (18.06.2023) mergt der addAnimals-Stub
  in den Chart-Datensatz und verliert dabei sein isResident -> expliziter
  resolutions-Override (Ticket 36a3fcde/a8f11ac0, Züchterin: Zuchttier).

Außerdem: Excel legt neben Fotos teils EMF/WMF-Vektorvorschauen ab, die
Browser nicht darstellen können (kaputte Bildkachel in der Tier-Akte) ->
extract._attach_photos überspringt .emf/.wmf (5 Fotos betroffen).

Regressionstests für alle drei Punkte; alle Python-Suites grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:27:18 +02:00
1cb9e52117 fix(frontend): nginx client_max_body_size 50m für große /api-Uploads
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m9s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m34s
CI / Docker Build & Push (push) Successful in 1m25s
CI / Deploy auf TrueNAS (Custom App) (push) Failing after 3s
Der Reverse-Proxy des Frontend-Containers hatte kein client_max_body_size (Default 1 MB)
→ 413 bei größeren Uploads (RennmausPro-Backups, Foto-Anhänge, und v. a.
resolved_import.json ~6,6 MB für /api/import/ingest-resolved/upload). Limit auf 50m
angehoben, damit der Prod-Re-Ingest per HTTP-Upload ohne SSH funktioniert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:02:53 +02:00
2e7911074f feat(triage): Ticket-Fixes (Daten + Code) + prod-fähige Triage
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m36s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m28s
CI / Deploy auf TrueNAS (Custom App) (push) Failing after 3s
Daten-Fixes (conflict-decisions.json, re-ingest-stabil) für ~30 Tickets:
Merges (Jamie/Hiro/Mino/Jana/Blacky/Sakura/Malou/Socke→Marty), Eltern-Korrekturen
(Jacky/Idefix/Ichika/Roni/Ethan), Kruke→Kuke (+ Todesdatum), Targa-Wurf R14 + Druna,
Stacy/Merle/Domi/Eliza; Joghurt-Phantomwurf entfernt.

Code-Fixes:
- Gaida & alle Verstorbenen: Status wird aus Todesdatum/Abgabe abgeleitet
  (Program.cs Startup-Sweep heilt Altfälle; IngestResolved re-derived nach Freeze).
- CoCo: Scheckungsart wird bei jeder Schecke angezeigt (Platzhalter wenn leer).
- M-Wurf/Gale: über-gemergte Fremdtiere via neuem litterChildren-Override entfernt.
- renameTo eltern-verknüpfungssicher (Quell-Name im Index); dateOfDeath als Override.

Prod-fähige Triage (API):
- GET /feedback/{id} + GET /feedback?status= (kein 2-MB-Dump).
- POST /import/ingest-resolved/upload (multipart) → Ingest gegen Prod ohne SSH.

Tests: 280 Backend, 149 Frontend, alle Python, betroffene Playwright grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:41:43 +02:00
0a4bcabd98 feat(deploy): publicsite-Container intern (Port 8081) + app.update im Deploy-Skript
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m9s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m34s
CI / Docker Build & Push (push) Successful in 1m29s
CI / Deploy auf TrueNAS (Custom App) (push) Failing after 3s
- custom-app.compose.yaml: publicsite (nginx) ergaenzt, LAN-only, statisches HTML aus
  publicsite-Volume (POST /api/publish); PUBLICSITE_PORT-Platzhalter
- truenas-deploy.sh: bei existierender App app.update (statt nur redeploy), damit
  Compose-Aenderungen (neue Dienste) uebernommen werden
- CLAUDE.md: 5. Dienst dokumentiert

Verifiziert: publicsite Up auf 8081, publish -> HTTP 200 (inkl. abgabetiere-Seite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 14:24:54 +02:00
1484d04917 docs(claude): Produktion/Deployment-Sektion (TrueNAS Custom-App, wie deployen)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m13s
CI / Docker Build & Push (push) Has been cancelled
CI / Deploy auf TrueNAS (Custom App) (push) Has been cancelled
CI / Frontend Tests (Node/Vite) (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 14:19:25 +02:00
854da1a000 fix(deploy): postgres:18-Mount /var/lib/postgresql + backup ohne cron
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m8s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m28s
CI / Deploy auf TrueNAS (Custom App) (push) Failing after 3s
- db-Volume auf /var/lib/postgresql (v18 legt PGDATA in .../18/docker; Mount auf
  .../data laesst v18 nicht starten -> db unhealthy -> App-Rollback)
- Backup-Sidecar: Shell-Scheduler statt Alpine-crond (/etc/crontabs fehlt im
  Debian-postgres:18-Image -> Crash-Loop)

Verifiziert auf TrueNAS: Custom-App gerbilmanager RUNNING, 2370 Tiere, Port 8090.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 10:13:01 +02:00
60214a7c92 fix(web): zwei tsc-Buildfehler (useMutation-Rueckgabe void, Uint8Array<ArrayBuffer>)
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m18s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m46s
CI / Deploy auf TrueNAS (Custom App) (push) Failing after 4s
Blockierten den CI-Frontend-Build (npm run build) und damit build-and-push.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:47:03 +02:00
45673bb066 fix(deploy): App-Home auf Pool statt read-only /opt (TrueNAS Goldeye)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:25:26 +02:00
49 changed files with 5311 additions and 750 deletions

View File

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

View File

@@ -142,9 +142,10 @@ jobs:
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 nach /opt/gerbilmanager kopieren und dort
# 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)
@@ -152,12 +153,18 @@ jobs:
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: |
command -v ssh >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends openssh-client rsync)
# 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
@@ -167,16 +174,15 @@ jobs:
run: |
NAS="root@${{ secrets.NAS_SSH_HOST }}"
SSH="ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=accept-new"
$SSH "$NAS" 'mkdir -p /opt/gerbilmanager/deploy/truenas/scripts'
rsync -az -e "$SSH" \
deploy/truenas/custom-app.compose.yaml \
"$NAS":/opt/gerbilmanager/deploy/truenas/
rsync -az -e "$SSH" \
deploy/truenas/scripts/ \
"$NAS":/opt/gerbilmanager/deploy/truenas/scripts/
$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 /opt/gerbilmanager/deploy/truenas/scripts/truenas-deploy.sh"
"sh $APP_HOME/deploy/truenas/scripts/truenas-deploy.sh"

138
CLAUDE.md
View File

@@ -32,6 +32,62 @@ 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 99107 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
@@ -60,7 +116,15 @@ python tools/import/extract_contracts.py
python tools/import/merge_and_resolve.py
# 4. In die laufende DB laden (API muss laufen): UPSERT + Freeze
curl -X POST http://localhost:5179/import/ingest-resolved
# 4b. Gegen PROD (ohne SSH): resolved_import.json per HTTP hochladen + ingesten
curl -X POST http://truenas:8090/api/import/ingest-resolved/upload \
-F "resolved=@tools/import/output/resolved_import.json" # [-F "photos=@neue_fotos.zip"] nur bei neuen Fotos
```
- **`POST /import/ingest-resolved/upload`** (multipart `resolved` + optional `photos`-ZIP): staged die
hochgeladene Datei serverseitig in `Import:SourcePath` und ruft denselben UPSERT-Ingest — damit läuft
der Import **gegen Prod ohne SSH/`docker cp`**. Der alte `POST /import/ingest-resolved` (liest die
serverseitige Datei) bleibt unverändert. **DB-Copy vom lokalen Stand ist verboten**, sobald Prod
kuratiert wurde — nur der Upsert-Ingest bewahrt manuelle/verified/feedback-Zeilen.
- **`IngestResolvedService` (seit 2026-07-12: UPSERT statt Total-Wipe).** Importierte Zeilen
(Gerbils/Litters/Contacts) werden **per deterministischer Id gematcht und in place
@@ -152,6 +216,16 @@ python test_extract.py test_extract_docx.py test_genotype.py test_merge_resol
`gerbil-manager-web/src/genetics`). Unbekanntes Allel = `-` (nicht `?`).
E-Locus: `ee`=Fuchs, `eef`=Fuchsschimmel, `efef`=Schimmel. Rezessiver Fuchs ist
zwingend homozygot → `e-` ist ungültig (wirft), `ee[-]`→`ee`.
- **C-Locus-Zygotie (GEN-6, Tickets bde4ec70/f89e95ad — Regel der Züchterin):** Platin,
Saphir und Platin-Hell unterscheiden sich **nur** in der C-Zygotie: `aa CC …pp` = **Platin**,
`aa Cc[chm] …pp` = **Saphir**, `aa Cc[h] …pp` = **Platin-Hell**. Der Katalog-Match kann
deshalb pro Locus ein **exaktes, ungeordnetes Allelpaar** verlangen — Token-Form `"x/y"`
(z. B. `C: 'C/cchm'`), sonst reduziert `locusToken()` auf das dominante Allel und der erste
Treffer (Platin) verschluckt die anderen. Gilt in **beiden** Mirrors (`catalog.ts` +
`genotype.py`) und im Seed (`colorVarietySeed.*`, `ApplicationContext.SeedColorVarieties`,
dort in Kompaktnotation `Ccchm`/`Cch` ohne Klammern).
- **`c[hm]` ist ein Schreibfehler-Alias für `c[chm]`** (fehlendes c in einigen Stammbaum-Charts):
beide Parser normalisieren ihn, sonst ist der Gencode unparsebar bzw. ergibt „…-Hell".
- **Genetik-Engine = korrektheitskritisch: IMMER vollständig mit Tests absichern.**
Jede Änderung an `src/genetics/**` (+ Backend-Mirror + `genotype.py`) braucht pro
Use-Case/Ticket einen Regressionstest (`src/genetics/__tests__/genetics.test.ts`
@@ -222,6 +296,21 @@ python test_extract.py test_extract_docx.py test_genotype.py test_merge_resol
(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
@@ -234,6 +323,55 @@ python test_extract.py test_extract_docx.py test_genotype.py test_merge_resol
(`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"

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
using GerbilManagerWebAPI.Models;
using GerbilManagerWebAPI.Models;
namespace GerbilManagerWebAPI.Dtos
{
@@ -120,6 +120,17 @@ 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,

View File

@@ -55,12 +55,21 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
});
group.MapGet("/", async (ApplicationContext db) =>
group.MapGet("/", async (ApplicationContext db, string? status) =>
{
// 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);
@@ -90,6 +99,22 @@ namespace GerbilManagerWebAPI.Endpoints
.ToList());
});
// Einzelnes Ticket (inkl. Anhang-Metadaten). Praktisch für die Triage, um ein Ticket
// gezielt zu laden, statt die ganze Liste zu ziehen. 404 bei unbekannter Id.
group.MapGet("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound>> (
Guid id, ApplicationContext db) =>
{
var entity = await db.Feedback.FirstOrDefaultAsync(f => f.Id == id);
if (entity is null)
return TypedResults.NotFound();
var atts = await db.FeedbackAttachments
.Where(a => a.FeedbackId == id)
.Select(a => new FeedbackAttachmentDto(a.Id, a.FileName, a.ContentType, a.Size))
.ToListAsync();
return TypedResults.Ok(ToDto(entity, atts));
});
group.MapPut("/{id:guid}", async Task<Results<Ok<FeedbackDto>, NotFound, BadRequest<string>>> (
Guid id, FeedbackUpdate input, ApplicationContext db, Push.PushNotifier push) =>
{

View File

@@ -1,4 +1,4 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
@@ -90,6 +90,65 @@ namespace GerbilManagerWebAPI.Endpoints
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) =>
{
@@ -110,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

View File

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

View File

@@ -1,4 +1,4 @@
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Common;
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Gridify;
@@ -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;

View File

@@ -18,12 +18,23 @@ namespace GerbilManagerWebAPI.Import
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()
@@ -117,6 +128,11 @@ namespace GerbilManagerWebAPI.Import
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;
@@ -132,7 +148,8 @@ namespace GerbilManagerWebAPI.Import
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,
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,
@@ -313,6 +330,10 @@ namespace GerbilManagerWebAPI.Import
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++;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class 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");
}
}
}

View File

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

View File

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

View File

@@ -20,7 +20,9 @@ services:
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

View File

@@ -28,7 +28,10 @@ services:
POSTGRES_USER: postgres
TZ: Europe/Berlin
volumes:
- /mnt/JailStorage/DockerVolumes/gerbilmanager/pgdata:/var/lib/postgresql/data
# 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
@@ -76,6 +79,22 @@ services:
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

View File

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

View File

@@ -16,7 +16,8 @@
set -eu
APP_NAME=gerbilmanager
COMPOSE_DIR="${COMPOSE_DIR:-/opt/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"
@@ -37,6 +38,7 @@ set -a; . "$ENV_FILE"; set +a
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) ==="
@@ -53,36 +55,49 @@ 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 + midclt-Payload bauen (python: sichere Escapes) ---
PAYLOAD_FILE="$(mktemp)"
trap 'rm -f "$PAYLOAD_FILE"' EXIT
python3 - "$TEMPLATE" > "$PAYLOAD_FILE" <<'PY'
import sys, os, json
# --- 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)
print(json.dumps({"custom_app": True, "app_name": "gerbilmanager",
"custom_compose_config_string": tmpl}))
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 -> redeploy (frische Images sind bereits gezogen)"
midclt call app.redeploy "$APP_NAME" >/dev/null || fail "app.redeploy fehlgeschlagen"
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 "$(cat "$PAYLOAD_FILE")" >/dev/null || fail "app.create fehlgeschlagen"
midclt call app.create "$(build_payload create)" >/dev/null || fail "app.create fehlgeschlagen"
fi
# --- Auf Gesundheit warten (Frontend proxyt /api/health -> API) ---

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -168,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'),
@@ -179,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

View File

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

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

View File

@@ -227,6 +227,30 @@ export interface CreateLitter {
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>
/**

View File

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

View File

@@ -24,6 +24,7 @@ import { combineLocus } from '../punnett'
import { LOCI, type LocusKey } from '../loci'
import {
farbschlagFor,
formatVarietyName,
genotypeToFarbschlag,
representativeGenotype,
BASE_COLORS,
@@ -913,3 +914,84 @@ describe('GEN-5: parent inference fills unknown alleles (cc9ea3fe / 1a508c04)',
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')
})
})

View File

@@ -64,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' },
@@ -78,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' },
@@ -182,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
})
}
/**
@@ -342,8 +365,13 @@ export function formatVarietyName(baseName: string, genotypeStr: string | null |
try {
const g = fromDisplayString(genotypeStr)
const modifiers: string[] = []
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
// 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

View File

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

View File

@@ -162,7 +162,7 @@
},
{
"name": "Saphir",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"canonicalGenotype": "aa Cc[chm] DD EE GG pp spsp",
"sortOrder": 24,
"image": "saphir.jpg"
},
@@ -180,7 +180,7 @@
},
{
"name": "Platin-Hell",
"canonicalGenotype": "aa CC DD EE GG pp spsp",
"canonicalGenotype": "aa Cc[h] DD EE GG pp spsp",
"sortOrder": 27,
"image": "platin-hell.jpg"
},

View File

@@ -220,6 +220,11 @@ function normalizeToken(tok: string): string | null {
t = t.replace(/Uw/g, 'G').replace(/uw/g, 'g')
// GEN-3h: accept bracket display notation → canonical internal symbols.
t = t.replace(/e\[f\]/g, 'ef') // Schimmel allele display form → internal
// Ticket 2322c2a8: 'c[hm]' ist ein verschriebenes 'c[chm]' (fehlendes c) — so
// steht es in den Quell-Charts (Alberto Kids / Pukas Kids). Ohne dieses Alias
// wirft splitToken auf dem Token und der Genotyp ist in UI/Probeverpaarung
// unbrauchbar; mit Alias rechnet der TS-Mirror wie tools/import/genotype.py.
t = t.replace(/c\[hm\]/g, 'cchm') // Schreibfehler-Alias → Colourpoint-Allel
t = t.replace(/c\[chm\]/g, 'cchm') // Colourpoint display form → internal
t = t.replace(/c\[h\]/g, 'ch') // Himalayan display form → internal
// #42 (E-locus): a visible Fuchs is RECESSIVE → MUST be homozygous "ee". The herdbook

View File

@@ -55,6 +55,11 @@ function describeGenotype(genotype: string | null): { display: string; farbschla
}
/** A labelled key/value row inside a card (semantic dt/dd, styled as a grid). */
/** „Saphir Schecke" → „Saphir": Modifier-Zusaetze fuer den Namensvergleich abschneiden. */
function stripVarietyModifiers(name: string): string {
return name.replace(' Schecke', '').replace(' Rex', '').trim()
}
function Kv({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="ak-kv">
@@ -490,9 +495,6 @@ export default function GerbilDetailPage() {
<Kv label={t.fields.causeOfDeath}>{g.causeOfDeath || '—'}</Kv>
</>
)}
{g.status === 'GivenAway' && (
<Kv label={t.fields.goHomeDate}>{formatDate(g.goHomeDate)}</Kv>
)}
<Kv label={t.fields.colorVariety}>
{storedColorName ? (
<>
@@ -503,13 +505,24 @@ export default function GerbilDetailPage() {
'—'
)}
</Kv>
{g.spottingType && (
<Kv label={t.fields.spottingType}>
{de.pages.gerbils.spottingTypes[
g.spottingType as keyof typeof de.pages.gerbils.spottingTypes
] || g.spottingType}
</Kv>
)}
{/* Ticket 9f92c834 (CoCo): Scheckungsart bei jeder Schecke zeigen — auch wenn noch
nicht erfasst (dann Platzhalter), damit sichtbar ist, dass es eine Schecke ist und
welche Art. Schecke erkannt am Sp-Allel im Genotyp oder am Farbschlag-Namen. */}
{(() => {
const isSchecke =
/Sp/.test(g.genotype ?? '') ||
(storedColorName?.toLowerCase().includes('schecke') ?? false);
if (!g.spottingType && !isSchecke) return null;
return (
<Kv label={t.fields.spottingType}>
{g.spottingType
? de.pages.gerbils.spottingTypes[
g.spottingType as keyof typeof de.pages.gerbils.spottingTypes
] || g.spottingType
: de.pages.gerbils.spottingTypes.notRecorded}
</Kv>
);
})()}
{showEnclosure && (
<Kv label={t.fields.enclosure}>{lookup(enclosureName, g.enclosureId)}</Kv>
)}
@@ -538,15 +551,24 @@ export default function GerbilDetailPage() {
g.originBreeder || '—'
)}
</Kv>
{(g.receiverContactId || g.status !== 'Deceased') && (
<Kv label={t.fields.receiver}>{lookup(contactName, g.receiverContactId)}</Kv>
{/* Ticket 24522f5f (JackJack): Das Abgabedatum gehört direkt unter den Abnehmer und
darf NICHT am Status hängen — Tiere, die abgegeben wurden und später 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>
</>
)}
{g.isResident !== false && (partners.data?.items.length ?? 0) > 0 && (
<Kv label={t.detail.zuchtpartner}>
{(partners.data?.items ?? []).map((p, i) => (
{/* Trennkomma HINTEN an den Namen, nicht vorne an den nächsten: .ak-kv-value
ist ein Flex-Container mit wrap, jeder Eintrag also ein eigenes Flex-Item.
Vorangestellt landete das Komma beim Umbruch am ZEILENANFANG („, Zora"). */}
{(partners.data?.items ?? []).map((p, i, all) => (
<span key={p.id}>
{i > 0 && ', '}
<Link to={`/rennmaeuse/${p.id}`}>{p.name || de.pages.gerbils.nameless}</Link>
{i < all.length - 1 && ','}
</span>
))}
</Kv>
@@ -577,10 +599,16 @@ export default function GerbilDetailPage() {
return (
<>
{resolvedFarbschlag}
{/* Vergleich OHNE die Modifier-Zusaetze auf BEIDEN Seiten: der
eingetragene Name traegt „Schecke"/„Rex" bereits (formatVarietyName
haengt sie aus dem Gencode an), der errechnete ebenfalls. Vorher wurde
der Zusatz nur rechts entfernt → jede Schecke bekam faelschlich den
Hinweis „Weicht vom eingetragenen Farbschlag ab" (z. B. JackJack:
„Saphir Schecke" vs. „Saphir Schecke"). */}
{storedColorName &&
resolvedFarbschlag !== de.genetics.unknownFarbschlag &&
storedColorName !==
resolvedFarbschlag.replace(' Schecke', '').replace(' Rex', '') && (
stripVarietyModifiers(storedColorName) !==
stripVarietyModifiers(resolvedFarbschlag) && (
<small className="ak-mismatch"> {t.detail.farbschlagMismatch}</small>
)}
</>
@@ -601,12 +629,12 @@ export default function GerbilDetailPage() {
{(offspring.data?.items.length ?? 0) > 0 && (
<p className="ak-empty">
{t.detail.nonResidentOffspringLabel}{' '}
{(offspring.data?.items ?? []).map((child, i) => (
{(offspring.data?.items ?? []).map((child, i, all) => (
<span key={child.id}>
{i > 0 && ', '}
<Link to={`/rennmaeuse/${child.id}`}>
{child.name || de.pages.gerbils.nameless}
</Link>
{i < all.length - 1 && ','}
</span>
))}
</p>

View File

@@ -1,13 +1,22 @@
import { useState, type FormEvent } from 'react'
import { useMemo, useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { createGerbil, getGerbil, updateGerbil } from '../api/gerbils'
import { errorCode } from '../api/client'
import { createGerbil, getGerbil, listGerbils, updateGerbil, updateGerbilParents } from '../api/gerbils'
import { condition } from '../api/gridify'
import { listColorVarieties, listContacts, listEnclosures, listLitters } from '../api/lookups'
import { getBreederProfile } from '../api/settings'
import { SELECTABLE_STATUSES, type CreateGerbil, type Gender, type GerbilStatus } from '../api/types'
import {
SELECTABLE_STATUSES,
type CreateGerbil,
type Gender,
type GerbilParentsInput,
type GerbilStatus,
} from '../api/types'
import { useApi, useMutation } from '../hooks/useApi'
import { genderLabel, statusLabel } from '../format/labels'
import { fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG } from '../genetics'
import AnimalPicker from '../components/AnimalPicker'
import NameSuggestPanel from '../components/NameSuggestPanel'
import ColorVarietyPicker from '../components/ColorVarietyPicker'
import { useToast } from '../components/toast'
@@ -130,6 +139,9 @@ export default function GerbilFormPage() {
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
const [initializedFor, setInitializedFor] = useState<string | null>(null)
const [showNameSuggest, setShowNameSuggest] = useState(false)
// QOL-ELTERN: null = die Eltern folgen dem gewählten Wurf (Vorbelegung). Sobald die
// Züchterin hier etwas ändert, hält dieser State die abweichende Auswahl bis zum Speichern.
const [parentOverride, setParentOverride] = useState<GerbilParentsInput | null>(null)
const existing = useApi(() => (id ? getGerbil(id) : Promise.resolve(null)), [id])
const colorVarieties = useApi(() => listColorVarieties(), [])
@@ -145,6 +157,7 @@ export default function GerbilFormPage() {
if (existing.data && initializedFor !== existing.data.id) {
setInitializedFor(existing.data.id)
setForm(formFromGerbil(existing.data))
setParentOverride(null)
}
// Create-mode prefill from query params (?litterId, ?dob) — used by the Wurf
@@ -166,9 +179,48 @@ export default function GerbilFormPage() {
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }))
// QOL-ELTERN: Abstammung direkt hier pflegen. Eltern hängen im Datenmodell am GEBURTSWURF
// des Tiers; vorbelegt werden sie daher aus dem gewählten Wurf. `parentOverride` gewinnt,
// sobald die Züchterin etwas ändert.
const selectedLitter = useMemo(
() => (form.litterId ? ((litters.data ?? []).find((l) => l.id === form.litterId) ?? null) : null),
[litters.data, form.litterId],
)
const litterFatherId = selectedLitter?.fatherId ?? null
const litterMotherId = selectedLitter?.motherId ?? null
const fatherId = parentOverride ? parentOverride.fatherId : litterFatherId
const motherId = parentOverride ? parentOverride.motherId : litterMotherId
const parentsChanged = fatherId !== litterFatherId || motherId !== litterMotherId
const father = useApi(() => (fatherId ? getGerbil(fatherId) : Promise.resolve(null)), [fatherId])
const mother = useApi(() => (motherId ? getGerbil(motherId) : Promise.resolve(null)), [motherId])
const setParent = (role: 'father' | 'mother', value: string | null) =>
setParentOverride({
fatherId: role === 'father' ? value : fatherId,
motherId: role === 'mother' ? value : motherId,
})
// Wie viele WEITERE Jungtiere hängen am selben Wurf? Die Eltern gehören dem Wurf, eine
// Änderung gilt daher zwangsläufig auch für die Geschwister — davor wird gewarnt.
const litterMembers = useApi(
() =>
form.litterId
? listGerbils({
filter: condition({ field: 'litterId', op: '==', value: form.litterId }),
page: 1,
pageSize: 1,
})
: Promise.resolve(null),
[form.litterId],
)
const siblingCount = Math.max(0, (litterMembers.data?.totalCount ?? 0) - (isEdit ? 1 : 0))
const mutation = useMutation((body: CreateGerbil) =>
isEdit && id ? updateGerbil(id, body) : createGerbil(body),
)
const parentsMutation = useMutation((gerbilId: string, body: GerbilParentsInput) =>
updateGerbilParents(gerbilId, body),
)
function validate(): boolean {
const next: Partial<Record<keyof FormState, string>> = {}
@@ -209,13 +261,33 @@ export default function GerbilFormPage() {
isCastrated: form.isCastrated,
}
const result = await mutation.run(body)
if (result.ok) {
toast.success(de.common.saved)
navigate(`/rennmaeuse/${result.value.id}`)
} else {
if (!result.ok) {
// mutation.error drives the inline alert; run() never throws.
toast.error(result.error)
return
}
// On failure mutation.error drives the inline alert; run() never throws.
// PUT /gerbils/{id} antwortet 204 ohne Body — im Edit-Modus die Route-Id verwenden.
const savedId = isEdit && id ? id : result.value?.id
if (savedId && parentsChanged) {
const parents = await parentsMutation.run(savedId, { fatherId, motherId })
if (!parents.ok) {
// Auf dem Formular bleiben, damit die Eltern-Eingabe nicht verloren geht.
toast.error(
errorCode(parents.cause) === 'InvalidParentGender'
? de.pages.litters.validation.invalidParentGender
: parents.error,
)
return
}
const litterName = parents.value?.litterName
if (litterName && parents.value?.litterCreated) {
toast.success(t.form.parentsLitterCreated(litterName))
} else if (litterName && parents.value?.litterAttached) {
toast.success(t.form.parentsLitterAttached(litterName))
}
}
toast.success(de.common.saved)
if (savedId) navigate(`/rennmaeuse/${savedId}`)
}
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
@@ -503,17 +575,82 @@ export default function GerbilFormPage() {
</label>
)}
<label className="field">
<span>{t.fields.litter}</span>
<select value={form.litterId} onChange={(e) => set('litterId', e.target.value)}>
<option value="">{t.form.none}</option>
{(litters.data ?? []).map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</label>
{/* QOL-ELTERN: Wurf + Eltern in einem Block. Die Eltern gehören technisch zum
Geburtswurf — deshalb stehen sie direkt beim Wurf-Feld, statt dass man erst
über die Wurf-Seite gehen muss. */}
<div className="form-group-box">
<div className="form-group-box__title">{t.form.parentsSectionTitle}</div>
<label className="field">
<span>{t.fields.litter}</span>
<select
value={form.litterId}
onChange={(e) => {
set('litterId', e.target.value)
// Eltern folgen wieder dem (neu) gewählten Wurf.
setParentOverride(null)
}}
>
<option value="">{t.form.none}</option>
{(litters.data ?? []).map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</label>
{/* Vater/Mutter untereinander — auf dem Handy ist nebeneinander zu eng
(die Wurf-Seite stapelt die beiden Picker genauso). */}
<fieldset className="parent-selector">
<legend>{t.detail.parentLittersRoleVater}</legend>
{fatherId ? (
<p className="muted parent-selector__animal">
{father.data?.name || de.pages.gerbils.nameless}
<button
type="button"
className="link-btn"
onClick={() => setParent('father', null)}
>
{t.form.parentsClear}
</button>
</p>
) : (
<AnimalPicker gender="male" onPick={(g) => setParent('father', g.id)} />
)}
</fieldset>
<fieldset className="parent-selector">
<legend>{t.detail.parentLittersRoleMutter}</legend>
{motherId ? (
<p className="muted parent-selector__animal">
{mother.data?.name || de.pages.gerbils.nameless}
<button
type="button"
className="link-btn"
onClick={() => setParent('mother', null)}
>
{t.form.parentsClear}
</button>
</p>
) : (
<AnimalPicker gender="female" onPick={(g) => setParent('mother', g.id)} />
)}
</fieldset>
<small className="muted">{t.form.parentsHint}</small>
{parentsChanged && !form.litterId && (
<small className="muted" data-testid="parents-new-litter-hint">
{t.form.parentsWillCreateLitter}
</small>
)}
{parentsChanged && selectedLitter && siblingCount > 0 && (
<div className="alert alert--warning" data-testid="parents-sibling-warning">
{t.form.parentsSiblingWarning(selectedLitter.name, siblingCount)}
</div>
)}
</div>
@@ -555,8 +692,12 @@ export default function GerbilFormPage() {
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
<div className="form-actions">
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
{mutation.pending ? t.form.saving : t.form.save}
<button
type="submit"
className="btn btn--primary"
disabled={mutation.pending || parentsMutation.pending}
>
{mutation.pending || parentsMutation.pending ? t.form.saving : t.form.save}
</button>
<Link to={isEdit && id ? `/rennmaeuse/${id}` : '/rennmaeuse'} className="btn">
{t.form.cancel}

View File

@@ -61,7 +61,10 @@ export default function WuerfeListPage() {
// Parent names (shared by both tabs) — keep the full gerbil so the Zucht suffix applies.
const gerbilName = useGerbilName()
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 1000, orderBy: 'name' }), [])
// Alle Tiere laden (nicht nur die ersten 1000): sonst finden Würfe, deren Eltern
// alphabetisch hinter dem Fenster liegen, ihren Namen nicht und die Karte zeigt
// fälschlich „Unbekannt" (Prod hat >2400 Tiere).
const gerbils = useApi(() => listGerbils({ page: 1, pageSize: 10000, orderBy: 'name' }), [])
const gerbilById = useMemo(() => {
const map = new Map<string, Gerbil>()
for (const g of gerbils.data?.items ?? []) map.set(g.id, g)

View File

@@ -25,7 +25,7 @@ export async function fetchPushConfig(): Promise<{ enabled: boolean; publicKey:
}
}
function urlBase64ToUint8Array(base64: string): Uint8Array {
function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(b64)

View File

@@ -172,6 +172,24 @@ export const de = {
isDeafNo: 'Nein',
isScheckeLabel: 'Ist eine Schecke',
colorSectionTitle: 'Farbe & Genetik',
// QOL-ELTERN: Abstammung direkt im Tier-Formular pflegen. Gespeichert werden Vater und
// Mutter am Geburtswurf des Tiers — genau dort, wo sie auch die Wurf-Seite schreibt.
parentsSectionTitle: 'Abstammung',
parentsHint:
'Vater und Mutter gehören zum Geburtswurf des Tiers. Hier gepflegt, ohne den Wurf erst suchen zu müssen.',
parentsClear: 'Entfernen',
parentsUnknown: '— nicht erfasst —',
// Warnung: der Wurf hat weitere Jungtiere, die Eltern gelten zwangsläufig für alle.
parentsSiblingWarning: (litter: string, count: number) =>
count === 1
? `Der Wurf „${litter}“ hat noch 1 weiteres Jungtier — geänderte Eltern gelten auch dafür.`
: `Der Wurf „${litter}“ hat noch ${count} weitere Jungtiere — geänderte Eltern gelten auch für sie.`,
// Hinweis: das Tier hat noch keinen Wurf, für die Eltern wird einer angelegt.
parentsWillCreateLitter:
'Dieses Tier hat noch keinen Wurf. Beim Speichern wird dafür ein Wurf-Eintrag angelegt — er erscheint nicht in der Wurfchronik, nur in der Abstammung.',
parentsLitterCreated: (litter: string) => `Wurf „${litter}“ für die Eltern angelegt.`,
parentsLitterAttached: (litter: string) =>
`Dem bestehenden Wurf „${litter}“ zugeordnet (gleiche Eltern, gleiches Datum).`,
// STATUS-MODEL: Hinweis für abgeleitete Statuswerte
statusDerivedHint: {
Deceased: 'Wird automatisch gesetzt wenn Todesdatum erfasst.',
@@ -204,6 +222,7 @@ export const de = {
},
spottingTypes: {
none: '— keine Scheckungsart —',
notRecorded: 'noch nicht erfasst',
Punktschecke: 'Punktschecke',
Kragenschecke: 'Kragenschecke',
Ansatzschecke: 'Ansatzschecke',

View File

@@ -345,7 +345,21 @@
"decision": "father is Danny von Privatzucht Maintal (Wurfchronik J-Wurf: 'Eltern: Danny + Jana'). Chart-position had wrong/age-impossible parents.",
"father": "Danny von PZ Maintal",
"mother": "Jana of little longnoses",
"source": "Züchterin 2026-06-22 — Ticket #31"
"source": "Züchterin 2026-06-22 — Ticket #31 + Tickets 06b6734d/e96db323/c092f8d2 (Merge Stub) + Tickets 66ef9bdd/3bbd6ab4 (restliche Wurfchronik-Elternstubs page_0017/page_0023)",
"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"
]
]
},
{
"name": "Silver von den kleinen Chaoten",
@@ -466,11 +480,17 @@
"name": "Hiro of Golden Lights",
"dob": "13.02.2012",
"decision": "Eltern aus RennmausPro ergänzt: Wynn × Amidala",
"source": "Züchterin 2026-06-23 — Ticket f3ad5ec9 (Hiro-Ahnen aus RennmausPro)",
"source": "Züchterin 2026-06-23 — Ticket f3ad5ec9 (Hiro-Ahnen aus RennmausPro) + Ticket 44a9b677 (Merge Stub)",
"father": "Wynn",
"fatherDob": "16.05.2011",
"mother": "Amidala",
"motherDob": "06.07.2011"
"motherDob": "06.07.2011",
"mergeExternalRefs": [
[
"stammbaum-hirogoldenlights-13022012",
"Wurfchronik Teil 1_page_0041.md-1603ce65-27a3-41bb-ad68-6c84c17b1ee0"
]
]
},
{
"name": "Akane",
@@ -500,12 +520,16 @@
{
"name": "Targa",
"dob": "",
"decision": "Elternteil Caprice (Chevrolet Corvette of Topolino) und Zuchttier-Status per Breeder-Antwort eingetragen.",
"correctDob": "01.04.2019",
"gender": "female",
"source": "Züchterin 2026-06-29 — Ticket 0877d9d6",
"mother": "Chevrolet Corvette of Topolino",
"farbschlag": "Silberagouti",
"isResident": true,
"notes": "Tochter von Caprice (Chevrolet Corvette of Topolino)."
"deceased": true,
"dateOfDeath": "24.10.2022",
"receiver": "Nicole Nuzzo",
"notes": "Eigene Nachzucht aus Wurf R14 (*01.04.2019, South Dakota von den Kleinen Chaoten x Chevrolet Corvette of Topolino). Mit Tochter Druna an Nicole Nuzzo abgegeben; verstorben 24.10.2022 an Tumoren im Bauchraum.",
"decision": "Vater South Dakota + DOB/Farbe/Tod/Abnehmer aus Ticket ergänzt; Mutter-only-Override durch R14-addLitter ersetzt.",
"source": "Tickets 3269a722/1500f0cc/0877d9d6"
},
{
"name": "Mamono von den Kleinen Chaoten",
@@ -960,14 +984,26 @@
{
"externalRef": "Wurfchronik Teil 1_page_0013.md-g1000000-0000-0000-0000-000000000009",
"isResident": true,
"decision": "Domi: als eigenes Zuchttier gekennzeichnet. externalRef statt name+dob, sonst würde die name-only-Regel einen zweiten gleichnamigen Domi mit erfassen.",
"source": "KI-Triage 2026-07-12 — Ticket 7c6d53ac-c353-4a6f-874f-96d363e732d0"
"decision": "Domi: als eigenes Zuchttier gekennzeichnet. externalRef statt name+dob, sonst würde die name-only-Regel einen zweiten gleichnamigen Domi mit erfassen. Mit Wurfbruder Dastan an dieselben Abnehmer (Susanne + Nina Thomas) abgegeben.",
"source": "KI-Triage 2026-07-12 — Ticket 7c6d53ac-c353-4a6f-874f-96d363e732d0 + Ticket 9325e316",
"receiver": "Susanne + Nina Thomas"
},
{
"name": "Kruke",
"isResident": true,
"decision": "Als eigenes Zuchttier gekennzeichnet (Name und Geburtsdatum bleiben unverändert).",
"source": "KI-Triage 2026-07-12 — Ticket 34220307-23bc-420c-80f8-d273615caffd + b7016e5f-9985-4f17-a3f6-2048289958aa"
"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). Gencode/Farbschlag kommen bewusst AUS DEM MERGE (ihre eigenen Stammbaum-Charts: 'aa CC DD Ee Uwuw[d] Pp Spsp' = aa CC DD Ee Gg Pp Spsp), NICHT als rpro3-Override 'aa CC DD Ee gg Pp Spsp'/Anthrazit: die beiden Quellen widersprechen sich im G-Locus und in der Farbe, und die Stammbaum-Charts sind in diesem Projekt die primaere Quelle. Fuer Ticket f89e95ad zaehlt nur, dass Kuke ueberhaupt einen Gencode hat — den liefert der Merge.",
"source": "KI-Triage 2026-08-19 - Tickets 24006ccd (Zuchtname) + ea803c3d (ueberall 'Kuke') + f89e95ad (Gencode der A-Wurf-Mutter); zuvor e0a0c304 / a50116bf / 34220307 / b7016e5f",
"externalRef": "decision-kruke",
"dateOfDeath": "06.04.2011",
"deceased": true,
"originBreeder": "Marion Knoss",
"renameTo": "Kuke von Marion Knoss",
"mergeExternalRefs": [
[
"decision-kruke",
"stammbaum-kukemarionknoss-18092009"
]
]
},
{
"name": "Genna",
@@ -992,7 +1028,14 @@
"genotype": "aa c[chm]c[h] DD ee[f] Gg Pp spsp",
"originBreeder": "Clan of little longnoses",
"decision": "Jana-Daten aus RennmausPro: DOB 07.12.2009, Colourpoint-Kohlfuchsschimmel-Hell, Herkunft Clan of little longnoses.",
"source": "Zuechterin 2026-07-13 - Ticket 4e7e86bc"
"source": "Zuechterin 2026-07-13 - Ticket 4e7e86bc + Ticket 49548ac0 (Merge + rename)",
"renameTo": "Jana of little longnoses",
"mergeExternalRefs": [
[
"Wurfchronik Teil 1_page_0017.md-adb0649c-a56a-495e-9cce-4fa16505c1dd",
"stammbaum-janalittlelongnoses-07122009"
]
]
},
{
"name": "Danny von Maintaler PZ",
@@ -1026,13 +1069,16 @@
"source": "Zuechterin - Ticket 32d57d3d"
},
{
"name": "Blacky von PZ Seligenstadt",
"externalRef": "stammbaum-blackypzseligenstadt-23072009",
"father": "Snoopy",
"mother": "Bella",
"decision": "Eltern aus RennmausPro: Snoopy x Bella.",
"source": "Ticket cf6e2c99",
"motherDob": "01.01.2008"
"externalRef": "stammbaum-blackyprivatzuchtseligenstadt-23072009",
"mergeExternalRefs": [
[
"stammbaum-blackyprivatzuchtseligenstadt-23072009",
"stammbaum-blackypzseligenstadt-23072009"
]
],
"isResident": true,
"decision": "Zwei Schreibvarianten desselben Blacky (*23.07.2009) zusammengeführt; Keep = 'Blacky von Privatzucht Seligenstadt'. Zugekauftes Zuchttier (Nachzucht mit Kuke 'von den Kleinen Chaoten') -> Bestandstier, Extern-Badge entfernen. Eltern Snoopy x Bella aus gemeinsamem Geburtswurf.",
"source": "Ticket cf6e2c99 + Tickets 45576826/ff8d6bd7"
},
{
"name": "Dante",
@@ -1057,6 +1103,7 @@
{
"name": "JackSack",
"dob": "18.02.2010",
"externalRef": "Wurfchronik Teil 1_page_0013.md-g1000000-0000-0000-0000-000000000002",
"mergeExternalRefs": [
[
"Wurfchronik Teil 1_page_0013.md-g1000000-0000-0000-0000-000000000002",
@@ -1064,8 +1111,9 @@
]
],
"renameTo": "JackJack",
"decision": "Dublette JackSack/JackJack = dasselbe Tier (A-Wurf, *18.02.2010). Zusammenfuehren, richtiger Name JackJack.",
"source": "Zuechterin - Ticket 0cf6b838"
"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 (die Transkription baut daraus faelschlich '1310-05-13'). Adressiert ueber die ExternalRef, weil Wurfchronik-Jungtiere ihr Geburtsdatum erst NACH dem Override-Block aus dem Wurfdatum erhalten — der (name,dob)-Key greift dort nie (deshalb feuerte auch renameTo bisher nicht).",
"source": "Zuechterin - Tickets 0cf6b838, 24522f5f",
"goHomeDate": "13.05.2010"
},
{
"name": "Montana v.d. Kleinen Chaoten",
@@ -1143,13 +1191,6 @@
"genotype": "aa Cc[chm] DD ee[f] Gg Pp spsp",
"source": "Ticket 395667eb: Farbschlag Kohlfuchsschimmel, hell (RennerPro Eddy, eigene Zucht *13.06.2011)"
},
{
"name": "Kuke",
"dob": "18.09.2009",
"genotype": "aa CC DD Ee GG Pp Spsp",
"source": "Ticket 21ce7734 (Schwarz-Schecke) + 17d2d455 (Herkunft Marion Knoss)",
"originBreeder": "Marion Knoss"
},
{
"name": "Julien",
"isResident": false,
@@ -1158,7 +1199,13 @@
{
"name": "Stacy",
"deceased": true,
"source": "Ticket 6b676f17: verstorben"
"correctDob": "23.10.2012",
"genotype": "Aa CC D- EE GG pp Spsp",
"farbschlag": "Gold-Schecke",
"gender": "female",
"originBreeder": "Gelnhausener Privatzucht",
"decision": "Eindeutige Stacy = Gold-Schecke (rpro3 rid 79), Wurf mit Sammy (E2-Wurf 16.03.2013). Daten aus RennerPro: *23.10.2012, Fcode Aa CC D- EE GG pp Spsp, Herkunft Gelnhausener Privatzucht, Eltern Vati (Goldfuchs) x Mutti (Agouti-Schecke).",
"source": "Tickets 9bbfb6a0/f8d55b7a"
},
{
"externalRef": "stammbaum-rayavdbuntenfellnasen",
@@ -1170,11 +1217,6 @@
"mother": "Scarlett of Ulmer Strolche",
"motherDob": "05.09.2012"
},
{
"name": "Merle",
"originBreeder": "Clan of Black Forest",
"source": "Ticket a8f11ac0"
},
{
"name": "Gino",
"dob": "25.10.2010",
@@ -1194,14 +1236,25 @@
},
{
"name": "Jacky v.d. Kleinen Chaoten",
"father": "Jack Jr von Privatzucht Maintal",
"mother": "Holly gen Lilly of Ulmer Strolche",
"source": "Ticket 918a9a3f/873aca5d"
"dob": "24.07.2013",
"father": "Jack von PZ Maintal",
"fatherDob": "20.03.2012",
"mother": "Holly gen. Lilly of Ulmer Strolche",
"motherDob": "23.09.2012",
"decision": "Vater 'Jack von Privatzucht Maintal', Mutter 'Holly gen. Lilly of Ulmer Strolche'; Jacky & Jack Jr Vollgeschwister (rpro3). Früher fälschlich Jack Jr als Vater / Paul+Akina als Eltern.",
"source": "Tickets 918a9a3f/873aca5d"
},
{
"name": "Schlaganfall Sommer 2019",
"renameTo": "Malou von den kleinen Chaoten",
"source": "Ticket 2769d3a5"
"dob": "10.09.2015",
"mergeExternalRefs": [
[
"stammbaum-maloukleinenchaoten-10092015",
"stammbaum-schlaganfallsommer2019-10092015"
]
],
"decision": "Duplikat: 'Malou von den Kleinen Chaoten' (korrekte Eltern Koji x Zoe via echtem I7-Wurf) und 'Schlaganfall Sommer 2019' (defekter Virtual-Wurf) sind dasselbe Tier *10.09.2015. Merge -> Malou behalten; der Zusatz 'früher Schlaganfall Sommer 2019' fällt weg.",
"source": "Ticket bbf2898b"
},
{
"name": "Drake Jr",
@@ -1225,6 +1278,109 @@
"name": "Suen von Zooparadies (Gießen)",
"genotype": "aa C- D- ee G- pp Spsp",
"source": "Ticket f2433ab9 (RennerPro: Rotfuchs-Schecke; Eltern dort nicht erfasst)"
},
{
"externalRef": "Wurfchronik Teil 1_page_0013.md-g1000000-0000-0000-0000-000000000011",
"isResident": false,
"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 (der bisherige Wert war nur Kanon-Fill aus dem Farbschlag 'Schwarz-Sp').",
"source": "Ticket aa5238b6 + Ticket a547be62",
"genotype": "aa C- DD E- G- P- Spsp",
"spottingType": "Ansatzschecke"
},
{
"externalRef": "stammbaum-gwengensakuralittleangels-17122011",
"mergeExternalRefs": [
[
"gwengensakuralittleangels-17122011",
"page_0022_parent_sakura"
],
[
"gwengensakuralittleangels-17122011",
"Wurfchronik Teil 1_page_0023.md-7cbb62c5-7a46-4cb4-a3ad-bf7f94080e15"
]
],
"originBreeder": "Clan of little Angels",
"isResident": true,
"decision": "Sakura (Wurfchronik-Elternteil A2/D2/H2, ohne DOB) und 'Gwen gen. Sakura of little Angels' (*17.12.2011, +05.01.2015, Eltern Chap of little Angels x Julietta gen. Becky of LennyLengo) sind dasselbe Tier. Gwen-Record kanonisch, Wurfchronik-Sakura einmergen. Herkunft Clan of little Angels.",
"source": "Ticket 7f9b3724 + Ticket 6c9539d2 (zweiter Wurfchronik-Stub page_0023)"
},
{
"name": "Marty v.d. Kleinen Chaoten",
"dob": "27.11.2014",
"renameTo": "Marty von den Kleinen Chaoten",
"mergeExternalRefs": [
[
"stammbaum-martyvdkleinenchaoten-27112014",
"430976e2-f0bb-5bc1-8875-496e94e7a0a9"
],
[
"stammbaum-martyvdkleinenchaoten-27112014",
"M4-Wurf_Socke_Marty"
]
],
"notes": "Hieß früher Socke, umbenannt in Marty von den Kleinen Chaoten.",
"decision": "Socke (Wurfchronik S.27, fälschlich Kind L3-Wurf/Vater Taro) und 'Socke (Marty)' (M4-Wurf) = kanonisches Stammbaum-Marty (*27.11.2014, M4-Wurf, Vater Milow). Beide Duplikate in Marty mergen; Sockes falsche L3/Taro-Verknüpfung fällt weg.",
"source": "Ticket 2c49eebf"
},
{
"name": "Mino von den Kleinen Chaoten",
"dob": "26.02.2015",
"mergeExternalRefs": [
[
"stammbaum-minokleinenchaoten-26022015",
"Wurfchronik Teil 1_page_0051.md-868ca4ff-730c-43b9-a9a3-5cde654c607f"
]
],
"decision": "Leeres Mino-Stub (ohne DOB) in vollständigen Mino mergen; L6-/X6-Wurf (Mino×Misaki) hängen dadurch am vollständigen Mino.",
"source": "Ticket 44a9b677"
},
{
"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"
},
{
"name": "Jay",
"dob": "24.10.2021",
"decision": "C-Locus = c[chm]c[chm] — die Quell-Charts schreiben 'c[hm]' (fehlendes c, Tippfehler); das 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]).",
"genotype": "aa c[chm]c[chm] Dd Ee gg P- spsp",
"source": "Zuechterin-Ticket 2322c2a8 (2026-08-19) — „Falsche Farbe. Er ist ein Zobel“"
},
{
"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). Die Chart-Position hatte die Grossmutter Izumi als Mutter gesetzt und keinen Vater (Zeilen-Offset wie im Akane-Fall 88389f8e).",
"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)"
},
{
"name": "Fast Boy of Golden Lights",
"dob": "31.03.2017",
"decision": "Eltern sind Charly of Golden Lights x Ziwa of Golden Lights, NICHT Danjo (= Ziwas Vater, also der Grossvater) + Xtra Bounty (= Zuleikas Mutterlinie) — Chart-Position um eine Zeile verrutscht.",
"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": "Vater = Danjo of Golden Lights, Mutter = Isiri of Golden Lights (rpro3); die Paarzelle R61 'Isiri of Golden Lights & Danjo of Golden Lights' in 'Stammbaum von Akio Kids.xlsx' ist rollenvertauscht. Haengt Danjo/Isiri dort ein, wo sie hingehoeren (Fast Boys Grosseltern), statt den Stammbaum bei Ziwa abzubrechen.",
"father": "Danjo of Golden Lights",
"mother": "Isiri of Golden Lights",
"source": "Ticket 6c89082e — RennmausPro III (_rpro3.db)"
}
],
"addAnimals": [
@@ -1614,6 +1770,56 @@
"zucht": "Privatzüchterin Alice Langevel",
"dob": "01.02.2010",
"source": "RennerPro-Ahne (Tickets df0dae90 / 8293e573)"
},
{
"name": "Druna",
"gender": "female",
"dob": "17.04.2021",
"isResident": false,
"farbschlag": "Silberagouti",
"receiver": "Nicole Nuzzo",
"notes": "Tochter von Targa x Marlin of Black Forest (Wurf H19, *17.04.2021). Mit Mutter Targa an Nicole Nuzzo abgegeben; dort am 29.07.2024 an einem Tumor im Bauchraum verstorben.",
"source": "Ticket 1500f0cc"
},
{
"name": "Grisu of Ulmer Strolche",
"gender": "male",
"isResident": false,
"zucht": "Clan of Ulmer Strolche",
"decision": "Vater von Antares (rpro3), externer Ahn.",
"source": "Ticket d02c2a1b"
},
{
"name": "Akuma of Ulmer Strolche",
"gender": "male",
"isResident": false,
"zucht": "Clan of Ulmer Strolche",
"decision": "Vater von Chagan (rpro3), externer Ahn.",
"source": "Ticket d02c2a1b"
},
{
"name": "Bekter of Ulmer Strolche",
"gender": "female",
"isResident": false,
"zucht": "Clan of Ulmer Strolche",
"decision": "Mutter von Chagan (rpro3), externe Ahnin.",
"source": "Ticket d02c2a1b"
},
{
"name": "Shika von den Kleinen Chaoten",
"gender": "female",
"isResident": true,
"zucht": "Zucht der kleinen Chaoten",
"notes": "Mutter von Ethan. Gencode lt. Stammbaum: aa Cc[chm] D- e[f]e[f] G- pp Spsp. Verstorben 01.09.2024.",
"source": "Ticket 5fe7c884"
},
{
"name": "Bonnie gen. Amika von den Kleinen Chaoten",
"gender": "female",
"isResident": true,
"zucht": "Zucht der kleinen Chaoten",
"notes": "Mutter des N20-Wurfs (mit Kaleo gen. Halio); auch Mutter des A21-Wurfs. Eigene Eltern/Geburtsdatum unbekannt.",
"source": "Ticket 71e3e6a1"
}
],
"suppressExternalRefs": [
@@ -1646,9 +1852,11 @@
"Wurfchronik Teil 1_page_0054.md-6a1a1054-0001-4000-8000-000000000001",
"Wurfchronik Teil 1_page_0054.md-6a1a1054-0002-4000-8000-000000000002",
"Wurfchronik Teil 1_page_0054.md-6a1a1054-0003-4000-8000-000000000003",
"stammbaum-verpaarungchevroletcamarotopolino"
"stammbaum-verpaarungchevroletcamarotopolino",
"Stammbaum-virtual-a7c4a776-e7fc-5672-a004-8aaf5534db97",
"Wurfchronik Teil 1_page_0009.md-90e23e20-3051-4048-b4b1-e2bc00b21736"
],
"_doc_suppress": "suppressExternalRefs: ExternalRefs von bestätigten Phantom-/OCR-Fehllesungs-Dubletten, die final aus dem Payload entfernt werden. Hier: 'Muga' (OCR-Fehllesung von 'Nduga' aus Wurfchronik Seite 0054, Ticket a9c0d0f9, von der Züchterin bestätigt) samt ihrem kinderlosen Phantom-Wurf 'Muga × Benjiro'. Die echten Kinder (Luna/Cleo/Izumi/Pinky) gehören bereits korrekt zum echten Wurf H13 (Nduga × Benjiro, 07.02.2018).",
"_doc_suppress": "suppressExternalRefs: ExternalRefs von bestätigten Phantom-/OCR-Fehllesungs-Dubletten, die final aus dem Payload entfernt werden. Hier: 'Muga' (OCR-Fehllesung von 'Nduga' aus Wurfchronik Seite 0054, Ticket a9c0d0f9, von der Züchterin bestätigt) samt ihrem kinderlosen Phantom-Wurf 'Muga × Benjiro'. Die echten Kinder (Luna/Cleo/Izumi/Pinky) gehören bereits korrekt zum echten Wurf H13 (Nduga × Benjiro, 07.02.2018). Ausserdem das namenlose Q3-Jungtier von Seite 0009 (Ticket 4f890829), das fälschlich als Platzhalter-Vater „Unbekannt“ des Q4-Wurfs aufgelöst wurde — die echten Q3-Jungtiere stehen mit Namen auf Seite 0028 (Pünktchen/Quebec/Kai-Uwe).",
"addLitters": [
{
"name": "Akanes Wurf (bei Clan of Black Forest)",
@@ -1857,6 +2065,41 @@
],
"showInChronicle": false,
"source": "Ticket 8293e573 (Gwen/Sakuras Grosseltern)"
},
{
"name": "R14-Wurf (Chevrolet Corvette x South Dakota)",
"mother": "Chevrolet Corvette of Topolino",
"father": "South Dakota von den Kleinen Chaoten",
"date": "01.04.2019",
"totalBorn": 4,
"children": [
{
"name": "Targa"
},
{
"name": "Tennessee von den Kleinen Chaoten",
"dob": "01.04.2019"
}
],
"showInChronicle": true,
"notes": "R14 aus Wurfchronik (*01.04.2019): 4 Jungtiere (1M/3W). Frieda + Böckchen ohne eigenen Datensatz. Eltern South Dakota von den Kleinen Chaoten (Silberschimmel) x Chevrolet Corvette of Topolino.",
"source": "Tickets 3269a722/1500f0cc"
},
{
"name": "H19-Wurf (Targa x Marlin of Black Forest)",
"mother": "Targa",
"father": "Marlin of Black Forest",
"date": "17.04.2021",
"totalBorn": 2,
"children": [
{
"name": "Druna",
"dob": "17.04.2021"
}
],
"showInChronicle": true,
"notes": "H19 aus Wurfchronik (*17./18.04.2021): 2 Weibchen. Überlebende Silberagouti-Tochter = Druna.",
"source": "Ticket 1500f0cc"
}
],
"litterParents": [
@@ -1900,6 +2143,81 @@
"mother": "Akina von den Kleinen Chaoten",
"motherDob": "13.04.2013",
"source": "Ticket f3dd4e7e (RennerPro: Kojis Mutter = Akina)"
},
{
"litterName": "Wurf von Carlotta v.d. Rastatter Fellnasen + Blacky von Privatzucht Seligenstadt",
"date": "16.01.2010",
"father": "Grisu of Ulmer Strolche",
"mother": "Carlotta v.d. Rastatter Fellnasen",
"source": "Ticket d02c2a1b (rpro3: Antares = Grisu x Carlotta, nicht Blacky)"
},
{
"litterName": "Wurf von Carlotta v.d. Rastatter Fellnasen + Blacky von Privatzucht Seligenstadt",
"date": "20.02.2011",
"father": "Akuma of Ulmer Strolche",
"mother": "Bekter of Ulmer Strolche",
"source": "Ticket d02c2a1b (rpro3: Chagan = Akuma x Bekter)"
},
{
"litterName": "Wurf von Living Force's Vally + Living Force's Idefix",
"date": "05.04.2016",
"father": "Living Force's Nando",
"fatherDob": "07.03.2014",
"mother": "Living Force's Vally",
"motherDob": "01.11.2014",
"source": "Tickets d8e6f1a2/1be92139"
},
{
"date": "09.07.2020",
"mother": "Shika von den Kleinen Chaoten",
"source": "Ticket 5fe7c884"
},
{
"date": "19.04.2020",
"father": "Jeremy von den Kleinen Chaoten",
"fatherDob": "11.09.2016",
"mother": "Inory von den Kleinen Chaoten",
"motherDob": "07.07.2019",
"source": "Ticket 37cf2f40"
},
{
"litterName": "N20-Wurf",
"date": "27.01.2022",
"mother": "Bonnie gen. Amika von den Kleinen Chaoten",
"source": "Ticket 71e3e6a1"
},
{
"litterName": "M4-Wurf",
"date": "27.11.2014",
"mother": "Hina von den Kleinen Chaoten",
"source": "Ticket 2c49eebf"
},
{
"litterName": "Wurf Hiro + Skarlett",
"date": "05.04.2015",
"father": "Hiro of Golden Lights",
"fatherDob": "13.02.2012",
"mother": "Skarlett von den Kleinen Chaoten",
"motherDob": "14.07.2013",
"source": "Ticket 44a9b677: April-Wurf hatte fälschlich Ahnin Skarlett *2009 als Mutter"
},
{
"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 fuer alle Wuerfe fatherId/motherId=null"
},
{
"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'."
}
],
"_doc_litterParents": "litterParents: erzwingt Vater/Mutter eines BESTEHENDEN Wurfs (Match litterName+date), via name+dob. Für Namensvetter-/verstorbenes-Elternteil-Korrekturen, die der Resolver nicht selbst trifft.",
@@ -1942,6 +2260,49 @@
"name": "Malina",
"dob": "22.03.2010",
"source": "Ticket df0dae90: kompletter Stammbaum aus RennerPro"
},
{
"name": "Stacy",
"dob": "23.10.2012",
"source": "Tickets 9bbfb6a0/f8d55b7a: kompletter Stammbaum (Vati x Mutti) aus RennerPro"
}
]
}
],
"litterChildren": [
{
"date": "03.03.2011",
"matchChild": "Cotton",
"keep": [
"Cotton",
"Mayu",
"Kiwi",
"Mio",
"Mo"
],
"renameTo": "M-Wurf",
"note": "Ticket 4b9f49fb: Wurf hieß fälschlich \"Ungeklärt\" und hatte durch Merge einer Abnehmer-Sammelseite zu viele Jungtiere. Korrekt M-Wurf (Blacky x Charlett, 03.03.2011) mit den 5 benannten Jungtieren; Enzo/Cesar/Jack/Jones (andere Herkunft/Geburtsdaten) gelöst."
},
{
"date": "21.06.2014",
"matchChild": "Kai-Uwe",
"keep": [
"Pünktchen",
"Quebec v.d. Kleinen Chaoten",
"Kai-Uwe"
],
"note": "Ticket 8d259edf: Gales (früher Drake Jr) Wurf hatte 9 fälschlich zugeordnete unbenannte Jungtiere. Wurfchronik: 3 überlebt = Pünktchen, Quebec, Kai-Uwe (aus Abgabeverträgen/Stammbaum bestätigt)."
},
{
"date": "21.01.2023",
"matchChild": "Anakoni",
"add": [
{
"name": "Akane von den Kleinen Chaoten",
"dob": "21.01.2023"
}
],
"note": "Akane ist laut Wurfchronik ein Z21-Jungtier (Roni × Fumi). Die neuen Stammbäume (Pukas Kids / Sa Sp (YaraFire) & Sa Kids) führen Akanes Eltern in Spalte N direkt UNTER ihrem Block (N80 Roni = Vater, N81 Fumi = Mutter); die Positions-Heuristik griff eine Zeile zu hoch und paarte Irish Coffee (Bonapartes Mutter) mit Roni — dadurch entstand ein Phantom-Wurf vom 21.01.2023 in der Wurfchronik.",
"source": "Züchterin 2026-06-22 — Ticket 88389f8e (Roni=Vater, Fumi=Mutter) + Chart „Stammbaum von Pukas Kids“"
}
],
"_doc_litterChildren": "litterChildren: autoritative Kinder-Whitelist eines Wurfs (Match date+matchChild). Kinder außerhalb keep werden aus dem Wurf gelöst (LitterId=None); optional renameTo. Für Über-Merges von Abnehmer-Sammelseiten. Optional add [Name | {name, dob}]: hängt ein Jungtier an DIESEN Wurf (Gegenstück zu keep) — für Jungtiere, die die Chart-Positions-Heuristik in einen falschen virtuellen Stammbaum-Wurf gezogen hat; wird der alte Wurf dadurch kinderlos UND virtuell, entfernt der Importer ihn (kein Phantom-Wurf in der Wurfchronik)."
}

View File

@@ -222,6 +222,39 @@ def parse_detail(text):
geno)
# Sternloses Geburtsdatum: "22.08.2019/+09.04.2024" statt "*22.08.2019/+09.04.2024".
# Erlaubt ist optional ein angehaengtes Todesdatum nach "/+" (auch leer, "30.01.2020/+").
# Kein Whitespace innerhalb des Datums, damit die DOB-Regex nach dem Praefixen sicher greift.
BARE_DOB = re.compile(r"^\s*\d{1,2}\.\d{1,2}\.\d{4}(\s*/\s*\+.*)?$")
def _normalise_starless_dobs(cells):
"""Setzt den fehlenden Stern vor sternlose Geburtsdaten in echten Bloecken.
WARUM (Ticket 65266679 Bijou): Einige Charts vergessen den Stern vor dem
Geburtsdatum (Louis of Black Forest in "Alberto Kids"/"Pukas Kids", Jiminy of
Black Forest in "Stella Kids"). Ohne Stern erkennt die Blockheuristik das Tier
gar nicht -> das Tier fehlt im Extrakt und `_reconstruct_parents` greift in den
Nachbar-Ast (Bijou bekam Arya Stark als Mutter statt Louis x Nani).
Damit nicht jede beliebige Datumszelle (Notizen, Abgabedaten) zum Tier wird,
normalisieren wir nur MIT Block-Kontext: ein Genotyp <= 3 Zeilen darunter UND
eine Namenszelle <= 3 Zeilen darueber -- genau die Nachbarschaft, die die
Blockerkennung anschliessend auch auswertet.
"""
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()
def extract_stammbaum(path):
"""Return list of animal dicts for one chart file."""
fname = os.path.basename(path)
@@ -229,6 +262,7 @@ def extract_stammbaum(path):
ss = xu.shared_strings(z)
sheets = xu.sheet_paths(z)
cells = xu.read_cells(z, sheets[0], ss)
_normalise_starless_dobs(cells)
fillsex = xu.cell_fill_sex(z, sheets[0]) # box colour -> sex (blue=male, white=female)
has_col2 = any(c == 2 for (c, r) in cells)
@@ -444,6 +478,11 @@ def _attach_photos(z, sheets, animals, fname):
if not target:
continue
ext = os.path.splitext(media)[1] or ".img"
# Excel legt neben dem echten Foto teils ein Vektor-Metafile (EMF/WMF) als
# Vorschau ab. Browser können das NICHT darstellen → es landete als kaputte
# Bildkachel in der Tier-Akte. Solche Anhänge sind keine Fotos → überspringen.
if ext.lower() in (".emf", ".wmf"):
continue
sl = slug(target["name"], target["dob"])
dest_dir = os.path.join(media_dir, sl)
os.makedirs(dest_dir, exist_ok=True)

View File

@@ -84,6 +84,15 @@ def _deaf_value(token):
return None
# Schreibfehler-Aliase auf Allel-Ebene (Quelldaten): 'c[hm]' ist ein verschriebenes
# 'c[chm]' (fehlendes c) — Ticket 2322c2a8 (Jay *24.10.2021, Stammbaum von Alberto
# Kids/Pukas Kids). Ohne Alias landet das Allel als unbekanntes 'c^hm' im C-Locus und
# der Farbschlag kippt von Zobel auf Zobel-Hell (both_cchm=False); zusätzlich ist der
# String im TS-Parser ungültig. Das Alias normalisiert beim Parsen, damit BEIDE Mirrors
# identisch rechnen (genotype.ts normalizeToken macht dasselbe).
_SUPERSCRIPT_ALIAS = {("c", "hm"): "chm"}
def _alleles_for(locus, token):
pat = _ALLELE_UNIT.get(locus)
units = pat.findall(token) if pat else re.findall(r"[A-Za-z](?:\[[a-z]+\])?|-", token)
@@ -94,7 +103,8 @@ def _alleles_for(locus, token):
else:
m = re.match(r"([A-Za-z]+)\[([a-z\-]+)\]", u)
if m:
alleles.append(m.group(1) if m.group(2) == "-" else f"{m.group(1)}^{m.group(2)}")
sup = _SUPERSCRIPT_ALIAS.get((m.group(1), m.group(2)), m.group(2))
alleles.append(m.group(1) if sup == "-" else f"{m.group(1)}^{sup}")
else:
alleles.append(u)
if len(alleles) == 1:
@@ -224,7 +234,7 @@ _BASE_COLORS = [
("Algierfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
("Blau", {"A": "a", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
("Gold", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Platin", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Platin", {"A": "a", "C": "C/C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Goldfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
("Rotfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "p"}),
("Dilute Gold", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "p"}),
@@ -237,10 +247,10 @@ _BASE_COLORS = [
("Elfenbein", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "g", "P": "p"}),
("Kohlfuchs", {"A": "a", "C": "C", "D": "D", "E": "e", "G": "G", "P": "P"}),
("Polarfuchs", {"A": "A", "C": "C", "D": "D", "E": "e", "G": "g", "P": "P"}),
("Saphir", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Saphir", {"A": "a", "C": "C/cchm", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Orangeschimmel", {"A": "A", "C": "C", "D": "D", "E": "ef", "G": "G", "P": "P"}),
("Topas", {"A": "A", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Platin-Hell", {"A": "a", "C": "C", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Platin-Hell", {"A": "a", "C": "C/ch", "D": "D", "E": "E", "G": "G", "P": "p"}),
("Dilute Agouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "G", "P": "P"}),
("Dilute Silberagouti", {"A": "A", "C": "C", "D": "d", "E": "E", "G": "g", "P": "P"}),
("Dilute Kohlfuchs", {"A": "a", "C": "C", "D": "d", "E": "e", "G": "G", "P": "P"}),
@@ -356,8 +366,22 @@ def _entry_in_e_family(name, tokens, family):
return "schimmel" not in n
def _pair_matches(mapped, locus, token):
"""GEN-6-Mirror (catalog.ts pairMatches): Token 'x/y' verlangt das EXAKTE,
ungeordnete Allelpaar statt des exprimierten dominanten Allels."""
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(_locus_token(mapped, locus) == tok for locus, tok in tokens.items())
# GEN-6 (Tickets bde4ec70 / f89e95ad): Platin / Saphir / Platin-Hell unterscheiden
# sich NUR in der C-Zygotie (aa CC / aa Cc[chm] / aa Cc[h]) — dort muss zygotie-
# genau statt auf dem dominanten Allel gematcht werden. Mirror von catalog.ts.
return all(
_pair_matches(mapped, locus, tok) if "/" in tok else _locus_token(mapped, locus) == tok
for locus, tok in tokens.items()
)
def _base_colour_for(mapped):

View File

@@ -606,6 +606,11 @@ def parse_date(d):
d = f"{year}-{parts[1]}-{parts[2]}"
elif year == 1900:
return None
# Implausible Jahre verwerfen (Ticket 24522f5f): eine Wurfchronik-Transkription
# baute aus „13.5.10" das Datum „1310-05-13" zusammen. Lieber KEIN Datum als ein
# Datum aus dem 14. Jahrhundert (oder aus der fernen Zukunft).
if year < 1900 or year > datetime.now().year + 1:
return None
return d
# Try DD.MM.YYYY or D.M.YY
match = re.match(r"^(\d{1,2})\.(\d{1,2})\.(\d{2,4})$", d)
@@ -619,6 +624,9 @@ def parse_date(d):
year += 100
elif year == 1900:
return None
# s. o. — Plausibilitäts-Guard NACH der Jahres-Normalisierung
if year < 1900 or year > datetime.now().year + 1:
return None
try:
return datetime(year, month, day).strftime("%Y-%m-%d")
except ValueError:
@@ -632,6 +640,8 @@ def parse_date(d):
return dt.strftime("%Y-%m-%d")
elif year == 1900:
return None
if dt.year < 1900 or dt.year > datetime.now().year + 1:
return None
return dt.strftime("%Y-%m-%d")
except ValueError:
pass
@@ -1389,6 +1399,244 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
return stats, sale_contracts
def apply_rename_note_sweep(resolved_gerbils, resolved_litters):
"""Zieht ein `renameTo` in die Freitext-Notizen nach (Tickets ea803c3d/24006ccd).
`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 genau dort sieht ihn die Züchterin. Ersetzt wird wortgenau an Wortgrenzen und durch
den RUFNAMEN des neuen Namens — Notizen bleiben so lesbar (nicht „Kuke von Marion
Knoss" mitten im Satz). MUSS als letzter Schritt vor dem Payload-Dump laufen: die
Notizen werden vorher fürs Dedup-Scoring gelesen. Gibt die Zahl der geänderten
Notizen zurück.
"""
_rename_pairs = []
for g in resolved_gerbils:
_old = (g.get("_pre_rename_name") or "").strip()
_new = (g.get("Name") or "").strip()
if not _old or not _new or _old == _new:
continue
_new_call = get_call_name(_new) or _new
# Nur ein Zuchtname-Suffix dazubekommen („Jana" → „Jana of little longnoses")
# ist keine Namenskorrektur → Notizen unangetastet lassen.
if normalize_name(_old) == normalize_name(_new_call):
continue
# Guards gegen Kollateralschaden (im ersten Probelauf real aufgetreten):
# (1) zu kurze Quellnamen („M") treffen Abkürzungen im Freitext
# („Natascha M.", „Sch. M. 2", „60g; M: 17.3.19").
if len(_old) < 4:
continue
# (2) mehr als zwei Wörter = kein Rufname, sondern eine Phrase (z. B. der
# Sentinel „Zucht der Kleinen Chaoten" als Quellname eines Stubs).
if len(_old.split()) > 2:
continue
# (3) steckt der alte Name als Wort im neuen Rufnamen („Missy" → „Missy gen.
# Krümmel"), würde die Notiz den Zusatz bei jedem Lauf verdoppeln.
if re.search(r"\b" + re.escape(_old) + r"\b", _new_call):
continue
_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 not _val or not _rx.search(_val):
continue
# (4) Notizen, die die Umbenennung SELBST dokumentieren („hieß ursprünglich
# Drake Jr, wurde später in Gale umbenannt"), müssen den alten Namen
# behalten — sonst wird der Satz sinnlos.
_low = _val.lower()
if "umbenannt" in _low or "ursprünglich" in _low or "urspruenglich" in _low:
continue
_rec["Notes"] = _rx.sub(_new_call, _val)
_notes_fixed += 1
return _notes_fixed
def apply_decision_overrides(resolved_gerbils, decisions, variety_map,
resolve_contact_id=None):
"""Wendet die Feld-Overrides aus conflict-decisions.json auf die finalen Tiere an.
Als Modul-Funktion (statt inline in main()) herausgezogen, damit die
Override-Semantik regressionsgetestet werden kann — insbesondere die
AUTORITATIVEN Felder (genotype/spottingType/goHomeDate), die auch einen
bereits gefüllten Wert überschreiben. Gibt die Zahl der geänderten Tiere zurück.
"""
# ── isResident/notes/receiver-Overrides (Mensch-Entscheidung) ─────────────
# NACH dem Dedup anwenden, sonst würde die Dedup-Zusammenführung (IsResident=True
# falls eine Variante resident ist) sie wieder überschreiben. Auch NACH der
# Residenz-Propagation (die nur auf Stammbaum-Roh-Tiere wirkt) → diese Overrides
# sind das letzte Wort. Matcht primär über `externalRef` (präzise, auch für
# namenlose Tiere) oder sonst über normalize(call-name)+ISO-dob (leere dob =
# name-only, für Vorfahren ohne Datum). Ein Match kann zusätzlich `notes` (→
# Notes, autoritativ) und `receiver` (Kontaktname → ReceiverContactId, Status=
# GivenAway) setzen — so bekommt ein bereits existierendes Tier (z. B. Akane)
# seinen Lebenslauf + Abnehmer, ohne ein addAnimals-Stub zu sein.
_ovr_by_extref = {}
_ovr_by_namedob = {}
for d in decisions:
has_ovr = (("isResident" in d) or d.get("notes") or d.get("receiver")
or d.get("renameTo") or d.get("deceased") or d.get("dateOfDeath")
or d.get("correctDob") or d.get("genotype") or d.get("farbschlag")
or d.get("gender") or d.get("father") or d.get("mother")
or d.get("originBreeder") or d.get("spottingType")
or d.get("goHomeDate"))
if not has_ovr:
continue
er = (d.get("externalRef") or "").strip()
if er:
_ovr_by_extref[er] = d
nm = d.get("name")
if nm is not None:
ck = normalize_name(get_call_name(nm or ""))
iso = parse_date(d.get("dob")) if d.get("dob") else ""
_ovr_by_namedob[(ck, iso or "")] = d
_isres_applied = 0
if _ovr_by_extref or _ovr_by_namedob:
for g in resolved_gerbils:
er = g.get("ExternalRef") or ""
ck = normalize_name(get_call_name(g.get("Name") or ""))
iso = g.get("DateOfBirth") or ""
# `_precise` = die Entscheidung meint NACHWEISLICH dieses Tier (externalRef
# oder name+dob). Ein name-only-Match kann dagegen einen Namensvetter
# treffen → autoritative Overrides (Gencode) dürfen dort nichts
# überschreiben. (Ticket a547be62 Eliza / 7037f5d8 Merle of Samsimar.)
_precise = False
if er and er in _ovr_by_extref:
d = _ovr_by_extref[er]
_precise = True
elif er and any(er.endswith(k) for k in _ovr_by_extref):
d = next(v for k, v in _ovr_by_extref.items() if er.endswith(k))
_precise = True
elif (ck, iso) in _ovr_by_namedob:
d = _ovr_by_namedob[(ck, iso)]
_precise = bool(iso)
elif (ck, "") in _ovr_by_namedob:
d = _ovr_by_namedob[(ck, "")]
else:
continue
applied = False
if "originBreeder" in d:
val = d["originBreeder"]
if g.get("OriginBreeder") != val:
g["OriginBreeder"] = val
applied = True
if "isResident" in d:
val = bool(d["isResident"])
# Ein expliziter Override (true ODER false) ist das letzte Wort und
# darf vom späteren Residenz-Sweep NICHT überschrieben werden.
g["_resident_override"] = True
if g.get("IsResident") != val:
g["IsResident"] = val
# Ein Nicht-Bestandstier ist kein eigenes Zuchttier → den eigenen
# Zucht-Breeder entfernen (nur, wenn er auf die eigene Zucht zeigt).
if not val and g.get("OriginBreeder") == "Zucht der kleinen Chaoten":
g["OriginBreeder"] = None
applied = True
notes_ovr = (d.get("notes") or "").strip()
if notes_ovr and g.get("Notes") != notes_ovr:
g["Notes"] = notes_ovr
applied = True
rec_name = d.get("receiver")
if rec_name:
rec_cid = resolve_contact_id(rec_name) if resolve_contact_id else None
if rec_cid and g.get("ReceiverContactId") != rec_cid:
g["ReceiverContactId"] = rec_cid
if g.get("Status") not in ("Deceased",):
g["Status"] = "GivenAway"
applied = True
# renameTo: korrigiert den Anzeigenamen eines bestehenden Tieres. Der
# Match-Key bleibt der ursprüngliche name/externalRef (NICHT umdeuten) —
# erst HIER wird der Name überschrieben.
rename_to = (d.get("renameTo") or "").strip()
if rename_to and g.get("Name") != rename_to:
# Quell-Namen merken, damit spätere Eltern-Verknüpfungen, die noch den
# alten Namen referenzieren, weiter auflösen (siehe Namens-Index unten,
# Ticket e0a0c304 Kruke→Kuke: sonst verlieren Kukes Würfe die Mutter).
g["_pre_rename_name"] = g.get("Name")
g["Name"] = rename_to
applied = True
# deceased: markiert ein bestehendes Tier als verstorben, falls noch
# nicht. dateOfDeath wird bewusst NICHT angefasst.
if d.get("deceased") and g.get("Status") != "Deceased":
g["Status"] = "Deceased"
applied = True
# dateOfDeath: setzt das Todesdatum (falls noch keins) + Status Deceased.
# rpro3 hat keine Sterbespalte, daher gibt die Züchterin/das Ticket das Datum vor.
if d.get("dateOfDeath") and not g.get("DateOfDeath"):
_dod = parse_date(d["dateOfDeath"])
if _dod:
g["DateOfDeath"] = _dod
g["Status"] = "Deceased"
applied = True
# goHomeDate: Abgabedatum aus einer Züchterin-Entscheidung. AUTORITATIV —
# überschreibt auch einen vorhandenen Wert, weil Wurfchronik-Transkriptionen
# das Datum falsch zusammenbauen können (JackJack: „1310-05-13" statt
# 13.05.2010, Ticket 24522f5f). Zieht Status GivenAway nach, sofern das Tier
# nicht verstorben ist (Ticket 7037f5d8 Merle) — „GivenAway" verhindert
# außerdem, dass die 6-Jahres-Sterbe-Heuristik greift.
if d.get("goHomeDate"):
_gh = parse_date(d["goHomeDate"])
if _gh:
if g.get("GoHomeDate") != _gh:
g["GoHomeDate"] = _gh
applied = True
if g.get("Status") not in ("Deceased", "GivenAway"):
g["Status"] = "GivenAway"
applied = True
if d.get("correctDob") and not g.get("DateOfBirth"):
dob_iso = parse_date(d["correctDob"])
g["DateOfBirth"] = dob_iso
g["_birth_date"] = dob_iso
g["_eff_dob"] = dob_iso
applied = True
# 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, 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 (Freitext, Werte wie in de.ts `spottingTypes`, z. B.
# „Ansatzschecke"). Der Importer leitet sie aus keiner Quelle ab — sie ist
# ausschließlich per Entscheidung setzbar.
if d.get("spottingType"):
_sp_ovr = d["spottingType"].strip()
if g.get("SpottingType") != _sp_ovr:
g["SpottingType"] = _sp_ovr
applied = True
if d.get("farbschlag") and not g.get("ColorVarietyId"):
cv_id = variety_map.get(d["farbschlag"].strip().lower())
if cv_id:
g["ColorVarietyId"] = cv_id
applied = True
if d.get("gender") and g.get("Gender") in ("unknown", None):
gender = d["gender"].strip().lower()
gender = {"m": "male", "männlich": "male", "w": "female",
"f": "female", "weiblich": "female"}.get(gender, gender)
if gender in ("male", "female"):
g["Gender"] = gender
applied = True
if d.get("father"):
g["_override_father_name"] = d["father"].strip()
g["_override_father_dob"] = d.get("fatherDob")
applied = True
if d.get("mother"):
g["_override_mother_name"] = d["mother"].strip()
g["_override_mother_dob"] = d.get("motherDob")
applied = True
if applied:
_isres_applied += 1
if _isres_applied:
print("isResident/notes/receiver/dob/geno/spotting/abgabe-Overrides angewandt: "
f"{_isres_applied}")
return _isres_applied
def main():
print("Loading color variety seeds...")
variety_map = {}
@@ -3196,124 +3444,9 @@ def main():
print(f"Deduplicated to {len(resolved_gerbils)} unique gerbil records.")
# ── isResident/notes/receiver-Overrides (Mensch-Entscheidung) ─────────────
# NACH dem Dedup anwenden, sonst würde die Dedup-Zusammenführung (IsResident=True
# falls eine Variante resident ist) sie wieder überschreiben. Auch NACH der
# Residenz-Propagation (die nur auf Stammbaum-Roh-Tiere wirkt) → diese Overrides
# sind das letzte Wort. Matcht primär über `externalRef` (präzise, auch für
# namenlose Tiere) oder sonst über normalize(call-name)+ISO-dob (leere dob =
# name-only, für Vorfahren ohne Datum). Ein Match kann zusätzlich `notes` (→
# Notes, autoritativ) und `receiver` (Kontaktname → ReceiverContactId, Status=
# GivenAway) setzen — so bekommt ein bereits existierendes Tier (z. B. Akane)
# seinen Lebenslauf + Abnehmer, ohne ein addAnimals-Stub zu sein.
_ovr_by_extref = {}
_ovr_by_namedob = {}
for d in _decisions:
has_ovr = (("isResident" in d) or d.get("notes") or d.get("receiver")
or d.get("renameTo") or d.get("deceased")
or d.get("correctDob") or d.get("genotype") or d.get("farbschlag")
or d.get("gender") or d.get("father") or d.get("mother")
or d.get("originBreeder"))
if not has_ovr:
continue
er = (d.get("externalRef") or "").strip()
if er:
_ovr_by_extref[er] = d
nm = d.get("name")
if nm is not None:
ck = normalize_name(get_call_name(nm or ""))
iso = parse_date(d.get("dob")) if d.get("dob") else ""
_ovr_by_namedob[(ck, iso or "")] = d
_isres_applied = 0
if _ovr_by_extref or _ovr_by_namedob:
for g in resolved_gerbils:
er = g.get("ExternalRef") or ""
ck = normalize_name(get_call_name(g.get("Name") or ""))
iso = g.get("DateOfBirth") or ""
if er and er in _ovr_by_extref:
d = _ovr_by_extref[er]
elif er and any(er.endswith(k) for k in _ovr_by_extref):
d = next(v for k, v in _ovr_by_extref.items() if er.endswith(k))
elif (ck, iso) in _ovr_by_namedob:
d = _ovr_by_namedob[(ck, iso)]
elif (ck, "") in _ovr_by_namedob:
d = _ovr_by_namedob[(ck, "")]
else:
continue
applied = False
if "originBreeder" in d:
val = d["originBreeder"]
if g.get("OriginBreeder") != val:
g["OriginBreeder"] = val
applied = True
if "isResident" in d:
val = bool(d["isResident"])
# Ein expliziter Override (true ODER false) ist das letzte Wort und
# darf vom späteren Residenz-Sweep NICHT überschrieben werden.
g["_resident_override"] = True
if g.get("IsResident") != val:
g["IsResident"] = val
# Ein Nicht-Bestandstier ist kein eigenes Zuchttier → den eigenen
# Zucht-Breeder entfernen (nur, wenn er auf die eigene Zucht zeigt).
if not val and g.get("OriginBreeder") == "Zucht der kleinen Chaoten":
g["OriginBreeder"] = None
applied = True
notes_ovr = (d.get("notes") or "").strip()
if notes_ovr and g.get("Notes") != notes_ovr:
g["Notes"] = notes_ovr
applied = True
rec_name = d.get("receiver")
if rec_name:
rec_cid = _resolve_contact_id_by_name(rec_name)
if rec_cid and g.get("ReceiverContactId") != rec_cid:
g["ReceiverContactId"] = rec_cid
if g.get("Status") not in ("Deceased",):
g["Status"] = "GivenAway"
applied = True
# renameTo: korrigiert den Anzeigenamen eines bestehenden Tieres. Der
# Match-Key bleibt der ursprüngliche name/externalRef (NICHT umdeuten) —
# erst HIER wird der Name überschrieben.
rename_to = (d.get("renameTo") or "").strip()
if rename_to and g.get("Name") != rename_to:
g["Name"] = rename_to
applied = True
# deceased: markiert ein bestehendes Tier als verstorben, falls noch
# nicht. dateOfDeath wird bewusst NICHT angefasst.
if d.get("deceased") and g.get("Status") != "Deceased":
g["Status"] = "Deceased"
applied = True
if d.get("correctDob") and not g.get("DateOfBirth"):
dob_iso = parse_date(d["correctDob"])
g["DateOfBirth"] = dob_iso
g["_birth_date"] = dob_iso
g["_eff_dob"] = dob_iso
applied = True
if d.get("genotype") and not g.get("Genotype"):
g["Genotype"] = d["genotype"].strip()
applied = True
if d.get("farbschlag") and not g.get("ColorVarietyId"):
cv_id = variety_map.get(d["farbschlag"].strip().lower())
if cv_id:
g["ColorVarietyId"] = cv_id
applied = True
if d.get("gender") and g.get("Gender") in ("unknown", None):
gender = d["gender"].strip().lower()
gender = {"m": "male", "männlich": "male", "w": "female",
"f": "female", "weiblich": "female"}.get(gender, gender)
if gender in ("male", "female"):
g["Gender"] = gender
applied = True
if d.get("father"):
g["_override_father_name"] = d["father"].strip()
g["_override_father_dob"] = d.get("fatherDob")
applied = True
if d.get("mother"):
g["_override_mother_name"] = d["mother"].strip()
g["_override_mother_dob"] = d.get("motherDob")
applied = True
if applied:
_isres_applied += 1
if _isres_applied:
print(f"isResident/notes/receiver/dob/geno-Overrides angewandt: {_isres_applied}")
# Logik siehe apply_decision_overrides() (Modul-Funktion, damit testbar).
apply_decision_overrides(resolved_gerbils, _decisions, variety_map,
_resolve_contact_id_by_name)
@@ -3414,6 +3547,13 @@ def main():
# …and by the connector-folding canon key so abbreviation variants match
# (e.g. „BlackFire v.d. Kleinen Chaoten“ vs „… von den …“ — ticket #30).
keys.add(canon_name_key(g["Name"]))
# Umbenannte Tiere (renameTo) auch unter ihrem Quell-Namen indexieren, damit
# Eltern-Verknüpfungen, die noch den alten Namen referenzieren, weiter auflösen.
pre = g.get("_pre_rename_name")
if pre:
keys.add(normalize_name(pre))
keys.add(normalize_name(get_call_name(pre)))
keys.add(canon_name_key(pre))
for k in keys:
if k:
gerbil_by_norm_name.setdefault(k, []).append(g)
@@ -4140,6 +4280,84 @@ def main():
if _lo_applied:
print(f"litterOrder: {_lo_applied} Jungtier-Reihenfolgen gesetzt")
# ── litterChildren: autoritative Kinder-Whitelist eines Wurfs (Mensch-Entscheidung) ──
# Für Fälle, in denen der Wurf-Merge fremde Tiere (z. B. Abnehmer-Sammelseiten der
# Wurfchronik) in einen datierten Wurf gezogen hat (Tickets 4b9f49fb M-Wurf, 8d259edf Gale).
# Match per date + `matchChild` (ein aktuell zugeordnetes Kind, disambiguiert gleich-datierte
# Würfe). Alle Kinder, deren Ruf-Name NICHT in `keep` steht, werden aus dem Wurf gelöst
# (LitterId=None) — sie verschwinden aus dem Wurf, bleiben aber als Tier erhalten. Optional
# `renameTo` setzt den kanonischen Wurf-Namen (z. B. 'Ungeklärt' -> 'M-Wurf').
# Optional `add` [Name | {name, dob}] hängt ein Jungtier an DIESEN Wurf (Gegenstück zu `keep`):
# nötig, wenn die Chart-Positions-Heuristik ein Jungtier in einen falschen (virtuellen)
# Stammbaum-Wurf gezogen hat, obwohl die Wurfchronik es einem echten Wurf zuordnet
# (Ticket 36a3fcde/88389f8e: Akane gehört in den Z21-Wurf Roni × Fumi). Der vorherige Wurf
# wird entfernt, wenn er dadurch ein kinderloser VIRTUELLER Wurf wird — sonst blieben
# Phantom-Würfe in der Wurfchronik stehen.
try:
with open(CONFLICT_DECISIONS_PATH, encoding="utf-8") as _lcfh:
_litter_children = (json.load(_lcfh).get("litterChildren") or [])
except Exception:
_litter_children = []
def _drop_if_empty_virtual(litter_id):
"""Entfernt einen VIRTUELLEN Wurf, der kein Jungtier mehr hat (Phantom-Wurf aus der
Chart-Positions-Heuristik). Echte Wurfchronik-Würfe bleiben immer erhalten — die haben
legitim keine namentlich erfassten Jungtiere."""
if not litter_id:
return 0
if any(g.get("LitterId") == litter_id for g in resolved_gerbils):
return 0
for i, cand in enumerate(resolved_litters):
if cand["Id"] != litter_id:
continue
ext = cand.get("ExternalRef") or ""
if cand.get("_virtual") or "-virtual-" in ext or ext.startswith("override-virtual-litter"):
del resolved_litters[i]
return 1
return 0
return 0
_lc_removed = 0
_lc_added = 0
_lc_dropped = 0
for lc in _litter_children:
lc_date = parse_date(lc["date"]) if lc.get("date") else None
match_child = normalize_name(get_call_name(lc.get("matchChild") or ""))
keep = {normalize_name(get_call_name(n)) for n in (lc.get("keep") or [])}
add = lc.get("add") or []
if not keep and not add:
continue
for l in resolved_litters:
if lc_date and l.get("Date") != lc_date:
continue
kids = [g for g in resolved_gerbils if g.get("LitterId") == l["Id"]]
names = {normalize_name(get_call_name(g.get("Name") or "")) for g in kids}
if match_child and match_child not in names:
continue # falscher, nur zufällig gleich-datierter Wurf
if keep:
for g in kids:
if normalize_name(get_call_name(g.get("Name") or "")) not in keep:
g["LitterId"] = None
_lc_removed += 1
for entry in add:
a_name = entry.get("name") if isinstance(entry, dict) else entry
a_dob = entry.get("dob") if isinstance(entry, dict) else None
child = _find_final_gerbil(a_name, a_dob)
if not child or child.get("LitterId") == l["Id"]:
continue
prev_litter_id = child.get("LitterId")
child["LitterId"] = l["Id"]
_lc_added += 1
_lc_dropped += _drop_if_empty_virtual(prev_litter_id)
if lc.get("renameTo"):
l["Name"] = lc["renameTo"]
break
if _lc_removed:
print(f"litterChildren: {_lc_removed} fälschlich zugeordnete Jungtiere aus Würfen gelöst")
if _lc_added:
print(f"litterChildren: {_lc_added} Jungtier(e) dem korrekten Wurf zugeordnet "
f"({_lc_dropped} leerer Phantom-Wurf/Würfe entfernt)")
# ── isResident-Sweep (Ticket 381f7e51, freigegeben) ───────────────────────
# Entscheidung der Züchterin: „Nimm wirklich ALLE Tiere aus dem Bestand heraus,
# MIT AUSNAHME der, die bereits ein ELTERNTIER sind." Datengetrieben + re-ingest-
@@ -4362,6 +4580,12 @@ def main():
if _origin_fixed:
print(f"Herkunft normalisiert auf 'Zucht der Kleinen Chaoten': {_origin_fixed} Tier(e)")
# ── Umbenennungs-Sweep in Freitext-Notizen (Tickets ea803c3d/24006ccd) ──
# Finaler Anzeige-Sweep NACH aller Logik — s. apply_rename_note_sweep().
_notes_fixed = apply_rename_note_sweep(resolved_gerbils, resolved_litters)
if _notes_fixed:
print(f"renameTo-Notiz-Sweep: {_notes_fixed} Notiz(en) auf den korrigierten Namen gesetzt")
# 6. Save final output JSON payload
os.makedirs(OUTPUT_DIR, exist_ok=True)
payload = {

View File

@@ -4,15 +4,15 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
## Überblick
- Rohe Tier-Einträge aus den Stammbäumen: **2548**
- Nach Zusammenführung (eindeutige Tiere): **1042**
- davon mit Geburtsdatum: 683
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 477
- Konflikte zur Klärung: **8**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **380**
- Fotos zugeordnet: **435**
- Rohe Tier-Einträge aus den Stammbäumen: **3603**
- Nach Zusammenführung (eindeutige Tiere): **1487**
- davon mit Geburtsdatum: 771
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 545
- Konflikte zur Klärung: **19**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **757**
- Fotos zugeordnet: **510**
- Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 174, nur über Geburtsdatum: 96; mehrdeutig: 16)
- Tiere mit Wurf verknüpft: **292** (davon über Geburtsdatum **und** Eltern: 188, nur über Geburtsdatum: 104; mehrdeutig: 18)
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
## Zusammenführungs-Schlüssel
@@ -24,6 +24,7 @@ Tiere wurden zusammengeführt über **normalisierter Rufname + Geburtsdatum**, m
| Tier | Geburtsdatum | Zuchten | Dateien |
|---|---|---|---|
| Blacky | 23.07.2009 | PZ Seligenstadt // Privatzucht Seligenstadt | Stammbaum von Danako, Stammbaum von Hana, Stammbaum von Jin, Stammbaum von Unit, Stammbaum von Uriana, Stammbaum von Wildfire und Vestras Kids |
| Dark Henry | 10.07.2022 | Kleinen Chaoten (extern) // Zucht der Kleinen Chaoten (extern SC) | Stammbaum von Fire Kids, Stammbaum von Kids von dd Algierfuchs & Sa, Stammbaum von Sa Sp (YaraFire) & Sa Kids, Stammbaum von Valentino Firehearts Kids |
| Danny | 09.10.2009 | Maintaler PZ // PZ Maintal | Stammbaum von Hana, Stammbaum von Vance |
| Lola | 10.02.2014 | Chalfont Stud, Freddy Braun // Lennylengo | Stammbaum von Jeremy, Stammbaum von Jiminy of Black Forest, Stammbaum von Marlin of Black Forest, Stammbaum von Zac (Vance.Dorie), Stammbaum von Zenon von Elea |
| Debby gen. Eva | 08.08.2012 | KK Chaos // KK Chaos of KK Chaos | Stammbaum von Kalea, Stammbaum von Unit, Stammbaum von Uriana |
@@ -34,18 +35,29 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|---|---|---|---|---|---|
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 // 31.01.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Ella, Stammbaum von Picus Son, Stammbaum von Valentino Firehearts Kids |
| Lui von den Kleinen Chaoten | 16.04.2021 | Aa Cc[chm] DD ee[f] Gg PP Spsp // Aa c[chm]c[chm] D- Ee[f] Gg PP spsp | — | 15.12.2020 | Stammbaum von Alberto Kids, Stammbaum von Picus Son |
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa C D- ee[f] GG PP spsp // Aa Cc[chm] D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 // 31.01.2023 | Stammbaum von Akio Kids, Stammbaum von Blitzis Kids, Stammbaum von CP Mädels von Grisu, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von CP-Polarfuchs von Grisu, Stammbaum von Ella, Stammbaum von Picus Son, Stammbaum von Sa von Johnny Kids, Stammbaum von Valentino Firehearts Kids |
| Fatou gen. Sam von den Kleinen Chaoten | 03.02.2020 | aa Cc[chm] DD Ee gg Pp spsp // aa c[chm]c[h] D- Ee GG P- Spsp | — | 19.03.2022 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Kid von Cassy & Blaster, Stammbaum von Pukas Kids, Stammbaum von Sa Sp (YaraFire) & Sa Kids |
| Charly of Golden Lights | 05.04.2016 | aa c[chm]c[chm] D- Ee Gg P- Spsp // aa c[chm]c[chm] DD Ee GG P- Spsp | — | — | Stammbaum von Akio Kids, Stammbaum von Kid von Cassy & Blaster |
| Xtra Bounty von den Schlossmäusen | 20.03.2014 | aa c[h]c[h] DD Ee Gg PP spsp // aa c[h]c[h] DD Ee gg PP spsp | — | 01.05.2018 | Stammbaum von Akio Kids, Stammbaum von Kid von Cassy & Blaster |
| Louis of Black Forest | 22.08.2019 | Aa CC dd EE Gg P- spsp | — | 09.04.2024 // 10.04.2024 | Stammbaum von Alberto Kids, Stammbaum von CP Mädels von Grisu, Stammbaum von CP-Polarfuchs von Grisu, Stammbaum von Kid von Cassy & Blaster, Stammbaum von Kids von dd Algierfuchs & Sa, Stammbaum von Pukas Kids, Stammbaum von Rj, Stammbaum von Sa Sp (YaraFire) & Sa Kids |
| Inochi gen. Picu von den Kleinen Chaoten | 11.07.2020 | Aa CC dd Ee GG Pp spsp // Aa CC dd Ee Gg Pp spsp | Dilute Agouti | 11.03.2025 // 12.03.2025 | Stammbaum von Alberto Kids, Stammbaum von Kids von dd Algierfuchs & Sa, Stammbaum von Picus Son, Stammbaum von Rj |
| Snickers of Black Forest | 21.09.2018 | Aa Cc[chm] dd Ee Gg P- spsp // Aa Cc[chm] dd Ee gg Pp Spsp | — | 04.04.2023 // 04.11.2022 | Stammbaum von Alberto Kids, Stammbaum von CP Mädels von Grisu, Stammbaum von CP-Polarfuchs von Grisu, Stammbaum von Jiminy of Black Forest, Stammbaum von Kid von Cassy & Blaster, Stammbaum von Kids von dd Algierfuchs & Sa, Stammbaum von Picus Son, Stammbaum von Pukas Kids, Stammbaum von Rj, Stammbaum von Sa Sp (YaraFire) & Sa Kids, Stammbaum von Stella Kids |
| Lui von den Kleinen Chaoten | 16.04.2021 | Aa Cc[chm] DD ee[f] Gg PP Spsp // Aa c[chm]c[chm] D- Ee[f] Gg PP spsp | — | 15.12.2020 | Stammbaum von Alberto Kids, Stammbaum von Blitzis Kids, Stammbaum von Kids von dd Algierfuchs & Sa, Stammbaum von Picus Son, Stammbaum von Rj |
| Odette von den Kleinen Chaoten | 22.02.2021 | 2, AA c[chm]c[chm] DD Ee Gg PP Spsp // AA c[chm]c[chm] DD Ee Gg Pp Spsp DP | — | 30.02.2020 // 30.12.2024 | Stammbaum von Blitzis Kids |
| Marlin of Black Forest | 28.05.2019 | Aa C- DD Ee Gg Pp spsp // Aa Cc[chm] DD Ee Gg Pp Spsp // Aa Cc[chm] DD Ee Gg Pp spsp | Agouti | 18.11.2023 | Stammbaum von Blitzis Kids, Stammbaum von Fire Kids, Stammbaum von Kids von dd Algierfuchs & Sa, Stammbaum von Marlin of Black Forest, Stammbaum von Sa Sp (YaraFire) & Sa Kids, Stammbaum von Valentino Firehearts Kids |
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Kid von Cassy & Blaster, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Rj, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| Taro von den kleinen Chaoten | 01.09.2012 | Aa Cc[chm] D- Ee Gg Pp spsp // aa Cc[chm] DD EE GG PP spsp | — | 01.10.2015 // 21.09.2012 | Stammbaum von Danako, Stammbaum von Kalea |
| Quebec v.d. Kleinen Chaoten | 21.06.2014 | aa Cc[chm] dd Ee Gg Pp spsp // aa Cc[chm] dd Ee Uwuw[d] Pp spsp // aa Cc[chm] dd Ee gg Pp spsp | — | 21.10.2016 | Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| | 20.04.2024 | Aa C- dd Ee Gg P- Spsp | Dilute Agouti Kragenschecke // Dilute Kohlfuchs Kragenschecke DP | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
| Hanami | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 // 14.01.2020 | Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Stella Kids, Stammbaum von Vance, Stammbaum von Zac (Vance.Dorie) |
| | 13.08.2025 | Aa C- D- ee[f] G(G) pp Spsp // aa Cc[chm] D- ee[f] Gg Pp Spsp | Goldfuchsschimmel Kragenschecke // Kohlfuchsschimmel, hell | — | Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
| Little Runner's Destiny | 02.03.2019 | aa Cc[chm] DD ee Gg PP Spsp // aa Cc[chm] DD ee Gg Pp Spsp | — | — | Stammbaum von Goldfuchs Sp DP, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Sa von Johnny Kids |
| | 01.08.2025 | AA CC DD EE gg PP spsp // Aa Cc[chm] D- Ee gg P- Spsp | Silberagouti // Silberagouti Punktschecke | — | Stammbaum von Kids von dd Algierfuchs & Sa, Stammbaum von Sa Sp (YaraFire) & Sa Kids |
| | 26.02.2026 | Aa C-D- Ee[-] Gg pp spsp // Aa C-D- Ee[-] gg P- spsp // Aa C-D- ee[-] Gg P- spsp | Algierfuchs // Gold // Silberagouti | — | Stammbaum von Kohlief und Johnny Jumper Kids, Stammbaum von Sa von Johnny Kids |
| | 13.08.2025 | Aa C- D- ee[f] G(G) pp Spsp // aa Cc[chm] D- ee[f] Gg Pp Spsp | Goldfuchsschimmel Kragenschecke // Kohlfuchsschimmel, hell | — | Stammbaum von Kohlief und Johnny Jumper Kids, Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
| Gold Sp (RV) | 16.01.2023 | Aa CC D- Ee Gg pp Spsp [DP] // aa c[chm]c[chm] D- EE GG P- Spsp | — | — | Stammbaum von Kohlief und Johnny Jumper Kids, Stammbaum von Sa von Johnny Kids |
## Mehrdeutige / unvollständige Einträge
380 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt.
757 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt.
- Oskar v.d. bunten Fellnase · Stammbaum von Akio Kids.xlsx
- Raya v.d. bunten Fellnasen · Stammbaum von Akio Kids.xlsx
@@ -69,14 +81,6 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- Katara · Stammbaum von Akio Kids.xlsx
- Jack Jr. · Stammbaum von Akio Kids.xlsx
- Nala · Stammbaum von Akio Kids.xlsx
- Brandon Stark · Stammbaum von Akio Kids.xlsx
- Enya · Stammbaum von Akio Kids.xlsx
- Oscar of Black Forest · Stammbaum von Akio Kids.xlsx
- Naho · Stammbaum von Akio Kids.xlsx
- Hagrid Rubeus of Black Forest · Stammbaum von Akio Kids.xlsx
- Lilo of LennyLengo · Stammbaum von Akio Kids.xlsx
- Kazuya · Stammbaum von Akio Kids.xlsx
- Rainny · Stammbaum von Akio Kids.xlsx
- Eddward · Stammbaum von Akio Kids.xlsx
- Harumi · Stammbaum von Akio Kids.xlsx
- Marc Sloan · Stammbaum von Akio Kids.xlsx
@@ -87,10 +91,18 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- Isa of Golden Lights · Stammbaum von Akio Kids.xlsx
- Mystique of Black Forest · Stammbaum von Alberto Kids.xlsx
- Verpaarung von Fleur · Stammbaum von Alberto Kids.xlsx
- Pinto (RV) · Stammbaum von Alberto Kids.xlsx
- Verpaarung von Hanami · Stammbaum von Alberto Kids.xlsx
- Robb Stark · Stammbaum von Alberto Kids.xlsx
- Verpaarung von Isabel · Stammbaum von Alberto Kids.xlsx
- Tyrion Lannister · Stammbaum von Alberto Kids.xlsx
- Verpaarung von Fleur · Stammbaum von Alberto Kids.xlsx
- Pinto (RV) · Stammbaum von Alberto Kids.xlsx
- Verpaarung von Nala (Exidra) · Stammbaum von Alberto Kids.xlsx
## Wahrscheinliche Zuordnungen unvollständiger Einträge
98 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
245 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
- „Tai of Lennylengo“ → Tai of Lennylengo (*01.10.2011)
- „Arrow PZ Niederlande“ → Arrow PZ Niederlande (*20.05.2014)
@@ -98,9 +110,6 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Leila v. Jessica Walldorf“ → Leila v. Jessica Walldorf (*08.04.2014)
- „Osamu“ → Osamu (*10.12.2015)
- „Nala“ → Nala (*06.03.2015)
- „Oscar of Black Forest“ → Oscar of Black Forest (*12.06.2019)
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
- „Harumi“ → Harumi (*21.02.2015)
- „Pan“ → Pan (*09.12.2014)
- „Gin“ → Gin (*05.02.2015)
@@ -109,6 +118,41 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Elay“ → Elay (*16.03.2016)
- „Osamu“ → Osamu (*10.12.2015)
- „Baiko“ → Baiko (*02.06.2019)
- „Raya von den bunten Fellnasen“ → Raya von den bunten Fellnasen (*02.02.2013)
- „Raya von den bunten Fellnasen“ → Raya von den bunten Fellnasen (*02.02.2013)
- „Hanse Renner's Poseidon“ → Hanse Renner's Poseidon (*14.08.2014)
- „Yuki“ → Yuki (*02.07.2023); Yuki (*02.09.2019); Yuki (*10.07.2022)
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Osamu“ → Osamu (*10.12.2015)
- „Umi“ → Umi (*13.08.2016)
- „Chevrolet Camaro of Topolino“ → Chevrolet Camaro of Topolino (*10.04.2018)
- „Stich von Privatzucht Gießen“ → Stich von Privatzucht Gießen (*01.09.2018)
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
- „Baiko“ → Baiko (*02.06.2019)
- „Blue Wave of Fiomi“ → Blue Wave of Fiomi (*22.09.2018)
- „Giulietta gen. Gulie of Black Forest“ → Giulietta gen. Gulie of Black Forest (*30.01.2020)
- „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016)
- „Yurika“ → Yurika (*27.11.2017)
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
- „Nala“ → Nala (*06.03.2015)
- „Osamu“ → Osamu (*10.12.2015)
- „Umi“ → Umi (*13.08.2016)
- „Pauline von den Schlossmäusen“ → Pauline von den Schlossmäusen (*31.07.2018)
- „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016)
- „Osamu“ → Osamu (*10.12.2015)
- „Umi“ → Umi (*13.08.2016)
- „Stich von Privatzucht Gießen“ → Stich von Privatzucht Gießen (*01.09.2018)
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
- „Osamu“ → Osamu (*10.12.2015)
- „Umi“ → Umi (*13.08.2016)
- „Dorie of Black Forest“ → Dorie of Black Forest (*28.05.2019)
- „Zadar from Zeko i ptica, Croatia“ → Zadar from Zeko i ptica, Croatia (*12.04.2019)
- „Hana“ → Hana (*14.10.2018)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Rosie of LennyLengo“ → Rosie of LennyLengo (*15.03.2016)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Rosie of LennyLengo“ → Rosie of LennyLengo (*15.03.2016)
- „Hagrid Rubeus of Black Forest“ → Hagrid Rubeus of Black Forest (*18.07.2019)
- „Osamu“ → Osamu (*10.12.2015)
- „Umi“ → Umi (*13.08.2016)
@@ -119,39 +163,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
- „Gin“ → Gin (*05.02.2015)
- „Isa of golden lights“ → Isa of Golden Lights (*24.12.2014)
- „Porter“ → Porter (*23.05.2015)
- „Chelsea von den Kleinen Chaoten“ → Chelsea von den Kleinen Chaoten (*02.04.2021)
- „Pinto of Fiomi“ → Pinto of Fiomi (*28.08.2016)
- „Nala“ → Nala (*06.03.2015)
- „Hanami“ → Hanami (*10.09.2015)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Osamu“ → Osamu (*10.12.2015)
- „Baiko“ → Baiko (*02.06.2019)
- „Olivia“ → Olivia (*23.07.2019)
- „Baiko“ → Baiko (*02.06.2019)
- „Olivia“ → Olivia (*23.07.2019)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Rosie of LennyLengo“ → Rosie of LennyLengo (*15.03.2016)
- „Pan“ → Pan (*09.12.2014)
- „Gin“ → Gin (*05.02.2015)
- „Chiako“ → Chiako (*15.11.2016)
- „Stich von Privatzucht Gießen“ → Stich von Privatzucht Gießen (*01.09.2018)
- „Lilo of LennyLengo“ → Lilo of LennyLengo (*04.11.2018)
- „Ken'ichi“ → Ken'ichi (*01.03.2015)
- „Rumi“ → Rumi (*24.02.2017)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Rosie of LennyLengo“ → Rosie of LennyLengo (*15.03.2016)
- „Living Force's Idefix“ → Living Force's Idefix (*05.04.2016)
- „Rosie of LennyLengo“ → Rosie of LennyLengo (*15.03.2016)
- „Inusch of Black Forest“ → Inusch of Black Forest (*25.10.2017)
- „Little Hero of Black Forest“ → Little Hero of Black Forest (*22.02.2018)
- „Little Runner's Destiny“ → Little Runner's Destiny (*02.03.2019)
- „Chevrolet Camaro of Topolino“ → Chevrolet Camaro of Topolino (*10.04.2018)
- „Danako“ → Danako (*22.08.2018)
- „Marlin of Black Forest“ → Marlin of Black Forest (*28.05.2019)
- „Zenon von Elea“ → Zenon von Elea (*06.10.2019)
- „Nisha of Black Forest“ → Nisha of Black Forest (*28.03.2018)
- „Zenon von Elea“ → Zenon von Elea (*06.10.2019)
- „Nisha of Black Forest“ → Nisha of Black Forest (*28.03.2018)
## Nicht ins 8-Loci-Modell abgebildete Tokens (verbatim erhalten)
@@ -169,6 +181,8 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `!` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) |
| `/18.02.2020` | 2 | ? |
| `chmchm` | 2 | Schreibweise (c[chm]c[chm]) |
| `C-D-` | 2 | ? |
| `(KW` | 2 | ? |
| `[WFNZ-Maroon]` | 1 | ? |
| `[meliert]-[DP]` | 1 | ? |
@@ -180,10 +194,8 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `!Niereninsuffizienz!` | 1 | ? |
| `7+09.07.2017` | 1 | ? |
| `+2018` | 1 | ? |
| `(F5-Wurf)` | 1 | ? |
| `/05.06.2014` | 1 | ? |
| `AAA` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
| `c[chm]chm]` | 1 | ? |
## Wurfchronik — Datenqualitäts-Hinweise

View File

@@ -485,6 +485,70 @@ check("gender override: Mozart flipped male -> female", _g_merged[0]["gender"] =
try: os.remove(_g_dec)
except OSError: pass
# --- EMF/WMF-Vorschauen sind keine Fotos (neue Stammbäume 2026-08-18) ---
# Excel legt neben dem echten Foto teils ein Vektor-Metafile ab; Browser können es nicht
# darstellen → es landete als kaputte Bildkachel in der Tier-Akte.
_emf_chart = os.path.join(r"C:\Users\gulum\dev\Sttammbäume",
"Stammbaum von Kohlief und Johnny Jumper Kids.xlsx")
if os.path.exists(_emf_chart):
_emf_animals = e.extract_stammbaum(_emf_chart)
_emf_photos = [ph for a in _emf_animals for ph in a["photos"]
if ph.lower().endswith((".emf", ".wmf"))]
check("EMF/WMF werden nicht als Foto angehängt", _emf_photos == [])
else:
print("\nWarning: EMF-Stammbaum nicht gefunden, EMF-Check übersprungen.")
# --- sternloses Geburtsdatum im Block (Ticket 65266679 Bijou) ---
# Einige Charts vergessen den Stern ("22.08.2019/+09.04.2024"); ohne Stern erkannte die
# Blockheuristik das Tier gar nicht und _reconstruct_parents griff in den Nachbar-Ast.
# Normalisiert wird NUR mit Block-Kontext (Genotyp darunter UND Name darueber), damit
# beliebige Datumszellen nicht faelschlich zu Tieren werden.
_tmp_star = os.path.join(tempfile.gettempdir(), "starless_dob.xlsx")
_make_xlsx(_tmp_star, {
# echter Block, aber ohne Stern vor dem Geburtsdatum (Quelldatei-Tippfehler)
"K44": "Louis of Black Forest",
"K45": "22.08.2019/+09.04.2024",
"K46": "Aa CC dd EE Gg P- spsp",
# Datum mit Name darueber, aber OHNE Genotyp darunter -> kein Block
"N44": "Irgendeine Notiz",
"N45": "01.01.2020",
# Datum mit Genotyp darunter, aber OHNE Namenszelle darueber -> kein Block
"Q45": "02.02.2021",
"Q46": "aa CC DD EE GG PP spsp",
})
try:
_star_animals = e.extract_stammbaum(_tmp_star)
_star_by_name = {a["name"]: a for a in _star_animals}
check("sternloses DOB wird als Block erkannt", "Louis of Black Forest" in _star_by_name)
check("sternloses DOB: Geburts- und Todesdatum geparst",
_star_by_name.get("Louis of Black Forest", {}).get("dob") == "22.08.2019"
and _star_by_name.get("Louis of Black Forest", {}).get("death") == "09.04.2024")
check("Datumszelle ohne Block-Kontext wird NICHT zum Tier",
len(_star_animals) == 1)
finally:
try: os.remove(_tmp_star)
except OSError: pass
# Direkt auf der Normalisierung: Todesdatum-only-Zellen ("/+01.09.2024", kommt in
# "Stammbaum von Kentucky/Watarus Kids" vor) duerfen NIE zum Geburtsdatum werden.
_cells = {
(11, 45): "22.08.2019/+09.04.2024", # Block -> bekommt den Stern
(11, 44): "Louis of Black Forest",
(11, 46): "Aa CC dd EE Gg P- spsp",
(14, 66): "/+01.09.2024", # nur Todesdatum -> unangetastet
(14, 65): "Nani of Black Forest",
(14, 67): "Aa CC dd EE Gg P- spsp",
(17, 30): "30.01.2020/+", # Block, Todesdatum leer -> bekommt den Stern
(17, 29): "Jiminy of Black Forest",
(17, 31): "Aa Cc[chm] Dd ee gg P- Spsp",
}
e._normalise_starless_dobs(_cells)
check("Normalisierung setzt den Stern im Block", _cells[(11, 45)] == "*22.08.2019/+09.04.2024")
check("Normalisierung setzt den Stern auch bei leerem Todesdatum",
_cells[(17, 30)] == "*30.01.2020/+")
check("Normalisierung laesst reine Todesdatum-Zellen unberuehrt",
_cells[(14, 66)] == "/+01.09.2024")
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)

View File

@@ -124,6 +124,23 @@ check("ef/e het -> Fuchsschimmel family (Kohlfuchsschimmel)",
fs("aa CC DD ee[f] GG PP spsp") == "Kohlfuchsschimmel")
# Unknown allele = copy of the visible partner (GEN-5): A? -> AA, D? -> DD.
check("unknown copies known: AA C? DD ee GG pp -> Goldfuchs", fs("AA C- DD ee GG pp spsp") == "Goldfuchs")
# GEN-6 / Tickets bde4ec70 + f89e95ad — C-Locus-ZYGOTIE: Platin, Saphir und
# Platin-Hell unterscheiden sich NUR im C-Locus. Zuechterin autoritativ: „Saphir ist
# aa Cc[chm] D- E- G- pp und Platin ist aa CC D- E- G- pp. Es ist nicht dasselbe!"
# Vorher trugen alle drei identische Katalog-Tokens (C:'C') -> immer Platin.
check("bde4ec70: aa CC pp -> Platin (zwei volle C)", fs("aa CC DD EE GG pp spsp") == "Platin")
check("f89e95ad: aa Cc[chm] pp -> Saphir", fs("aa Cc[chm] DD EE GG pp spsp") == "Saphir")
check("bde4ec70 JackJack: aa Cc[chm] DD E- G- pp Spsp -> Saphir (Basisname)",
fs("aa Cc[chm] DD E- G- pp Spsp") == "Saphir")
check("GEN-3g: aa Cc[h] pp -> Platin-Hell", fs("aa Cc[h] DD EE GG pp spsp") == "Platin-Hell")
# Ticket 2322c2a8 (Jay *24.10.2021) — 'c[hm]' ist ein verschriebenes 'c[chm]'
# (fehlendes c) in den Quell-Charts; ohne Alias kippt der Farbschlag auf Zobel-Hell.
check("2322c2a8: aa c[chm]c[hm] gg -> Zobel (nicht Zobel-Hell)",
fs("aa c[chm]c[hm] Dd Ee gg P- spsp") == "Zobel")
check("2322c2a8: c[hm]-Alias landet im C-Locus als c^chm",
g.parse("aa c[chm]c[hm] Dd Ee gg P- spsp")["mapped8locus"]["C"] == ["c^chm", "c^chm"])
check("2322c2a8: Cc[hm] bleibt c[chm]-Traeger -> Saphir",
fs("aa Cc[hm] DD EE GG pp spsp") == "Saphir")
# An incomplete parse must NOT throw and must not be invented as a real colour.
check("empty mapping -> Unbekannt", g.genotype_to_farbschlag({}) == g.UNKNOWN_FARBSCHLAG)

View File

@@ -537,6 +537,169 @@ check("excludeContractMatch: control (no exclude) -> receiver IS set",
_excl_gerbils2[0].get("ReceiverContactId") is not None)
# ── parse_date: Plausibilitaets-Guard (Ticket 24522f5f, JackJack) ─────────────
# Die Wurfchronik-Transkription baute aus „13.5.10" das Datum „1310-05-13" — solche
# unmoeglichen Jahre muessen verworfen werden, ohne die 19xx→20xx-Normalisierung
# („13.5.10" → 2010-05-13) zu beschaedigen.
check("parse_date: implausibles Jahr 1310 (ISO) -> None",
m.parse_date("1310-05-13") is None)
check("parse_date: implausibles Jahr 1310 (DD.MM.YYYY) -> None",
m.parse_date("13.05.1310") is None)
check("parse_date: 2-stelliges Jahr bleibt intakt ('13.5.10' -> 2010-05-13)",
m.parse_date("13.5.10") == "2010-05-13")
check("parse_date: 19xx wird weiter auf 20xx normalisiert",
m.parse_date("22.12.1924") == "2024-12-22")
check("parse_date: regulaeres Datum unveraendert",
m.parse_date("22.12.2024") == "2024-12-22")
check("parse_date: fernes Zukunftsjahr -> None",
m.parse_date("2999-01-01") is None)
# ── renameTo-Notiz-Sweep (Tickets ea803c3d/24006ccd, Kruke→Kuke) ──────────────
# `renameTo` korrigiert nur das Namensfeld; der alte Name blieb in den aus der
# Wurfchronik uebernommenen Freitext-Notizen stehen. Der finale Sweep ersetzt ihn
# wortgenau (an Wortgrenzen) durch den RUFNAMEN des neuen Namens.
_sw_gerbils = [
{"Name": "Kuke von Marion Knoss", "_pre_rename_name": "Kruke",
"Notes": "Krukelinde bleibt; Kruke wird ersetzt"},
{"Name": "Blacky", "Notes": "Vater von Kruke-Kindern"},
]
_sw_litters = [
{"Notes": "Blacky + Kruke v. 21.09.2010; WS=4"},
{"Notes": None},
{"Notes": "Eltern: Blacky + Bonnie"},
]
_sw_n = m.apply_rename_note_sweep(_sw_gerbils, _sw_litters)
check("Notiz-Sweep: Wurf-Notiz traegt den neuen Rufnamen",
_sw_litters[0]["Notes"] == "Blacky + Kuke v. 21.09.2010; WS=4")
check("Notiz-Sweep: Rufname (nicht der volle Zuchtname) wird eingesetzt",
"von Marion Knoss" not in _sw_litters[0]["Notes"])
check("Notiz-Sweep: Teilwort 'Krukelinde' bleibt unangetastet",
_sw_gerbils[0]["Notes"] == "Krukelinde bleibt; Kuke wird ersetzt")
check("Notiz-Sweep: auch Gerbil-Notizen anderer Tiere werden gefixt",
_sw_gerbils[1]["Notes"] == "Vater von Kuke-Kindern")
check("Notiz-Sweep: unbeteiligte Notizen/None bleiben unveraendert",
_sw_litters[1]["Notes"] is None
and _sw_litters[2]["Notes"] == "Eltern: Blacky + Bonnie")
check("Notiz-Sweep: Zaehler = Zahl der geaenderten Notizen", _sw_n == 3)
# Kein _pre_rename_name → kein Sweep.
_sw_none = [{"Name": "Kuke", "Notes": "Kruke stand hier nie zur Debatte"}]
check("Notiz-Sweep: ohne renameTo passiert nichts",
m.apply_rename_note_sweep(_sw_none, []) == 0)
# Guards gegen Kollateralschaden (alle vier im ersten Probelauf real aufgetreten):
# (1) Zu kurzer Quellname („M") darf keine Abkuerzungen im Freitext treffen.
_sw_short = [{"Name": "JD", "_pre_rename_name": "M",
"Notes": "Andreas B.; 60g; M: 17.3.19"},
{"Name": "Milly", "Notes": "Abgabe an Natascha M."}]
check("Notiz-Sweep: Quellname < 4 Zeichen wird ignoriert",
m.apply_rename_note_sweep(_sw_short, []) == 0
and _sw_short[1]["Notes"] == "Abgabe an Natascha M.")
# (2) Mehrwortige Phrase (Zucht-Sentinel als Quellname) wird ignoriert.
_sw_phrase = [{"Name": "Namenlose Tochter von Unity",
"_pre_rename_name": "Zucht der Kleinen Chaoten",
"Notes": "Herkunft: Zucht der Kleinen Chaoten"}]
check("Notiz-Sweep: mehrwortige Phrase als Quellname wird ignoriert",
m.apply_rename_note_sweep(_sw_phrase, []) == 0)
# (3) Alter Name steckt im neuen Rufnamen → keine Verdopplung des Zusatzes.
_sw_dbl = [{"Name": "Missy gen. Krümmel", "_pre_rename_name": "Missy",
"Notes": "Kinder: Xaki, Rover, Missy gen. Krümmel"}]
check("Notiz-Sweep: kein Verdoppeln, wenn der alte Name im neuen Rufnamen steckt",
m.apply_rename_note_sweep(_sw_dbl, []) == 0
and _sw_dbl[0]["Notes"] == "Kinder: Xaki, Rover, Missy gen. Krümmel")
# (4) Notizen, die die Umbenennung selbst dokumentieren, behalten den alten Namen.
_sw_doc = [{"Name": "Gale von den Kleinen Chaoten", "_pre_rename_name": "Drake Jr",
"Notes": "Hieß ursprünglich Drake Jr, wurde später in Gale umbenannt."}]
_sw_doc_litters = [{"Notes": "Parents: Jacky + Drake Jr"}]
_sw_doc_n = m.apply_rename_note_sweep(_sw_doc, _sw_doc_litters)
check("Notiz-Sweep: Umbenennungs-Dokumentation bleibt erhalten",
_sw_doc[0]["Notes"] == "Hieß ursprünglich Drake Jr, wurde später in Gale umbenannt.")
check("Notiz-Sweep: normale Notiz desselben Paares wird trotzdem gefixt",
_sw_doc_litters[0]["Notes"] == "Parents: Jacky + Gale" and _sw_doc_n == 1)
# ── Decision-Overrides: goHomeDate / genotype (autoritativ) / spottingType ────
# Tickets 24522f5f (JackJack: kaputtes Abgabedatum), 7037f5d8 (Merle: Abgabe zieht
# Status GivenAway nach), a547be62 (Eliza/Chris: Gencode + Scheckungsart).
def _ovr(gerbils, decisions):
return m.apply_decision_overrides(gerbils, decisions, {}, None)
# (1) goHomeDate ist AUTORITATIV — ueberschreibt auch einen (kaputten) Bestandswert.
_g_jj = {"Id": "1", "ExternalRef": "wc-0002", "Name": "JackSack",
"DateOfBirth": "2010-02-18", "Status": "Deceased",
"GoHomeDate": "1310-05-13"}
_ovr([_g_jj], [{"externalRef": "wc-0002", "goHomeDate": "13.05.2010"}])
check("goHomeDate: kaputtes Abgabedatum wird ueberschrieben",
_g_jj["GoHomeDate"] == "2010-05-13")
check("goHomeDate: Status Deceased bleibt Deceased",
_g_jj["Status"] == "Deceased")
# (2) goHomeDate zieht bei nicht-verstorbenen Tieren Status GivenAway nach (Merle).
_g_merle = {"Id": "2", "ExternalRef": "stammbaum-merle-18062023", "Name": "Merle",
"DateOfBirth": "2023-06-18", "Status": "Breeding", "IsResident": True}
_ovr([_g_merle], [{"name": "Merle", "dob": "18.06.2023",
"externalRef": "stammbaum-merle-18062023",
"goHomeDate": "22.12.2024"}])
check("goHomeDate: Abgabedatum gesetzt", _g_merle["GoHomeDate"] == "2024-12-22")
check("goHomeDate: Status wird GivenAway", _g_merle["Status"] == "GivenAway")
check("goHomeDate: Residenz bleibt unangetastet", _g_merle["IsResident"] is True)
# (3) Unplausibles Datum in der Entscheidung wird verworfen (kein Status-Wechsel).
_g_bad = {"Id": "3", "ExternalRef": "wc-9", "Name": "Bad", "Status": "Breeding"}
_ovr([_g_bad], [{"externalRef": "wc-9", "goHomeDate": "13.05.1310"}])
check("goHomeDate: implausibles Datum -> kein Abgabedatum, kein Status-Wechsel",
_g_bad.get("GoHomeDate") is None and _g_bad["Status"] == "Breeding")
# (4) Gencode-Override ist bei PRAEZISEM Match autoritativ (Eliza: der Bestandswert
# ist nur der Kanon-Fill des Farbschlags) und setzt zusaetzlich die Scheckungsart.
_g_eliza = {"Id": "4", "ExternalRef": "page_0013.md-g1000000-011", "Name": "Eliza",
"DateOfBirth": "2010-08-20", "Genotype": "aa CC DD EE GG PP Spsp rere"}
_ovr([_g_eliza], [{"externalRef": "page_0013.md-g1000000-011",
"genotype": "aa C- DD E- G- P- Spsp",
"spottingType": "Ansatzschecke"}])
check("genotype: praeziser Override ueberschreibt den Kanon-Fill",
_g_eliza["Genotype"] == "aa C- DD E- G- P- Spsp")
check("spottingType: Scheckungsart per Entscheidung gesetzt",
_g_eliza["SpottingType"] == "Ansatzschecke")
# (5) name+dob ist ebenfalls praezise, ein Namensvetter mit anderem dob nicht.
_g_named = {"Id": "5", "Name": "Chris", "DateOfBirth": "2010-08-20",
"Genotype": "aa CC DD EE gg PP Spsp rere"}
_g_cousin = {"Id": "6", "Name": "Chris of Elsewhere", "DateOfBirth": "2014-01-01",
"Genotype": "AA CC DD EE GG PP spsp"}
_ovr([_g_named, _g_cousin], [{"name": "Chris", "dob": "20.08.2010",
"genotype": "aa Cc[-] DD Ee gg P- Spsp"}])
check("genotype: name+dob-Match ist praezise und ueberschreibt",
_g_named["Genotype"] == "aa Cc[-] DD Ee gg P- Spsp")
check("genotype: gleichnamiges Tier mit anderem dob bleibt unberuehrt",
_g_cousin["Genotype"] == "AA CC DD EE GG PP spsp")
# (6) name-only-Entscheidung (unpraezise) darf einen vorhandenen Gencode NICHT
# ueberschreiben — sie kann auf einen Namensvetter geleakt sein (Merle of Samsimar).
_g_only = {"Id": "7", "Name": "Vetter", "DateOfBirth": "2012-09-01",
"Genotype": "AA CC DD EE GG PP spsp"}
_ovr([_g_only], [{"name": "Vetter", "genotype": "aa CC DD ee GG PP Spsp"}])
check("genotype: name-only-Override ueberschreibt vorhandenen Gencode NICHT",
_g_only["Genotype"] == "AA CC DD EE GG PP spsp")
# (7) ... fuellt aber weiterhin ein LEERES Feld (bisheriges Verhalten, z. B. Jana/Kyara).
_g_empty = {"Id": "8", "Name": "Jana", "DateOfBirth": "2010-01-01", "Genotype": None}
_ovr([_g_empty], [{"name": "Jana", "genotype": "aa CC DD Ee GG PP spsp"}])
check("genotype: name-only-Override fuellt ein leeres Feld weiterhin",
_g_empty["Genotype"] == "aa CC DD Ee GG PP spsp")
# (8) Eine Entscheidung OHNE die neuen Schluessel bleibt fuer sie ein No-Op.
_g_plain = {"Id": "9", "Name": "Plain", "DateOfBirth": "2020-01-01",
"Genotype": "AA CC DD EE GG PP spsp", "Status": "Breeding"}
_ovr([_g_plain], [{"name": "Plain", "dob": "01.01.2020", "isResident": True}])
check("Overrides: ohne genotype/spottingType/goHomeDate keine Nebenwirkung",
_g_plain["Genotype"] == "AA CC DD EE GG PP spsp"
and _g_plain.get("SpottingType") is None
and _g_plain.get("GoHomeDate") is None
and _g_plain["Status"] == "Breeding")
# ── Integration: assert the resolved_import.json output reflects the ticket fixes ──
# (Only when the pipeline has already been run; tolerant if the file is absent.)
import os as _os, json as _json
@@ -596,12 +759,48 @@ if _os.path.exists(_resolved):
_check_parents("#9 Beatrice", "Beatrice von den kleinen", "Dante", "Malina")
_check_parents("#30 Theodore", "Theodore von den Kleinen", "BlackFire", "Katara")
# ── litterChildren: over-merged foreign animals removed from datierten Würfen ──
def _kids(lit_id):
return sorted(_G[k]["Name"] for k in _G if _G[k].get("LitterId") == lit_id)
_mw = next((l for l in _d["litters"]
if (l.get("Date") or "").startswith("2011-03-03") and "Cotton" in _kids(l["Id"])), None)
check("4b9f49fb M-Wurf: renamed from 'Ungeklärt' to 'M-Wurf'",
_mw is not None and _mw.get("Name") == "M-Wurf")
if _mw:
check("4b9f49fb M-Wurf: exactly the 5 correct kids",
_kids(_mw["Id"]) == ["Cotton", "Kiwi", "Mayu", "Mio", "Mo"])
_gw = next((l for l in _d["litters"]
if (l.get("Date") or "").startswith("2014-06-21") and "Kai-Uwe" in _kids(l["Id"])), None)
check("8d259edf Gale-Wurf: no wrongly-attached 'Unbekannt' kids remain",
_gw is not None and "Unbekannt" not in _kids(_gw["Id"]))
if _gw:
check("8d259edf Gale-Wurf: 3 surviving named juveniles", len(_kids(_gw["Id"])) == 3)
# ── New ticket-triage fixes (non-genetics import cluster) ─────────────────
# Akane: Roni is the FATHER (gender flipped male), mother = Fumi (stub).
_check_parents("Akane (wrong-parents)", "Akane", "Roni", "Fumi")
# Match on the call-name: since the new Stammbäume (2026-08-18) Roni carries the full
# clan name „Roni von den Kleinen Chaoten“ — and a substring _find("Roni") would hit
# „Vroni of little Jumpers“ first.
_roni = next((g for g in _d["gerbils"]
if g["Name"] == "Roni" and g.get("DateOfBirth") == "2022-01-27"), None)
if g["Name"].startswith("Roni") and g.get("DateOfBirth") == "2022-01-27"), None)
check("Roni: gender flipped to male", _roni is not None and _roni.get("Gender") == "male")
# litterChildren.add (neue Stammbäume 2026-08-18): Akane bleibt im Z21-Wurf (Roni × Fumi);
# der Phantom-Wurf „Irish Coffee × Roni“ aus der Chart-Positions-Heuristik ist entfernt.
_akane = next((g for g in _d["gerbils"]
if g["Name"].startswith("Akane") and g.get("DateOfBirth") == "2023-01-21"), None)
_akane_litter = _L.get(_akane.get("LitterId")) if _akane else None
check("Akane: im Z21-Wurf (nicht im Chart-Phantom-Wurf)",
_akane_litter is not None and _akane_litter.get("Name") == "Z21-Wurf")
check("kein Phantom-Wurf 'Irish Coffee × Roni' vom 21.01.2023",
not any((l.get("Date") == "2023-01-21" and "Irish Coffee" in (l.get("Name") or ""))
for l in _d["litters"]))
# Merle (Ticket 36a3fcde/a8f11ac0): DOB aus „Stammbaum von Pukas Kids“, Zuchttier per Override.
_merle = next((g for g in _d["gerbils"] if g["Name"] == "Merle"), None)
check("Merle: DOB 18.06.2023 aus dem neuen Stammbaum",
_merle is not None and _merle.get("DateOfBirth") == "2023-06-18")
check("Merle: Zuchttier (isResident-Override)",
_merle is not None and _merle.get("IsResident") is True)
# Fumi: materialised stub that is the (Wurfchronik) MOTHER of T21/Z21/22 →
# under the isResident-Sweep (Ticket 381f7e51) a parent of her OWN litters is
# resident. (No isResident override in conflict-decisions, so the sweep decides.)
@@ -729,7 +928,10 @@ if _os.path.exists(_resolved):
check("E-Wurf 2010: present", _el is not None)
if _el:
check("E-Wurf 2010: father is Blacky", _el.get("FatherId") is not None and _G[_el["FatherId"]]["Name"].startswith("Blacky"))
check("E-Wurf 2010: mother is Kruke", _el.get("MotherId") is not None and _G[_el["MotherId"]]["Name"] == "Kruke")
# Ticket e0a0c304: 'Kruke' war ein Tippfehler → korrekt 'Kuke'.
# Ticket 24006ccd: renameTo setzt jetzt den vollen Zuchtnamen
# 'Kuke von Marion Knoss' (Merge mit dem Chart-Ahnenrecord), daher Prefix-Pruefung.
check("E-Wurf 2010: mother is Kuke", _el.get("MotherId") is not None and _G[_el["MotherId"]]["Name"].startswith("Kuke"))
check("Eliza: shares same litter with Eddy", _eliza.get("LitterId") == _eddy.get("LitterId"))
# Zeus + Beatrice vs Eddy + Sheila same-date litters (2012-06-13)