feat(import): merge animals with different DOBs if their parent names match
Some checks failed
CI / Backend Tests (.NET) (push) Successful in 1m2s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Failing after 12s

This commit is contained in:
2026-06-21 22:23:59 +02:00
parent d2993418b6
commit 41ff973f9a
3 changed files with 111 additions and 15 deletions

View File

@@ -612,6 +612,49 @@ def dedup(animals):
"files": sorted(set(f for a in grp for f in a["sourceFiles"])), "files": sorted(set(f for a in grp for f in a["sourceFiles"])),
}) })
# 2. Merge groups that have different DOBs but same call-name (and Zucht) and matching parents
def get_parent_keys(a):
parent_refs = a.get("parentRefs", [])
f_name = next((p["name"] for p in parent_refs if p.get("roleGuess") == "father"), "")
m_name = next((p["name"] for p in parent_refs if p.get("roleGuess") == "mother"), "")
return norm_name(f_name), norm_name(m_name)
def groups_parents_match(g1, g2):
for a1 in g1:
for a2 in g2:
f1, m1 = get_parent_keys(a1)
f2, m2 = get_parent_keys(a2)
if f1 and f2 and f1 == f2 and m1 and m2 and m1 == m2:
return True
return False
i = 0
while i < len(final_groups):
g1 = final_groups[i]
call1, _ = split_name_zucht(g1[0]["name"])
n_call1 = norm_name(call1)
zucht1 = g1[0].get("_zucht", "")
j = i + 1
merged_any = False
while j < len(final_groups):
g2 = final_groups[j]
call2, _ = split_name_zucht(g2[0]["name"])
n_call2 = norm_name(call2)
zucht2 = g2[0].get("_zucht", "")
if n_call1 == n_call2:
if zucht1 == zucht2 or not zucht1 or not zucht2:
if groups_parents_match(g1, g2):
g1.extend(g2)
final_groups.pop(j)
merged_any = True
continue
j += 1
if merged_any:
continue
i += 1
merged = [] merged = []
conflicts = [] conflicts = []
for grp in final_groups: for grp in final_groups:
@@ -652,11 +695,25 @@ def dedup(animals):
return sum(1 for pair in gd["mapped8locus"].values() for a in pair if a != "?") return sum(1 for pair in gd["mapped8locus"].values() for a in pair if a != "?")
best = max((a["genotype"] for a in grp), best = max((a["genotype"] for a in grp),
key=lambda gd: (len(gd["mapped8locus"]), _specificity(gd), len(gd["rawGenotype"]))) key=lambda gd: (len(gd["mapped8locus"]), _specificity(gd), len(gd["rawGenotype"])))
# Find best DOB (proband first)
best_dob = ""
for a in grp:
if a.get("_gen") == 0 and a.get("dob"):
best_dob = a["dob"]
break
if not best_dob:
for a in grp:
if a.get("dob"):
best_dob = a["dob"]
break
chosen_dob = norm_dob(best_dob or base["dob"])
out = { out = {
"id": slug(base["name"], base["dob"]), "id": slug(base["name"], chosen_dob),
"name": base["name"], "name": base["name"],
"nameVariants": sorted(v for v in variants if v), "nameVariants": sorted(v for v in variants if v),
"dob": norm_dob(base["dob"]), "dob": chosen_dob,
"death": sorted(deaths)[0] if deaths else "", "death": sorted(deaths)[0] if deaths else "",
# box-colour sex (blue=male, white=female): majority across mentions, else None. # box-colour sex (blue=male, white=female): majority across mentions, else None.
"gender": Counter(genders).most_common(1)[0][0] if genders else None, "gender": Counter(genders).most_common(1)[0][0] if genders else None,

View File

@@ -1173,6 +1173,17 @@ def main():
norm_b, keep_b = get_normalized_contact_name(raw_breeder) norm_b, keep_b = get_normalized_contact_name(raw_breeder)
raw_breeder = norm_b if keep_b else None raw_breeder = norm_b if keep_b else None
parent_refs = []
if old_litter_id:
rl = next((l for l in raw_litters if l.get("_scoped_id") == old_litter_id), None)
if rl:
f_name = rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName") or rl.get("_father_name")
m_name = rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName") or rl.get("_mother_name")
if f_name:
parent_refs.append({"name": f_name, "roleGuess": "father"})
if m_name:
parent_refs.append({"name": m_name, "roleGuess": "mother"})
all_processed_gerbils.append({ all_processed_gerbils.append({
"Id": new_guid, "Id": new_guid,
"Name": name_val, "Name": name_val,
@@ -1198,6 +1209,7 @@ def main():
"CharacterNote": char_note, "CharacterNote": char_note,
"IsDeaf": is_deaf, "IsDeaf": is_deaf,
"IsResident": is_resident, "IsResident": is_resident,
"parentRefs": parent_refs,
"_photos": rg.get("photos", []), "_photos": rg.get("photos", []),
"_old_scoped_litter_id": old_litter_id, "_old_scoped_litter_id": old_litter_id,
"_eff_dob": eff_dob_val, "_eff_dob": eff_dob_val,
@@ -1306,6 +1318,7 @@ def main():
"CharacterNote": None, "CharacterNote": None,
"IsDeaf": is_deaf, "IsDeaf": is_deaf,
"IsResident": a_id in stammbaum_resident_ids, "IsResident": a_id in stammbaum_resident_ids,
"parentRefs": a.get("parentRefs", []),
"_photos": a.get("photos", []), "_photos": a.get("photos", []),
"_old_scoped_litter_id": scoped_litter_id, "_old_scoped_litter_id": scoped_litter_id,
"_eff_dob": dob_val or "2010-01-01", "_eff_dob": dob_val or "2010-01-01",
@@ -1364,6 +1377,15 @@ def main():
# Residents: if sold/given away, it's not a resident # Residents: if sold/given away, it's not a resident
is_resident = not bool(o_name) is_resident = not bool(o_name)
parent_refs = []
if scoped_litter_id:
dl = next((l for l in docx_litters if docx_litter_id_map.get((l["wsCode"], parse_date(l["dob"]))) == scoped_litter_id), None)
if dl:
if dl.get("fatherName"):
parent_refs.append({"name": dl["fatherName"], "roleGuess": "father"})
if dl.get("motherName"):
parent_refs.append({"name": dl["motherName"], "roleGuess": "mother"})
all_processed_gerbils.append({ all_processed_gerbils.append({
"Id": scoped_id, "Id": scoped_id,
"Name": name_val, "Name": name_val,
@@ -1389,6 +1411,7 @@ def main():
"CharacterNote": None, "CharacterNote": None,
"IsDeaf": None, "IsDeaf": None,
"IsResident": is_resident, "IsResident": is_resident,
"parentRefs": parent_refs,
"_photos": da.get("photos", []), "_photos": da.get("photos", []),
"_old_scoped_litter_id": scoped_litter_id, "_old_scoped_litter_id": scoped_litter_id,
"_eff_dob": dob_val or "2020-01-01", "_eff_dob": dob_val or "2020-01-01",
@@ -1418,13 +1441,27 @@ def main():
bd1 = g1.get("_birth_date") bd1 = g1.get("_birth_date")
bd2 = g2.get("_birth_date") bd2 = g2.get("_birth_date")
# If both have explicit birth dates, they must match within 30 days # New rule: if name and parents match, they are compatible regardless of DOB!
if bd1 and bd2: p1 = g1.get("parentRefs", [])
days1 = date_to_days(bd1) p2 = g2.get("parentRefs", [])
days2 = date_to_days(bd2) f1 = next((p["name"] for p in p1 if p.get("roleGuess") == "father"), "")
if days1 is not None and days2 is not None: m1 = next((p["name"] for p in p1 if p.get("roleGuess") == "mother"), "")
if abs(days1 - days2) > 30: f2 = next((p["name"] for p in p2 if p.get("roleGuess") == "father"), "")
return False m2 = next((p["name"] for p in p2 if p.get("roleGuess") == "mother"), "")
parents_match = False
if f1 and f2 and m1 and m2:
if normalize_name(f1) == normalize_name(f2) and normalize_name(m1) == normalize_name(m2):
parents_match = True
if not parents_match:
# If both have explicit birth dates, they must match within 30 days
if bd1 and bd2:
days1 = date_to_days(bd1)
days2 = date_to_days(bd2)
if days1 is not None and days2 is not None:
if abs(days1 - days2) > 30:
return False
# If g1 has birth date, and g2 has parenting dates, birth date must be before parenting dates # If g1 has birth date, and g2 has parenting dates, birth date must be before parenting dates
p_dates2 = parent_litter_dates.get(g2["Id"], []) p_dates2 = parent_litter_dates.get(g2["Id"], [])

View File

@@ -5,14 +5,14 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
## Überblick ## Überblick
- Rohe Tier-Einträge aus den Stammbäumen: **2449** - Rohe Tier-Einträge aus den Stammbäumen: **2449**
- Nach Zusammenführung (eindeutige Tiere): **1005** - Nach Zusammenführung (eindeutige Tiere): **1000**
- davon mit Geburtsdatum: 680 - davon mit Geburtsdatum: 677
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 460 - in mehreren Dateien gefunden (Dubletten zusammengeführt): 462
- Konflikte zur Klärung: **2** - Konflikte zur Klärung: **4**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342** - Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
- Fotos zugeordnet: **416** - Fotos zugeordnet: **416**
- Würfe aus der Wurfchronik: **752** - Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 165, nur über Geburtsdatum: 105; mehrdeutig: 16) - Tiere mit Wurf verknüpft: **269** (davon über Geburtsdatum **und** Eltern: 165, nur über Geburtsdatum: 104; mehrdeutig: 16)
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema) - Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
## Zusammenführungs-Schlüssel ## Zusammenführungs-Schlüssel
@@ -35,7 +35,9 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien | | Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 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 Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea | | 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 Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, 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) | | 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 |
## Mehrdeutige / unvollständige Einträge ## Mehrdeutige / unvollständige Einträge
@@ -173,11 +175,11 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `/+Dezember'2014` | 1 | ? | | `/+Dezember'2014` | 1 | ? |
| `(Ansatz)` | 1 | ? | | `(Ansatz)` | 1 | ? |
| `-psp` | 1 | ? | | `-psp` | 1 | ? |
| `G(G)` | 1 | ? |
| `!Niereninsuffizienz!` | 1 | ? | | `!Niereninsuffizienz!` | 1 | ? |
| `+09.07.2017` | 1 | ? | | `+09.07.2017` | 1 | ? |
| `+2018` | 1 | ? | | `+2018` | 1 | ? |
| `AAA` | 1 | ? | | `AAA` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
## Wurfchronik — Datenqualitäts-Hinweise ## Wurfchronik — Datenqualitäts-Hinweise