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>
This commit is contained in:
2026-08-19 23:31:48 +02:00
parent bf074696b4
commit 5a6d1c2541
22 changed files with 2832 additions and 182 deletions

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,136 +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("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"))
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:
# 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
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)
@@ -4459,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 = {