fix(tickets): 16 Tickets der Züchterin — Genetik, Importer, Daten, Akte
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:
@@ -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
|
||||
{
|
||||
@@ -286,5 +287,56 @@ namespace GerbilManager.Tests
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -128,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;
|
||||
@@ -143,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,
|
||||
|
||||
1927
GerbilManagerWebAPI/Migrations/20260819212610_ReseedColorVarietiesGen6Saphir.Designer.cs
generated
Normal file
1927
GerbilManagerWebAPI/Migrations/20260819212610_ReseedColorVarietiesGen6Saphir.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReseedColorVarietiesGen6Saphir : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa Ccchm DD EE GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa Cch DD EE GG pp spsp rere");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000025"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD EE GG pp spsp rere");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "ColorVarieties",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000028"),
|
||||
column: "CanonicalGenotype",
|
||||
value: "aa CC DD EE GG pp spsp rere");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -179,7 +179,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
|
||||
|
||||
@@ -221,6 +221,26 @@ test('Detailseite: Zuchtpartner-Liste trennt mit Komma HINTEN am Namen (KV-COMMA
|
||||
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')
|
||||
})
|
||||
|
||||
/** 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 })
|
||||
|
||||
@@ -913,3 +913,68 @@ 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('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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -490,9 +490,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 ? (
|
||||
<>
|
||||
@@ -549,8 +546,14 @@ export default function GerbilDetailPage() {
|
||||
g.originBreeder || '—'
|
||||
)}
|
||||
</Kv>
|
||||
{(g.receiverContactId || g.status !== 'Deceased') && (
|
||||
{/* 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}>
|
||||
|
||||
@@ -345,11 +345,19 @@
|
||||
"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 + Tickets 06b6734d/e96db323/c092f8d2 (Merge Stub)",
|
||||
"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"
|
||||
]
|
||||
]
|
||||
},
|
||||
@@ -983,11 +991,19 @@
|
||||
{
|
||||
"name": "Kruke",
|
||||
"isResident": true,
|
||||
"decision": "'Kruke' war ein Wurfchronik-Tippfehler; korrekt 'Kuke' (rpro3). Bestandstier. Todesdatum 06.04.2011 (Ausbruch-Unfall, rpro3). Farbe/Genotyp (Anthrazit-Schecke vs. Schwarz) folgt separat via Ticket f89e95ad, sobald die Zuechterin Saphir/Platin geklaert hat. KEIN Merge mit dem Ahnen-Record 'Kuke von Marion Knoss' — der Merge nullte die Mutter-Verknuepfung aller Blacky-x-Kuke-Wuerfe.",
|
||||
"source": "KI-Triage 2026-07-12 — Ticket 34220307-23bc-420c-80f8-d273615caffd + b7016e5f-9985-4f17-a3f6-2048289958aa + Tickets e0a0c304/a50116bf",
|
||||
"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",
|
||||
"renameTo": "Kuke"
|
||||
"deceased": true,
|
||||
"originBreeder": "Marion Knoss",
|
||||
"renameTo": "Kuke von Marion Knoss",
|
||||
"mergeExternalRefs": [
|
||||
[
|
||||
"decision-kruke",
|
||||
"stammbaum-kukemarionknoss-18092009"
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Genna",
|
||||
@@ -1087,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",
|
||||
@@ -1094,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",
|
||||
@@ -1199,14 +1217,6 @@
|
||||
"mother": "Scarlett of Ulmer Strolche",
|
||||
"motherDob": "05.09.2012"
|
||||
},
|
||||
{
|
||||
"name": "Merle",
|
||||
"originBreeder": "Clan of Black Forest",
|
||||
"source": "Ticket a8f11ac0 + Ticket 00715df9",
|
||||
"externalRef": "decision-merle",
|
||||
"genotype": "Aa c[chm]c[chm] D- ee GG P- Spsp",
|
||||
"decision": "Gencode aus Merles eigenem Ticket a8f11ac0. Eltern Akane x Bonaparte bereits über addLitter verknüpft. Geburtsdatum in keiner lesbaren Quelle -> offen."
|
||||
},
|
||||
{
|
||||
"name": "Gino",
|
||||
"dob": "25.10.2010",
|
||||
@@ -1273,8 +1283,10 @@
|
||||
"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.",
|
||||
"source": "Ticket aa5238b6"
|
||||
"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",
|
||||
@@ -1282,12 +1294,16 @@
|
||||
[
|
||||
"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"
|
||||
"source": "Ticket 7f9b3724 + Ticket 6c9539d2 (zweiter Wurfchronik-Stub page_0023)"
|
||||
},
|
||||
{
|
||||
"name": "Marty v.d. Kleinen Chaoten",
|
||||
@@ -1322,9 +1338,49 @@
|
||||
{
|
||||
"name": "Merle",
|
||||
"dob": "18.06.2023",
|
||||
"externalRef": "stammbaum-merle-18062023",
|
||||
"isResident": true,
|
||||
"decision": "Merle (Tochter Akane × Bonaparte, bei Clan of Black Forest geboren) ist Zuchttier. Das Geburtsdatum 18.06.2023 kommt aus „Stammbaum von Pukas Kids“ — seit der addAnimals-Stub mit dem Chart-Datensatz verschmilzt, braucht die Residenz einen expliziten Override (der isResident-Sweep würde sie sonst auf false setzen).",
|
||||
"source": "Ticket 36a3fcde + Ticket a8f11ac0 (Züchterin: Merle = Zuchttier)"
|
||||
"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": [
|
||||
@@ -1797,9 +1853,10 @@
|
||||
"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-virtual-a7c4a776-e7fc-5672-a004-8aaf5534db97"
|
||||
"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)",
|
||||
@@ -2143,6 +2200,24 @@
|
||||
"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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -4,11 +4,11 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
||||
|
||||
## Überblick
|
||||
|
||||
- Rohe Tier-Einträge aus den Stammbäumen: **3600**
|
||||
- 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: **20**
|
||||
- Konflikte zur Klärung: **19**
|
||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **757**
|
||||
- Fotos zugeordnet: **510**
|
||||
- Würfe aus der Wurfchronik: **752**
|
||||
@@ -39,13 +39,12 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
||||
| 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 |
|
||||
| Jay | 24.10.2021 | aa c[chm]c[hm] Dd Ee gg P- spsp | Zobel | 10.01.2024 // 10.01.2025 | Stammbaum von Alberto Kids, Stammbaum von Pukas Kids |
|
||||
| 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 |
|
||||
| Louis of Black Forest | 22.08.2019 | Aa CC dd EE Gg P- spsp | — | 09.04.2024 // 10.04.2024 | 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 Rj, Stammbaum von Sa Sp (YaraFire) & Sa 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 |
|
||||
|
||||
@@ -498,6 +498,57 @@ if os.path.exists(_emf_chart):
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -766,7 +929,9 @@ if _os.path.exists(_resolved):
|
||||
if _el:
|
||||
check("E-Wurf 2010: father is Blacky", _el.get("FatherId") is not None and _G[_el["FatherId"]]["Name"].startswith("Blacky"))
|
||||
# Ticket e0a0c304: 'Kruke' war ein Tippfehler → korrekt 'Kuke'.
|
||||
check("E-Wurf 2010: mother is Kuke", _el.get("MotherId") is not None and _G[_el["MotherId"]]["Name"] == "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)
|
||||
|
||||
Reference in New Issue
Block a user