feat(deploy): TrueNAS Custom-App + Auto-Deploy, plus aufgelaufene Arbeit
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m11s
CI / Frontend Tests (Node/Vite) (push) Failing after 4m59s
CI / Docker Build & Push (push) Has been skipped
CI / Deploy auf TrueNAS (Custom App) (push) Has been skipped

Deployment:
- custom-app.compose.yaml: self-contained Compose fuer TrueNAS "Custom App"
  (absolute Host-Bind-Pfade, postgres:18, pull_policy always, Port 8090)
- scripts/truenas-deploy.sh: Host-Skript create/redeploy via midclt (App
  bleibt unter Apps sichtbar) inkl. Image-Pull + Health-Check
- ci.yml Deploy-Job: laeuft auf ubuntu-latest-Runner, kopiert Deploy-Dateien
  per SSH auf den NAS-Host und triggert truenas-deploy.sh (statt runs-on goldeye)
- compose.yaml/.env.example: postgres:18 (Locale-Match zur Quell-DB), Port 8090
- .gitignore: .agents/, tools/rag/, deploy/truenas/.env (Secrets/Scratch)

Aufgelaufene Feature-Arbeit (verified/Freeze, Migrationen, Import-Triage):
- GerbilOverride/VerifiedGerbil-Endpoints + GerbilSnapshotService + Tests
- EF-Migrationen (ShowInChronicle, Stillborn, BirthOrder, ManualFlag, DSGVO)
- Frontend VerifizierteTierePage + verified-API + e2e-Spec
- diverse Import-/Triage-Skripte und -Tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 09:19:11 +02:00
parent 84365bba7f
commit 45b8533f18
93 changed files with 20477 additions and 432 deletions

View File

@@ -487,6 +487,56 @@ _, _g_add = m.resolve_color_and_genotype("Agouti Schecke", "AA CC DD EE GG PP",
check("schecke label + no Sp token -> appends Spsp", "Spsp" in _g_add)
# ── parse_death_info: markerless death dates (Ticket ec9267b9) ──────────────────
# A death keyword without a +/cross marker before the date must still yield the
# death date. Status logic is unchanged; an existing dateOfDeath is never replaced.
_st, _dod, _cod = m.parse_death_info(
"Verstorbener Welpe am 30.03.15 an Durchfall nach frühem Abstillen wegen Tod der Mutter.",
"Breeding", None, None)
check("ec9267b9: markerless 'Verstorben ... am DD.MM.YY' -> Deceased", _st == "Deceased")
check("ec9267b9: markerless death date parsed (30.03.15 -> 2015-03-30)", _dod == "2015-03-30")
# 'Verstorben am DD.MM.YY' (the GivenAway-then-died notes) — date now captured.
_st2, _dod2, _ = m.parse_death_info("Verstorben am 04.12.18 an Leberzyste", "GivenAway", None, None)
check("ec9267b9: 'Verstorben am 04.12.18' -> Deceased + date", _st2 == "Deceased" and _dod2 == "2018-12-04")
# Never overwrite an existing dateOfDeath.
_st3, _dod3, _ = m.parse_death_info("Verstorben am 04.12.18", "Breeding", "2017-01-01", None)
check("ec9267b9: existing dateOfDeath kept (not overwritten)", _dod3 == "2017-01-01")
# No death keyword -> no spurious date / status change.
_st4, _dod4, _ = m.parse_death_info("Geboren am 30.03.15", "Breeding", None, None)
check("ec9267b9: no death keyword -> Status/date unchanged", _st4 == "Breeding" and _dod4 is None)
# ── enrich_from_contracts: excludeContractMatch gate (parent-not-pup) ───────────
# A parent animal pinned out via excludeContractMatch must NEVER receive a
# contract's receiver/gohome/GivenAway, even when its call-name matches.
_excl_gerbils = [{
"Id": "g-makoto", "Name": "Makoto von den Kleinen Chaoten", "Gender": "male",
"Status": "Breeding", "DateOfBirth": "2016-05-01", "ReceiverContactId": None,
"GoHomeDate": None, "OriginBreeder": "Zucht der kleinen Chaoten",
"ExternalRef": "stammbaum-makoto", "Provenance": None,
}]
_excl_contract = [{
"sourceFile": "Zucht der kleinen Chaoten _ Balu (Makoto.Ella)-Thomas Weickert.docx",
"buyer": "Thomas Weickert", "animals": ["Makoto"], "color": "",
"gender": "", "dob": "", "handoverDate": "2017-06-01", "contractDate": "2017-06-01",
"price": "",
}]
_excl_decisions = [{"name": "Makoto", "dob": "2016-05-01", "excludeContractMatch": True}]
_es, _ = m.enrich_from_contracts(_excl_contract, _excl_gerbils, {}, {},
exclude_decisions=_excl_decisions)
check("excludeContractMatch: pinned parent keeps no receiver",
_excl_gerbils[0].get("ReceiverContactId") is None)
check("excludeContractMatch: pinned parent stays Breeding (not GivenAway)",
_excl_gerbils[0].get("Status") == "Breeding")
check("excludeContractMatch: pinned parent gets no GoHomeDate",
_excl_gerbils[0].get("GoHomeDate") is None)
# Without the exclude, the same match WOULD set the receiver (control).
_excl_gerbils2 = [dict(_excl_gerbils[0])]
m.enrich_from_contracts(_excl_contract, _excl_gerbils2, {}, {}, exclude_decisions=[])
check("excludeContractMatch: control (no exclude) -> receiver IS set",
_excl_gerbils2[0].get("ReceiverContactId") is not None)
# ── 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
@@ -552,13 +602,112 @@ if _os.path.exists(_resolved):
_roni = next((g for g in _d["gerbils"]
if g["Name"] == "Roni" and g.get("DateOfBirth") == "2022-01-27"), None)
check("Roni: gender flipped to male", _roni is not None and _roni.get("Gender") == "male")
# 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.)
_fumi = _find("Fumi von den Kleinen")
check("Fumi: materialised as a non-resident stub",
_fumi is not None and _fumi.get("IsResident") is False)
check("Fumi: materialised; resident as a parent of her own Wurfchronik litters",
_fumi is not None and _fumi.get("IsResident") is True)
# Sunny von PZ Karl: father corrected Hiro → Bill von Privat.
_check_parents("Sunny (parents)", "Sunny von PZ Karl", "Bill von Privat", "Melly von Privat")
# ── Akane (Ticket 36a3fcde): addLitters injection + isResident/notes/receiver
# overrides + ShowInChronicle. The manually-added litter must hang off the
# EXISTING Akane and the new Bonaparte stub, link the three children, and be
# hidden from the Wurfchronik. ───────────────────────────────────────────
_akane = _find("Akane", "2023-01-21")
check("Akane: record present (*2023-01-21)", _akane is not None)
if _akane:
check("Akane: isResident override == False", _akane.get("IsResident") is False)
check("Akane: receiver = Ulrike Neu",
_akane.get("ReceiverContactId") == "33130df1-e164-54ae-be74-a9b7d2ced11b")
check("Akane: Lebenslauf-Notes mention Ulrike Neu",
"Ulrike Neu" in (_akane.get("Notes") or ""))
# The injected litter (deterministic ExternalRef from the slug).
_alit = next((l for l in _d["litters"]
if l.get("ExternalRef") == "decision-litter-akaneswurfbeiclanofblackforest"), None)
check("addLitters: Akane litter injected", _alit is not None)
if _alit:
check("addLitters: ShowInChronicle == False", _alit.get("ShowInChronicle") is False)
check("addLitters: mother is Akane",
_akane is not None and _alit.get("MotherId") == _akane["Id"])
_bona = _G.get(_alit.get("FatherId"))
check("addLitters: father is Bonaparte von den Schlossmäusen",
_bona is not None and _bona["Name"].startswith("Bonaparte"))
# The three children must point their LitterId at the injected litter.
_kids = {g["Name"]: g for g in _d["gerbils"]
if g.get("LitterId") == _alit["Id"]}
for _kn in ("Merle", "Fanella", "Pete"):
check(f"addLitters: child {_kn} linked to the litter", _kn in _kids)
check("addLitters: Merle is the new resident stub (not 'Merle of Samsimar')",
_kids.get("Merle") is not None and _kids["Merle"].get("IsResident") is True)
check("addLitters: Pete resident", _kids.get("Pete") is not None
and _kids["Pete"].get("IsResident") is True)
check("addLitters: Fanella non-resident, receiver Ulrike Neu",
_kids.get("Fanella") is not None
and _kids["Fanella"].get("IsResident") is False
and _kids["Fanella"].get("ReceiverContactId") == "33130df1-e164-54ae-be74-a9b7d2ced11b")
# ShowInChronicle (Ticket ea41257a): present on EVERY litter. The Wurfchronik
# shows only the breeder's own documented litters. Pure ancestor pairings
# (reconstructed from Stammbaum diagrams, NOT from the Wurfchronik, with NO
# resident parent) are hidden; the Akane addLitter stays hidden too; every
# real Wurfchronik litter and any reconstructed litter with a resident parent
# stays visible.
check("ShowInChronicle: present on every litter",
all("ShowInChronicle" in l for l in _d["litters"]))
def _prov_of(l):
try:
return _json.loads(l.get("Provenance") or "{}")
except Exception:
return {}
def _is_reconstructed(l):
p = _prov_of(l)
notes = " ".join(p.get("notes") or []).lower()
return ("rekonstruiert" in notes) and not p.get("fromWurfchronik")
def _has_resident_parent(l):
for pid in (l.get("FatherId"), l.get("MotherId")):
g = _G.get(pid)
if g and g.get("IsResident"):
return True
return False
_hidden = [l for l in _d["litters"] if l.get("ShowInChronicle") is False]
# (a) the manually injected Akane litter is still hidden.
check("ShowInChronicle: Akane addLitter still hidden",
any(l.get("ExternalRef") == "decision-litter-akaneswurfbeiclanofblackforest"
for l in _hidden))
# (b) every pure ancestor pairing (reconstructed, no resident parent) is hidden;
# and there is at least one such litter (the ticket case 47f8d2b6).
_pure_ancestor = [l for l in _d["litters"]
if _is_reconstructed(l) and not _has_resident_parent(l)]
check("ShowInChronicle: at least one pure ancestor pairing exists",
len(_pure_ancestor) > 0)
check("ShowInChronicle: every pure ancestor pairing is hidden",
all(l.get("ShowInChronicle") is False for l in _pure_ancestor))
# (c) the concrete ticket litter 47f8d2b6 (Antares × Charly) is hidden.
_ticket_lit = _L.get("47f8d2b6-bf13-5f94-935c-f8860a5a1ce6")
check("ShowInChronicle: ticket litter 47f8d2b6 present", _ticket_lit is not None)
if _ticket_lit:
check("ShowInChronicle: ticket litter 47f8d2b6 hidden",
_ticket_lit.get("ShowInChronicle") is False)
# (d) regression — a real Wurfchronik litter stays visible; and reconstructed
# litters WITH a resident parent (the breeder's own, only charted) stay
# visible, so her real litters are never hidden.
_wurfchronik_lits = [l for l in _d["litters"] if _prov_of(l).get("fromWurfchronik")]
check("ShowInChronicle: Wurfchronik litters present", len(_wurfchronik_lits) > 0)
check("ShowInChronicle: every Wurfchronik litter visible",
all(l.get("ShowInChronicle") is True for l in _wurfchronik_lits))
_recon_resident = [l for l in _d["litters"]
if _is_reconstructed(l) and _has_resident_parent(l)]
check("ShowInChronicle: reconstructed litter with resident parent stays visible",
all(l.get("ShowInChronicle") is True for l in _recon_resident))
# Danielle: mother = Ella *10.06.2019, father = Makoto (sibling pairing; Ticket 4692fd5c).
_dan = _find("Danielle von den Kleinen")
_df, _dm = _parents(_dan)
@@ -570,18 +719,42 @@ if _os.path.exists(_resolved):
check("Danielle: mother Ella is the *2019-06-10 one (not the *2023 Ella)",
_dmom is not None and _dmom.get("DateOfBirth") == "2019-06-10")
# Eddy: ticket 439b02e4 & Eliza: ticket b43a5e67
_eddy = _find("Eddy von den Kleinen")
_eliza = _find("Eliza", "2010-08-20")
check("Eddy: present", _eddy is not None)
check("Eliza: present", _eliza is not None)
if _eddy and _eliza:
_el = _L.get(_eddy.get("LitterId"))
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")
check("Eliza: shares same litter with Eddy", _eliza.get("LitterId") == _eddy.get("LitterId"))
# Zeus + Beatrice vs Eddy + Sheila same-date litters (2012-06-13)
_k1 = next((l for l in _d["litters"] if l.get("Name") == "Wurf K1" and l.get("Date") == "2012-06-13"), None)
_ed_sh = next((l for l in _d["litters"] if "Eddy" in l.get("Name") and "Sheila" in l.get("Name") and l.get("Date") == "2012-06-13"), None)
check("K1-Wurf: present on 2012-06-13", _k1 is not None)
check("Eddy+Sheila litter: present on 2012-06-13", _ed_sh is not None)
if _k1 and _ed_sh:
check("K1-Wurf and Eddy+Sheila litter are distinct", _k1["Id"] != _ed_sh["Id"])
check("K1-Wurf has correct father Zeus", _k1.get("FatherId") is not None and _G[_k1["FatherId"]]["Name"].startswith("Zeus"))
check("K1-Wurf has correct mother Beatrice", _k1.get("MotherId") is not None and _G[_k1["MotherId"]]["Name"].startswith("Beatrice"))
check("Eddy+Sheila litter is hidden from chronicle", _ed_sh.get("ShowInChronicle") is False)
# Catelyn (Ticket 7bbc045c): father Eddard Stark of Sunset Glow, mother Milena.
_check_parents("Catelyn", "Catelyn Stark von den Kleinen", "Eddard Stark", "Milena")
# Gaida (Ticket ba63325a): Geschwisterverpaarung Zhuāngzǐ × Zaibunissa (beide *2020-02-21).
_check_parents("Gaida", "Gaida von den Kleinen Chaoten", "Zhuāngzǐ", "Zaibunissa")
# Bentley / Alexandria / Bugatti (Ticket 09bcac78 / 45cc501b): nur Vorfahren → isResident False.
# Bentley / Alexandria / Bugatti (Ticket 09bcac78 / 45cc501b): suppressed by decision -> None.
for _tag, _ref in [("Bentley", "Wurfchronik Teil 1_page_0054.md-50505050-0003-4000-8000-000000000003"),
("Alexandria", "Wurfchronik Teil 1_page_0054.md-50505050-0004-4000-8000-000000000004"),
("Bugatti", "Wurfchronik Teil 1_page_0054.md-40404040-0003-4000-8000-000000000003")]:
_g = next((g for g in _d["gerbils"] if g.get("ExternalRef") == _ref), None)
check(f"{_tag}: isResident override == False", _g is not None and _g.get("IsResident") is False)
check(f"{_tag}: suppressed by decision", _g is None)
# Cherry Berry's Quqquluuruu: gender override female (box colour misread).
_cherry = _find("Cherry Berry")
@@ -655,6 +828,90 @@ if _os.path.exists(_resolved):
_mf, _mm = _parents(_mamta)
check("Mamta Mini: father Geely, mother Gaida linked at the litter",
(_mf or "").startswith("Geely") and (_mm or "").startswith("Gaida"))
# ── isResident-Sweep (Ticket 381f7e51): „ALLE raus, AUSSER Elterntiere" ───────
# Datengetriebene Endregel: resident GENAU DANN, wenn (1) expliziter Override
# ODER (2) Elternteil eines EIGENEN Wurfs (Wurfchronik/Clan/resident-Elternteil),
# NICHT bloß eines rein-virtuellen Ahnen-Wurfs.
_byid = lambda pref: next((g for g in _d["gerbils"]
if g.get("Id", "").startswith(pref)), None)
# (a) Jungtier ohne eigene Nachzucht (namenloses ♀ *2022-01-28, O20) → False.
_o20 = _byid("86c10cf2")
check("Sweep: jungling 86c10cf2 (no own offspring) → isResident False",
_o20 is not None and _o20.get("IsResident") is False)
# (b) Mindestens 90 % aller Tiere sind jetzt NICHT-resident (der Blanket-True-
# Default der Wurfchronik ist weg; vorher waren ~1709/2357 resident).
_n_res = sum(1 for g in _d["gerbils"] if g.get("IsResident"))
check("Sweep: resident count drastically reduced (< 700)", _n_res < 700)
# (c) Echte Zuchttiere (Eltern dokumentierter Wurfchronik-Würfe) bleiben True.
def _is_wurfchronik(l):
try:
return bool(_json.loads(l.get("Provenance") or "{}").get("fromWurfchronik"))
except Exception:
return False
_wc_parents_true = 0
_wc_parents_total = 0
for l in _d["litters"]:
if not _is_wurfchronik(l):
continue
for pid in (l.get("FatherId"), l.get("MotherId")):
pg = _G.get(pid)
if not pg:
continue
_wc_parents_total += 1
if pg.get("IsResident"):
_wc_parents_true += 1
# The vast majority of Wurfchronik parents are her own breeding stock → resident
# (a handful are explicit-false ancestors like Bentley/Alexandria).
check("Sweep: ≥ 95% of Wurfchronik-litter parents stay resident",
_wc_parents_total > 0 and _wc_parents_true / _wc_parents_total >= 0.95)
# (d) Externer Ahne, nur Elternteil eines virtuellen Ahnen-Wurfs → False.
_antares = _find("Antares of Ulmer Strolche")
check("Sweep: external ancestor Antares (virtual-only litter) → isResident False",
_antares is not None and _antares.get("IsResident") is False)
# (e) Expliziter false-Override gewinnt über Elternschaft (Akane *2023-01-21 ist
# Elternteil ihres Fremd-Wurfs, bleibt aber False per Override).
_ak = _find("Akane", "2023-01-21")
check("Sweep: explicit false override beats parenthood (Akane → False)",
_ak is not None and _ak.get("IsResident") is False)
# (f) Residenten, die KEIN Elternteil irgendeines Wurfs sind, sind ausschließlich
# explizite Overrides (z. B. die addAnimals-Stubs Merle/Pete) — der Sweep
# macht niemanden ohne Elternschaft resident.
_parent_ids = set()
for l in _d["litters"]:
for pid in (l.get("FatherId"), l.get("MotherId")):
if pid:
_parent_ids.add(pid)
# Ein expliziter isResident:true-Override in conflict-decisions.json macht ein
# NICHT-Elterntier legitim resident (Züchterin-Entscheidung, z. B. „extern"-Badge
# entfernen). Deren Match-Keys (name / externalRef) werden neben den decision--Stubs
# akzeptiert — sonst würde jede solche Kuratierung diese Invariante verletzen.
_cd_path = _os.path.join(_os.path.dirname(__file__), "conflict-decisions.json")
_ovr_names, _ovr_refs = set(), []
if _os.path.exists(_cd_path):
for _r in (_json.load(open(_cd_path, encoding="utf-8")).get("resolutions") or []):
if _r.get("isResident") is True:
if _r.get("name"):
_ovr_names.add(m.normalize_name(_r["name"]))
if _r.get("externalRef"):
_ovr_refs.append(_r["externalRef"])
def _res_ok(g):
er = g.get("ExternalRef") or ""
return (er.startswith("decision-")
or m.normalize_name(g.get("Name") or "") in _ovr_names
or any(er.endswith(ref) for ref in _ovr_refs))
_res_nonparent = [g for g in _d["gerbils"]
if g.get("IsResident") and g["Id"] not in _parent_ids]
check("Sweep: residents that are not a parent are only explicit override stubs",
all(_res_ok(g) for g in _res_nonparent))
else:
print("note: output/resolved_import.json not present — skipped integration assertions")