Compare commits

...

12 Commits

Author SHA1 Message Date
2befe53df9 feat(stammbaum): Desktop-Ansicht passt den ganzen Baum ein + höhere Zeichenfläche
All checks were successful
CI / Backend Tests (.NET) (push) Successful in 1m3s
CI / Frontend Tests (Node/Vite) (push) Successful in 9m35s
CI / Docker Build & Push (push) Successful in 1m30s
Auf dem Desktop wurde der Baum bei festem Zoom 1 rechts abgeschnitten (man sah
nur 1–2 Generationen, der Rest lag außerhalb) und viel Fläche blieb leer. Die
Standard-Ansicht passt jetzt den gesamten geladenen Baum ein — gedeckelt auf
Zoom 1, damit Karten bei kleinen Bäumen nicht vergrößert/unscharf werden. Mobil
bleibt es bei „Wurzel links + Pan" (sonst würden die Karten winzig). Zeichen-
fläche von 62dvh auf 72dvh erhöht (füllt die Desktop-Höhe besser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:26:50 +02:00
cf672c0933 feat(provenance): Datenherkunft als chronologischer, datei-attribuierter Verlauf
Die Herkunft liest sich jetzt wie eine History: pro Feld wird genannt, aus
welcher Quelldatei der Wert kam, plus Merge-/Entscheidungs-/Wurfchronik-Schritte.
Beispiel: „In ‚X.xlsx' gefunden." → „Geburtsdatum (…) aus ‚X.xlsx'." → „Auch in
‚Y.docx' gefunden → zusammengeführt." → „Eltern über Position erkannt (Quelle …)."

Import: build_entity_provenance() um history[] erweitert; ein field_source-
Tracking im Gerbil-Dedup hält fest, welcher Datensatz/welche Datei jedes Feld
(DOB/Genotyp/Geschlecht/Farbschlag/Sterbedatum) lieferte — auch über Empty-Fill
und Mehrheitsentscheid hinweg. Kontakte und Würfe erhalten analoge, datei-
attribuierte Verläufe. Kein DB-Migration nötig (history liegt im Provenance-JSON).

Frontend: ProvenanceDialog rendert den Verlauf als nummerierte Timeline.
Tests: history wird erzeugt, nennt Dateien, überlebt Ingest (dotnet 212,
vitest 129, playwright 17, python merge/extract grün).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:21:24 +02:00
a31fea17fa feat(stammbaum): Kontextmenü per Long-Press auf Touch-Geräten öffnen
Das Rechtsklick-Kontextmenü (ID kopieren / Fehler melden) war auf Mobil nicht
erreichbar (onContextMenu feuert auf Touch nicht zuverlässig). Jetzt öffnet ein
langer Druck (~500 ms ohne Verschieben) auf eine Ahnenkarte dasselbe Menü; der
darauf folgende synthetische Klick (Umwurzeln) wird unterdrückt. Bewegung >10 px
bricht den Long-Press ab (kollidiert nicht mit Pan/Zoom). Legenden-Hinweis
ergänzt. e2e: Long-Press öffnet das Menü (desktop; phone-Projekt übersprungen,
da synthetischer Touch dort instabil).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:09:47 +02:00
438c816820 feat: Fehler-melden + Datenherkunft auf Kontakte & Würfe erweitern
- Kontakt-Detailseite: „Fehler melden"- und „Datenherkunft"-Button.
- Wurf-Ansicht: „Datenherkunft"-Button (Feedback war bereits vorhanden).
- Feedback-Entity um loses, nullable ContactId erweitert (kein FK → übersteht
  Ingest-Wipe); Migration AddFeedbackContactId.
- Contact.Provenance + Litter.Provenance (nullable text); Migration
  AddContactLitterProvenance; im Ingest gemappt und in den DTOs zurückgegeben.
- Import: build_entity_provenance() generalisiert; Kontakte (sourceFiles,
  Züchter/Abnehmer-Hinweise) und Würfe (Wurfchronik vs. Diagramm-rekonstruiert,
  Geschwister-Merge) erhalten Herkunftsdaten in resolved_import.json.
- Frontend: ProvenanceDialog generalisiert (EntityProvenance + entityLabel).

Tests erweitert (Ingest-Round-trip Kontakt/Wurf, contact-scoped Feedback
übersteht Wipe). dotnet(212)/vitest(129)/playwright(36)/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:02:11 +02:00
9c98c37d2a feat(rennmausakte): Datenherkunft/Nachverfolgung pro Rennmaus
Neuer „Datenherkunft"-Button in der Rennmausakte öffnet einen Dialog, der zeigt,
aus welchen Quellen der Eintrag erzeugt wurde: Quelldateien (Stammbäume +
Wurfchronik), Anzahl zusammengeführter Datensätze, Eltern-Herleitung
(Methode/Konfidenz) und Hinweise (z. B. „per manueller Entscheidung zugeordnet",
„Konflikt gelöst", „aus N Datensätzen zusammengeführt").

Import: build_provenance() in merge_and_resolve.py sammelt die Herkunft über alle
deduplizierten Datensätze und schreibt sie als Provenance-JSON je Tier in
resolved_import.json. Backend: Gerbil.Provenance (nullable text) + Migration
AddGerbilProvenance, gemappt im Ingest und im GerbilDto zurückgegeben. Frontend:
ProvenanceDialog + Typen + Strings.

Tests: Ingest-Round-trip (vorhanden/abwesend), e2e provenance.spec.ts.
dotnet(212)/vitest(129)/playwright/tsc/eslint grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:45:15 +02:00
b9e5b031c4 feat(feedback): Fehler-melden-Dialog + ID-kopieren (Stammbaum/Akte/Wurf)
Rechtsklick auf eine Maus im Stammbaum öffnet ein Kontextmenü mit „ID kopieren"
(Clipboard + Toast) und „Fehler melden". Zusätzlich „Fehler melden"-Buttons in
der Rennmausakte und der Wurf-Ansicht. Der Dialog erfasst eine Beschreibung und
sendet sie samt Debug-Kontext (Ansicht, Tier-/Wurf-ID + Name, URL, Zeitstempel,
User-Agent) an POST /feedback; gespeichert in einer neuen Feedback-Tabelle.

Backend: Feedback-Entity (lose nullable GerbilId/LitterId ohne FK), Endpoints
POST/GET /feedback, EF-Migration AddFeedback. Die Tabelle wird vom Import-Ingest
NICHT geleert — Feedback überlebt Re-Ingests (Test deckt das ab).

Tests: FeedbackEndpointTests (persistiert, 400 bei leer, übersteht Ingest-Wipe);
e2e feedback.spec.ts. tsc/eslint/vitest(129)/playwright(6)/dotnet(212) grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:30:42 +02:00
1845d37476 fix(import): Eltern-Ref-Auswahl geschlechts-bewusst machen (Regression behoben)
Die in 03ad9e6 eingeführte DOB-Bevorzugung war zu grob: ein datierter, aber
geschlechts-falscher Ref (z. B. weibliche „Danielle") gewann den Vater-Slot
gegen einen undatieren männlichen/unbekannten (z. B. „Hagrid Rubeus") — wodurch
Mollys korrekte Mutter Arya Stark in der Rollen-Normalisierung verloren ging.

pick_parent_ref wertet jetzt zuerst das (aus den Tierdaten ermittelte) Geschlecht
für die Rolle: ein klar falsches Geschlecht wird stark abgewertet, erst danach
zählt plausibles DOB > kein DOB > unplausibles DOB. Fixt Molly (Hagrid × Arya)
UND behält Solice (Lui × Molly = Geschwister von Silvain). Keine Abdeckungs-
Regression (0 Selbst-Verpaarungen / 0 Gender-Fehler / 0 Alter-Verletzungen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:11:53 +02:00
2868aad369 feat(stammbaum): gleiches Geburtsdatum der Eltern als Geschwister-Indiz werten
Ein identisches Geburtsdatum bei Vater und Mutter ist ein starkes Indiz für eine
Vollgeschwister-Verpaarung — ein gemeinsamer Wurf ist praktisch der einzige
Grund dafür, und die Eltern-Würfe sind in den importierten Daten nicht immer
verknüpft (gleiche litterId fehlt dann).

Die Geschwister-Erkennung (Diagramm-Zusammenführung in build.ts + Akte-Chip in
GerbilDetailPage) greift jetzt bei gleicher litterId ODER gleichem, gesetztem
Geburtsdatum beider Eltern (distinkte Tiere vorausgesetzt).

Tests: gleiches DOB bei fehlender/verschiedener litterId → erkannt; unterschied-
liches DOB → nicht erkannt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:02:19 +02:00
03ad9e6e81 fix(import): Eltern-Alter-Sanity-Check + plausiblere Eltern-Auswahl aus Chart-Refs
Eine Rennmaus lebt max ~6 Jahre, kann also nicht Elternteil eines Tiers sein,
das nach ihrem Tod (oder vor ihrer Geburt) geboren wurde. Bisher fehlte diese
Prüfung: z. B. war Jayjay (*2013) als Vater von Solice (*2022) eingetragen, und
Solice/Silvain (Vollgeschwister, beide *27.03.2022) landeten in getrennten
Würfen mit verschiedenen Eltern.

- parent_age_plausible(): Elternteil muss vor dem Kind UND innerhalb der
  Lebensspanne (≤6 J.) geboren sein. Genutzt im Namens-Resolver (Kandidaten-
  filter) und als finaler Sanity-Pass, der unmögliche FatherId/MotherId verwirft
  und im Log auflistet.
- pick_parent_ref(): bei mehreren widersprüchlichen Chart-parentRefs wird nicht
  mehr blind der erste genommen, sondern ein alters-plausibler bevorzugt und das
  Tier der jeweils anderen Elternrolle gemieden (Vollgeschwister-Charts nennen
  denselben Namen in beiden Slots).

Ergebnis: Solice = Lui × Molly = Geschwister von Silvain (gemeinsamer Wurf);
0 Selbst-Verpaarungen, 0 Gender-Rollen-Fehler, 0 Eltern-Alter-Verletzungen über
alle 977 Würfe. Tests in test_merge_resolve.py ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:59:34 +02:00
18efab6996 fix(import): Foto-Generations-Shift beim Stammbaum-Import beheben
Fotos liegen in den Stammbaum-xlsx je nach Datei links, rechts oder auf der
Namenszelle. Die alte Seiten-Erkennung hing an festen Spalten (col 1 / col 4):
Blätter ohne solche Anker wurden als Rechts-Layout fehlgedeutet, wodurch jedes
Foto eine Generation zum Probanden hin verrutschte. Folge: Kazuya trug das Foto
seines Vaters (Wilbur), Wilbur das seines Großvaters (Elay), Elay hatte keins.

Fix (extract._attach_photos): beide Interpretationen (links/rechts) durchrechnen
und die wählen, die jedes Foto am nächsten an die Namensspalte seines Tiers legt
(minimale horizontale Fehlausrichtung). Diagnose über alle 42 Dateien: keine ist
echt rechts-seitig; ~21 waren fehlklassifiziert. Selbstjustierend statt fester
Spalten-Annahme.

Regressionstest in test_extract.py: Kazuya ohne Foto, Wilbur/Elay mit ihren
eigenen (gated auf vorhandene Quelldatei).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:49:38 +02:00
070a896072 feat(rennmausakte): Hinweis-Chip für Geschwisterverpaarung in der Tierakte
Stammt ein Tier aus einer Vollgeschwister-Verpaarung (Vater und Mutter teilen
dieselbe litterId), zeigt die Rennmausakte jetzt einen Chip
"⚭ Geschwisterverpaarung" in der Fakten-Zeile — neben Geschlecht, Geburtsdatum
und Farbschlag, in Akzentfarbe hervorgehoben (zuchtrelevant).

e2e-Test: Chip erscheint für 'Inzucht Kind' (Eltern aus demselben Wurf),
nicht für 'Krümel' (Eltern aus verschiedenen Würfen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:32:36 +02:00
3715e32303 fix(stammbaum): Geschwister-Verpaarung erkennen und im Diagramm zusammenführen
Vollgeschwister-Verpaarung (Vater & Mutter aus demselben Wurf) ist in der
Gerbil-Zucht häufig. Bisher zeigten Diagramm und Import sie nicht korrekt.

Import (merge_and_resolve.py):
- Wurf-Dedup gehärtet: asymmetrischer Merge nur bei kompatiblen Eltern-Namen;
  bei Date=None nur mit positivem Namens-Match (verhindert blindes Verschmelzen
  unverwandter datumloser Stubs). Falsch-Merges 112 -> 70.
- Parent-Resolver robust: Gender ist Präferenz statt hartem Filter, sodass
  vertauschte Eltern (z. B. weibliches Tier in der Vater-Position) trotzdem
  auflösen. Aufgelöste Eltern 200/206 -> 240/260.
- Neue Rollen-Normalisierung: weist jede Maus rollenrichtig nach Geschlecht zu,
  entfernt Selbst-Verpaarungen und unmögliche Doppelrollen.
  Selbst-Verpaarungen 16 -> 0, Gender-Rollen-Fehler 30 -> 0.
- Reine Helfer (litter_compatible/assign_parent_roles/names_*) auf Modulebene
  extrahiert und in test_merge_resolve.py (26 Tests) abgesichert.

Frontend (pedigree):
- buildAnimal markiert den Mutter-Knoten, wenn beide Eltern dieselbe litterId
  teilen (Vollgeschwister). toRawNodeDatum führt dessen Vorfahren-Ast zu einem
  Verweis-Knoten zusammen ("Geschwister von <Vater>, Eltern siehe oben"); die
  Vaterlinie zeigt die gemeinsamen Großeltern einmal.
- Druck-Ahnentafel bleibt vollständig (ignoriert die Marke).
- Build-Tests (+3) und e2e-Test (Inzucht-Kind-Fixture) ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:02:45 +02:00
51 changed files with 9578 additions and 121 deletions

View File

@@ -0,0 +1,181 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Import;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace GerbilManager.Tests;
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
/// - Validation: an empty message is rejected with 400.
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
/// </summary>
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
[Fact]
public async Task Post_feedback_persists_row_with_debug_context()
{
var client = _factory.CreateClient();
var gerbilId = Guid.NewGuid();
var resp = await client.PostAsJsonAsync("/feedback", new
{
message = "Der Stammbaum zeigt den falschen Vater.",
context = "stammbaum",
gerbilId,
litterId = (Guid?)null,
entityName = "Krümel",
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
clientTimestamp = "2026-06-22T12:00:00Z",
});
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
// GET returns it (newest first)
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
Assert.Contains(listed.EnumerateArray(),
f => f.GetProperty("entityName").GetString() == "Krümel"
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater.");
}
[Fact]
public async Task Post_feedback_rejects_empty_message()
{
var client = _factory.CreateClient();
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
}
[Fact]
public async Task Feedback_survives_ingest_wipe()
{
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var contactId = Guid.NewGuid();
var fatherId = Guid.NewGuid();
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var data = new
{
Contacts = new[]
{
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
},
Litters = new[]
{
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
},
Gerbils = new[]
{
Animal(fatherId, "Papa", "male", contactId),
Animal(motherId, "Mama", "female", contactId),
},
GerbilPhotos = Array.Empty<object>(),
};
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
var opts = new DbContextOptionsBuilder<ApplicationContext>()
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
.Options;
using var db = new ApplicationContext(opts);
db.Database.EnsureCreated();
// A feedback report referencing the gerbil + litter that the wipe will delete.
var feedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = feedbackId,
Message = "Bitte prüfen.",
Context = "gerbil-detail",
GerbilId = fatherId,
LitterId = litterId,
EntityName = "Papa",
Url = "http://localhost/rennmaeuse/papa",
CreatedAt = DateTimeOffset.UtcNow,
});
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
// so it must survive the contact-table wipe just like gerbil/litter ids.
var contactFeedbackId = Guid.NewGuid();
db.Feedback.Add(new Feedback
{
Id = contactFeedbackId,
Message = "Adresse stimmt nicht.",
Context = "contact-detail",
ContactId = contactId,
EntityName = "Test Breeder",
Url = "http://localhost/kontakte/test-breeder",
CreatedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
.Build();
// Run the ingest wipe + reload.
var result = await new IngestResolvedService(db, config, null!).RunAsync();
Assert.Contains("Ingestion successful!", result);
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
Assert.Equal(litterId, survivor.LitterId);
Assert.Equal("Papa", survivor.EntityName);
// The contact-scoped report also survives the contacts wipe (loose ContactId).
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
Assert.Equal(contactId, contactSurvivor.ContactId);
Assert.Equal("contact-detail", contactSurvivor.Context);
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
Assert.Equal(2, await db.Feedback.CountAsync());
}
finally
{
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
}
}
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
{
Id = id,
Name = name,
Gender = gender,
Status = "Breeding",
LitterId = (Guid?)null,
OriginContactId = contactId,
ReceiverContactId = (Guid?)null,
EnclosureId = (Guid?)null,
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
DateOfBirth = "2025-01-01",
DateOfDeath = (string?)null,
CauseOfDeath = (string?)null,
GoHomeDate = (string?)null,
Genotype = "aa CC DD EE GG PP spsp rere",
Notes = "",
ImportSource = "docx-export",
ExternalRef = "ext-" + name,
RawImportData = "{}",
OriginBreeder = "Test Zucht",
NameSearch = name.ToLowerInvariant(),
CharacterTraits = Array.Empty<string>(),
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
};
}

View File

@@ -36,7 +36,8 @@ namespace GerbilManager.Tests
Email = "test@example.com", Email = "test@example.com",
Homepage = "", Homepage = "",
Phone = "", Phone = "",
Address = "" Address = "",
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
} }
}, },
Litters = new[] Litters = new[]
@@ -54,7 +55,8 @@ namespace GerbilManager.Tests
Notes = "Test litter notes", Notes = "Test litter notes",
PairingCode = "PC01", PairingCode = "PC01",
ExternalRef = "ext-litter-1", ExternalRef = "ext-litter-1",
LitterLetter = "A" LitterLetter = "A",
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
} }
}, },
Gerbils = new[] Gerbils = new[]
@@ -84,7 +86,8 @@ namespace GerbilManager.Tests
CharacterTraits = new string[] {}, CharacterTraits = new string[] {},
CharacterNote = (string?)null, CharacterNote = (string?)null,
IsDeaf = false, IsDeaf = false,
IsResident = true IsResident = true,
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
}, },
new new
{ {
@@ -111,7 +114,8 @@ namespace GerbilManager.Tests
CharacterTraits = new string[] {}, CharacterTraits = new string[] {},
CharacterNote = (string?)null, CharacterNote = (string?)null,
IsDeaf = false, IsDeaf = false,
IsResident = true IsResident = true,
Provenance = (string?)null
} }
}, },
GerbilPhotos = new[] GerbilPhotos = new[]
@@ -176,6 +180,25 @@ namespace GerbilManager.Tests
Assert.Equal(mother.Id, litter.MotherId); Assert.Equal(mother.Id, litter.MotherId);
Assert.Equal(father.Id, photo.GerbilId); Assert.Equal(father.Id, photo.GerbilId);
Assert.Equal(father.OriginContactId, mother.OriginContactId); Assert.Equal(father.OriginContactId, mother.OriginContactId);
// Provenance round-trips verbatim through ingest (and stays null when absent).
Assert.NotNull(father.Provenance);
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
Assert.Contains("mergedRecordCount", father.Provenance);
// The chronological, file-attributed history survives ingest verbatim
// (stored as opaque JSON on the text column — no schema for it).
Assert.Contains("history", father.Provenance);
Assert.Contains("Genotyp aus", father.Provenance);
Assert.Null(mother.Provenance);
// Contact + litter provenance also round-trips through the ingest.
var contact = await db.Contacts.SingleAsync();
Assert.NotNull(contact.Provenance);
Assert.Contains("als Züchter erkannt", contact.Provenance);
Assert.NotNull(litter.Provenance);
Assert.Contains("aus Wurfchronik", litter.Provenance);
Assert.Contains("fromWurfchronik", litter.Provenance);
} }
} }
} }

View File

@@ -24,6 +24,7 @@ public class ApplicationContext : DbContext
public DbSet<Media> Media => Set<Media>(); public DbSet<Media> Media => Set<Media>();
public DbSet<Request> Requests => Set<Request>(); public DbSet<Request> Requests => Set<Request>();
public DbSet<MailSettings> MailSettings => Set<MailSettings>(); public DbSet<MailSettings> MailSettings => Set<MailSettings>();
public DbSet<Feedback> Feedback => Set<Feedback>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key), // Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity. // so it can never drift from Name regardless of which code path mutates the entity.
@@ -202,6 +203,15 @@ public class ApplicationContext : DbContext
modelBuilder.Entity<MailSettings>() modelBuilder.Entity<MailSettings>()
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId }); .HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
// FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable
// Guid columns (no navigation properties → EF creates NO foreign key), so the
// import re-ingest wipe of Gerbils/Litters never cascades into — or breaks —
// feedback rows. They survive re-ingest, which is the whole point.
modelBuilder.Entity<Feedback>(e =>
{
e.HasIndex(f => f.CreatedAt);
});
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only). // DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
if (isNpgsql) if (isNpgsql)
{ {

View File

@@ -32,7 +32,8 @@ namespace GerbilManagerWebAPI.Dtos
bool? IsDeaf, bool? IsDeaf,
bool IsResident, bool IsResident,
string? ProfilePhotoUrl, string? ProfilePhotoUrl,
bool IsCastrated); bool IsCastrated,
string? Provenance);
public record LitterDto( public record LitterDto(
Guid Id, Guid Id,
@@ -43,9 +44,10 @@ namespace GerbilManagerWebAPI.Dtos
Guid? FatherId, Guid? FatherId,
Guid? MotherId, Guid? MotherId,
DateOnly? ExpectedGoHomeDate, DateOnly? ExpectedGoHomeDate,
string? Notes); string? Notes,
string? Provenance);
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix); public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
public record EnclosureDto(Guid Id, string Name, string? Notes); public record EnclosureDto(Guid Id, string Name, string? Notes);

View File

@@ -0,0 +1,27 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
public record FeedbackInput(
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp);
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
public record FeedbackDto(
Guid Id,
string Message,
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp,
string? UserAgent,
DateTimeOffset CreatedAt);
}

View File

@@ -55,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
return app; return app;
} }
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix); private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix, c.Provenance);
} }
} }

View File

@@ -0,0 +1,63 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// FEEDBACK: the "Fehler melden" report sink.
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
/// GET /feedback -> list reports, newest first (for later review).
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
/// rows survive the import re-ingest wipe.
/// </summary>
public static class FeedbackEndpoints
{
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/feedback").WithTags("Feedback");
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
FeedbackInput input, ApplicationContext db, HttpContext http) =>
{
if (string.IsNullOrWhiteSpace(input.Message))
return TypedResults.BadRequest("Message darf nicht leer sein.");
var entity = new Feedback
{
Id = Guid.NewGuid(),
Message = input.Message.Trim(),
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
GerbilId = input.GerbilId,
LitterId = input.LitterId,
ContactId = input.ContactId,
EntityName = input.EntityName,
Url = input.Url,
ClientTimestamp = input.ClientTimestamp,
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
CreatedAt = DateTimeOffset.UtcNow,
};
db.Feedback.Add(entity);
await db.SaveChangesAsync();
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
});
group.MapGet("/", async (ApplicationContext db) =>
{
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
var rows = await db.Feedback.AsNoTracking().ToListAsync();
return TypedResults.Ok(rows
.OrderByDescending(f => f.CreatedAt)
.Select(ToDto)
.ToList());
});
return app;
}
private static FeedbackDto ToDto(Feedback f) =>
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
f.ClientTimestamp, f.UserAgent, f.CreatedAt);
}
}

View File

@@ -137,6 +137,7 @@ namespace GerbilManagerWebAPI.Endpoints
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId, g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath, g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder, g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated); g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
g.Provenance);
} }
} }

View File

@@ -98,7 +98,7 @@ namespace GerbilManagerWebAPI.Endpoints
private static LitterDto ToDto(Litter l) => new( private static LitterDto ToDto(Litter l) => new(
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks, l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes); l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
} }
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary> /// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>

View File

@@ -103,6 +103,8 @@ namespace GerbilManagerWebAPI.Import
existingContact.Notes = c.Notes; existingContact.Notes = c.Notes;
existingContact.IsBreeder = c.IsBreeder; existingContact.IsBreeder = c.IsBreeder;
existingContact.IsReceiver = c.IsReceiver; existingContact.IsReceiver = c.IsReceiver;
existingContact.NameSuffix = c.NameSuffix;
existingContact.Provenance = c.Provenance;
contactsUpdated++; contactsUpdated++;
} }
else if (addedContactIds.Add(c.Id)) else if (addedContactIds.Add(c.Id))
@@ -130,7 +132,8 @@ namespace GerbilManagerWebAPI.Import
Notes = l.Notes, Notes = l.Notes,
PairingCode = l.PairingCode, PairingCode = l.PairingCode,
ExternalRef = l.ExternalRef, ExternalRef = l.ExternalRef,
LitterLetter = l.LitterLetter LitterLetter = l.LitterLetter,
Provenance = l.Provenance
}); });
} }
_db.Litters.AddRange(littersToInsert); _db.Litters.AddRange(littersToInsert);
@@ -160,6 +163,7 @@ namespace GerbilManagerWebAPI.Import
ImportSource = g.ImportSource, ImportSource = g.ImportSource,
ExternalRef = g.ExternalRef, ExternalRef = g.ExternalRef,
RawImportData = g.RawImportData, RawImportData = g.RawImportData,
Provenance = g.Provenance,
OriginBreeder = g.OriginBreeder, OriginBreeder = g.OriginBreeder,
NameSearch = g.NameSearch, NameSearch = g.NameSearch,
CharacterTraits = g.CharacterTraits, CharacterTraits = g.CharacterTraits,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddFeedback : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Feedback",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Message = table.Column<string>(type: "text", nullable: false),
Context = table.Column<string>(type: "text", nullable: false),
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
LitterId = table.Column<Guid>(type: "uuid", nullable: true),
EntityName = table.Column<string>(type: "text", nullable: true),
Url = table.Column<string>(type: "text", nullable: true),
ClientTimestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
UserAgent = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Feedback", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Feedback_CreatedAt",
table: "Feedback",
column: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Feedback");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddGerbilProvenance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Provenance",
table: "Gerbils",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Provenance",
table: "Gerbils");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddFeedbackContactId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ContactId",
table: "Feedback",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContactId",
table: "Feedback");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContactLitterProvenance : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Provenance",
table: "Litters",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Provenance",
table: "Contacts",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Provenance",
table: "Litters");
migrationBuilder.DropColumn(
name: "Provenance",
table: "Contacts");
}
}
}

View File

@@ -741,6 +741,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Phone") b.Property<string>("Phone")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Contacts"); b.ToTable("Contacts");
@@ -793,6 +796,51 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("EnclosurePhotos"); b.ToTable("EnclosurePhotos");
}); });
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ClientTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ContactId")
.HasColumnType("uuid");
b.Property<string>("Context")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntityName")
.HasColumnType("text");
b.Property<Guid?>("GerbilId")
.HasColumnType("uuid");
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.HasColumnType("text");
b.Property<string>("UserAgent")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("Feedback");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -870,6 +918,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("OriginContactId") b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<string>("RawImportData") b.Property<string>("RawImportData")
.HasColumnType("text"); .HasColumnType("text");
@@ -1001,6 +1052,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("PairingCode") b.Property<string>("PairingCode")
.HasColumnType("text"); .HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<int?>("TotalBorn") b.Property<int?>("TotalBorn")
.HasColumnType("integer"); .HasColumnType("integer");

View File

@@ -24,5 +24,11 @@ namespace GerbilManagerWebAPI.Models
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar. /// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
/// </summary> /// </summary>
public string? NameSuffix { get; set; } public string? NameSuffix { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this contact (sourceFiles, mergedRecordCount,
/// notes). Written by the Python merge_and_resolve step and surfaced read-only
/// ("Datenherkunft"). Null = manually-added contact / no import data.</summary>
public string? Provenance { get; set; }
} }
} }

View File

@@ -0,0 +1,47 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// FEEDBACK: a user-submitted "Fehler melden" report. Decoupled from the rest of the
/// model on purpose — GerbilId/LitterId are plain nullable Guid columns (NOT enforced
/// foreign keys), so the import re-ingest wipe (IngestResolvedService) can delete
/// gerbils/litters without deleting or breaking feedback rows. The captured EntityName
/// keeps the report human-readable even after the referenced animal is gone.
/// </summary>
public class Feedback
{
[Key]
public Guid Id { get; set; }
/// <summary>The user's free-text description of the problem.</summary>
public required string Message { get; set; }
/// <summary>Which view the report came from: stammbaum | gerbil-detail | litter-detail.</summary>
public required string Context { get; set; }
/// <summary>Loose reference (no FK) to the gerbil the report is about, if any.</summary>
public Guid? GerbilId { get; set; }
/// <summary>Loose reference (no FK) to the litter the report is about, if any.</summary>
public Guid? LitterId { get; set; }
/// <summary>Loose reference (no FK) to the contact the report is about, if any.</summary>
public Guid? ContactId { get; set; }
/// <summary>Captured name of the referenced animal/litter (survives an ingest wipe).</summary>
public string? EntityName { get; set; }
/// <summary>The client URL/route the report was filed from.</summary>
public string? Url { get; set; }
/// <summary>Client-supplied timestamp (when the user submitted, in their browser).</summary>
public DateTimeOffset? ClientTimestamp { get; set; }
/// <summary>Optional browser user-agent for diagnostics.</summary>
public string? UserAgent { get; set; }
/// <summary>Server-side creation time.</summary>
public DateTimeOffset CreatedAt { get; set; }
}
}

View File

@@ -49,6 +49,13 @@ namespace GerbilManagerWebAPI.Models
public string? ImportSource { get; set; } public string? ImportSource { get; set; }
public string? ExternalRef { get; set; } public string? ExternalRef { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this entry (sourceFiles, mergedRecordCount,
/// fromWurfchronik, parentMethod/parentConfidence, notes). Written by the Python
/// merge_and_resolve step and surfaced read-only in the Rennmausakte
/// ("Nachverfolgungsinformationen"). Null = manually-added animal / no import data.</summary>
public string? Provenance { get; set; }
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like /// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary> /// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
public string? RawImportData { get; set; } public string? RawImportData { get; set; }

View File

@@ -38,5 +38,12 @@ namespace GerbilManagerWebAPI.Models
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses /// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary> /// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
public string? LitterLetter { get; set; } public string? LitterLetter { get; set; }
/// <summary>Data-provenance / traceability for the import: a JSON object describing
/// WHICH source information produced this litter (sourceFiles, mergedRecordCount,
/// fromWurfchronik, notes — e.g. "aus Wurfchronik", "aus Stammbaum-Diagramm rekonstruiert").
/// Written by the Python merge_and_resolve step and surfaced read-only ("Datenherkunft").
/// Null = manually-added litter / no import data.</summary>
public string? Provenance { get; set; }
} }
} }

View File

@@ -126,6 +126,7 @@ app.MapExportEndpoints();
app.MapCmsEndpoints(); app.MapCmsEndpoints();
app.MapRequestEndpoints(); app.MapRequestEndpoints();
app.MapNamesEndpoints(); app.MapNamesEndpoints();
app.MapFeedbackEndpoints();
app.Run(); app.Run();

View File

@@ -0,0 +1,132 @@
/** FEEDBACK: "Fehler melden" Dialog + Stammbaum-Rechtsklick ("ID kopieren" / "Fehler melden"). */
import { de, expect, skipUnlessMock, test } from './fixtures'
const f = de.feedback
const st = de.pages.stammbaum
test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Button in den Aktionen öffnet den Dialog.
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
// Debug-Kontext ist sichtbar (automatisch erfasst).
await expect(dialog).toContainText(f.debugTitle)
await expect(dialog).toContainText(f.contexts['gerbil-detail'])
// Beschreibung eintragen + senden.
await dialog.getByLabel(f.label).fill('Der Farbschlag stimmt nicht.')
await dialog.getByRole('button', { name: f.submit }).click()
// Erfolgs-Toast + Dialog schließt.
await expect(page.getByText(f.success)).toBeVisible()
await expect(dialog).not.toBeVisible()
// Der Bericht wurde mit Debug-Kontext gespeichert.
expect(mockDb).not.toBeNull()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({
message: 'Der Farbschlag stimmt nicht.',
context: 'gerbil-detail',
gerbilId: 'kruemel',
})
expect(reports[0].url).toContain('/rennmaeuse/kruemel')
expect(typeof reports[0].clientTimestamp).toBe('string')
})
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts['litter-detail'])
await dialog.getByLabel(f.label).fill('Die Wurfstärke ist falsch.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
})
test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
await page.getByRole('button', { name: f.button }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts['contact-detail'])
await dialog.getByLabel(f.label).fill('Die Adresse stimmt nicht.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
})
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
// Rechtsklick auf die Wurzelkarte (Krümel).
await page.locator('.pedigree-card--root').click({ button: 'right' })
const menu = page.locator('.stammbaum-context-menu')
await expect(menu).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.copyId })).toBeVisible()
await expect(menu.getByRole('menuitem', { name: st.contextMenu.reportError })).toBeVisible()
// "Fehler melden" öffnet den Dialog mit Stammbaum-Kontext.
await menu.getByRole('menuitem', { name: st.contextMenu.reportError }).click()
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(f.contexts.stammbaum)
await dialog.getByLabel(f.label).fill('Stammbaum-Bug.')
await dialog.getByRole('button', { name: f.submit }).click()
await expect(page.getByText(f.success)).toBeVisible()
const reports = mockDb!.feedback
expect(reports.length).toBe(1)
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
})
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {
skipUnlessMock()
// Der Long-Press-Code-Pfad (onTouchStart → Timer → Menü) ist projekt-unabhängig
// identisch; im Touch-emulierten phone-Projekt ist das Auslösen eines
// synthetischen TouchEvents unzuverlässig, daher hier auf das desktop-Projekt
// beschränkt (validiert dieselbe Logik).
testInfo.skip(testInfo.project.name === 'phone', 'synthetischer Long-Press im phone-Projekt instabil')
await page.goto('/rennmaeuse/kruemel/stammbaum')
const card = page.locator('.pedigree-card--root')
await expect(card).toBeVisible()
// Long-Press simulieren: echtes touchstart (mit Touch-Objekt) auslösen; der
// Timer im PedigreeCard öffnet nach ~500 ms das Menü.
const dispatched = await card.evaluate((el) => {
if (typeof Touch === 'undefined' || typeof TouchEvent === 'undefined') return false
const r = el.getBoundingClientRect()
const t = new Touch({ identifier: 1, target: el, clientX: r.left + 10, clientY: r.top + 10 })
el.dispatchEvent(
new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t] }),
)
return true
})
test.skip(!dispatched, 'TouchEvent in diesem Projekt nicht verfügbar')
await expect(page.locator('.stammbaum-context-menu')).toBeVisible({ timeout: 2000 })
await expect(
page.locator('.stammbaum-context-menu').getByRole('menuitem', { name: st.contextMenu.copyId }),
).toBeVisible()
})

View File

@@ -397,6 +397,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 200, result) return json(route, 200, result)
} }
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
if (path === '/feedback') {
if (method === 'POST') {
const body = request.postDataJSON() as Row
const created = {
id: newId('feedback'),
...body,
userAgent: request.headers()['user-agent'] ?? null,
createdAt: new Date().toISOString(),
}
db.feedback.push(created)
return json(route, 201, created)
}
if (method === 'GET') {
return json(route, 200, [...db.feedback].reverse())
}
return json(route, 405)
}
// Generische Kollektionen: /<resource> und /<resource>/<id> // Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/) m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined const col = m ? collections[m[1]] : undefined

View File

@@ -76,6 +76,8 @@ export interface MockDb {
saleAdConfigured: boolean saleAdConfigured: boolean
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren // FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
namesConfigured: boolean namesConfigured: boolean
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
feedback: Record<string, unknown>[]
} }
function gerbil( function gerbil(
@@ -120,6 +122,23 @@ export function seedDb(): MockDb {
enclosureId: 'enc-gross', enclosureId: 'enc-gross',
originContactId: 'con-meier', originContactId: 'con-meier',
originBreeder: 'Clan-Kleine-Chaoten', originBreeder: 'Clan-Kleine-Chaoten',
// NACHVERFOLGUNG: Datenherkunft (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Stammbaum von Krümel.xlsx', 'Wurfchronik-Detail.docx'],
mergedRecordCount: 2,
fromWurfchronik: true,
parentMethod: 'chart-position',
parentConfidence: 'medium',
notes: ['aus 2 Datensätzen zusammengeführt'],
history: [
'In „Stammbaum von Krümel.xlsx“ gefunden.',
'Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.',
'Genotyp aus „Stammbaum von Krümel.xlsx“.',
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
'Eltern über Position im Stammbaum erkannt (Quelle: „Stammbaum von Krümel.xlsx“).',
'Angaben aus der Wurfchronik übernommen.',
],
}),
}, },
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' }, { ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'), gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
@@ -171,10 +190,34 @@ export function seedDb(): MockDb {
}, },
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail. // UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null), gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
// SIBLING-PAIRING: Vollgeschwister-Verpaarung (in der Zucht häufig). Beide
// Eltern von 'inzucht-kind' stammen aus DEMSELBEN Wurf 'w-zwillinge' → das
// Diagramm führt ihren Vorfahren-Ast zu einem Verweis-Knoten zusammen.
gerbil('zwilling-bock', 'Zwilling Bock', 'male', '2020-05-01', 'w-zwillinge', 'cv-agouti'),
gerbil('zwilling-maus', 'Zwilling Maus', 'female', '2020-05-01', 'w-zwillinge', 'cv-schwarz'),
{ ...gerbil('opa-w', 'Opa W', 'male', '2018-01-01', null, 'cv-agouti'), isResident: false },
{ ...gerbil('oma-u', 'Oma U', 'female', '2018-02-01', null, 'cv-gold'), isResident: false },
gerbil('inzucht-kind', 'Inzucht Kind', 'female', '2021-06-01', 'w-inzucht', 'cv-agouti'),
] ]
const litters: Litter[] = [ const litters: Litter[] = [
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 }, { id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' },
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' },
{
id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1,
// NACHVERFOLGUNG: Datenherkunft des Wurfs (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0009.md', 'Wurfchronik Teil 1_page_0028.md'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
history: [
'Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.',
'Auch in „Wurfchronik Teil 1_page_0028.md“ gefunden → Datensätze zusammengeführt.',
'Geschwister-Würfe zusammengeführt.',
],
}),
},
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' }, { id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' }, { id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' }, { id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
@@ -191,7 +234,21 @@ export function seedDb(): MockDb {
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt. // FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
const contacts: Contact[] = [ const contacts: Contact[] = [
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true }, {
id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true,
// NACHVERFOLGUNG: Datenherkunft des Kontakts (vom Import erzeugter JSON-String).
provenance: JSON.stringify({
sourceFiles: ['Wurfchronik Teil 1_page_0001.md', 'Stammbaum von Krümel.xlsx'],
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
history: [
'In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.',
'Auch in „Wurfchronik Teil 1_page_0001.md“ gefunden → Datensätze zusammengeführt.',
'Sowohl als Züchter als auch als Abnehmer geführt.',
],
}),
},
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true }, { id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false }, { id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false }, { id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
@@ -314,5 +371,6 @@ export function seedDb(): MockDb {
contracts: [], contracts: [],
saleAdConfigured: true, saleAdConfigured: true,
namesConfigured: true, namesConfigured: true,
feedback: [],
} }
} }

View File

@@ -0,0 +1,101 @@
/** NACHVERFOLGUNG: "Datenherkunft"-Button in der Tierakte öffnet den
* Nachverfolgungs-Dialog mit Quelldateien, Datensatzanzahl und Hinweisen. */
import { de, expect, skipUnlessMock, test } from './fixtures'
const p = de.provenance
test('Tierakte: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/rennmaeuse/kruemel')
// Button in den Aktionen öffnet den Dialog.
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: chronologischer, dateibezogener Verlauf als Primärinhalt.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ gefunden.')
await expect(dialog).toContainText('Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.')
await expect(dialog).toContainText(
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
)
// Quelldateien werden angezeigt.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Stammbaum von Krümel.xlsx')
await expect(dialog).toContainText('Wurfchronik-Detail.docx')
// Zusammenführungs-Info + Wurfchronik-Hinweis.
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
// Eltern-Herkunft (übersetzte Methode/Konfidenz).
await expect(dialog).toContainText(p.methods['chart-position'])
await expect(dialog).toContainText(p.confidences.medium)
// Hinweis aus dem Import.
await expect(dialog).toContainText('aus 2 Datensätzen zusammengeführt')
// Schließen (Footer-Button — Header-Button trägt dasselbe aria-label).
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})
test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ page }) => {
skipUnlessMock()
// Luna hat keine provenance im Mock → Leer-Zustand.
await page.goto('/rennmaeuse/luna')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(p.empty)
})
test('Kontakt: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/kontakte/con-meier')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: dateibezogene Schritte des Kontakts.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.')
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('als Züchter erkannt')
await expect(dialog).toContainText('als Abnehmer erkannt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})
test('Wurf: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
skipUnlessMock()
await page.goto('/wuerfe/w-kruemel')
await page.getByRole('button', { name: p.button }).click()
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
// VERLAUF: dateibezogene Schritte des Wurfs.
await expect(dialog).toContainText(p.historyTitle)
await expect(dialog).toContainText('Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.')
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
await expect(dialog).toContainText(p.mergedCount(2))
await expect(dialog).toContainText(p.fromWurfchronik)
await expect(dialog).toContainText('aus Wurfchronik')
await expect(dialog).toContainText('Geschwister-Würfe zusammengeführt')
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
await expect(dialog).not.toBeVisible()
})

View File

@@ -111,6 +111,33 @@ test('-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 }) await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
}) })
test('Geschwister-Verpaarung führt den Vorfahren-Ast zu einem Verweis-Knoten zusammen (SIBLING-PAIRING)', async ({ page }) => {
skipUnlessMock()
// 'Inzucht Kind': Eltern Zwilling Bock (♂) + Zwilling Maus (♀) stammen aus
// demselben Wurf 'w-zwillinge' (Opa W × Oma U) → Vollgeschwister.
await page.goto('/rennmaeuse/inzucht-kind/stammbaum')
await expect(page.locator('.pedigree-card').first()).toBeVisible()
const fit = page.getByRole('button', { name: t.zoomFit })
if (await fit.isVisible()) await fit.click()
await page.waitForTimeout(600)
// Vaterlinie zeigt die echten Großeltern (einmal). Der Vater-Knoten trägt den
// Namen als Link (die Verweis-Karte nur als Span — daher Link-Rolle eindeutig).
await expect(page.getByRole('link', { name: 'Zwilling Bock' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Opa W' })).toBeVisible()
await expect(page.locator('.pedigree-card').filter({ hasText: 'Oma U' })).toBeVisible()
// Mutter-Ast ist zu EINEM Verweis-Knoten zusammengeführt (Verweis auf den Vater).
const ref = page.locator('.pedigree-card--sibling')
await expect(ref).toBeVisible()
await expect(ref).toContainText(t.siblingPairing.refLabel('Zwilling Bock'))
await expect(ref).toContainText(t.siblingPairing.refHint)
// Mini-Legende ergänzt den Geschwister-Hinweis.
await expect(page.locator('.stammbaum-hints')).toContainText(t.hintSiblings)
})
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => { test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen. // Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.

View File

@@ -104,6 +104,20 @@ test('Detailseite: Wurf-Feld ist ein Link zur Wurf-Detailseite (WURF-LINK)', asy
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible() await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
}) })
test('Detailseite zeigt Geschwisterverpaarungs-Hinweis nur wenn die Eltern Vollgeschwister sind (SIBLING-PAIRING)', async ({ page }) => {
skipUnlessMock()
// 'Inzucht Kind': Eltern Zwilling Bock + Zwilling Maus aus demselben Wurf
// 'w-zwillinge' → Vollgeschwister → Hinweis sichtbar.
await page.goto('/rennmaeuse/inzucht-kind')
await expect(page.getByRole('heading', { name: 'Inzucht Kind' })).toBeVisible()
await expect(page.locator('.ak-fact--sibling')).toContainText(t.detail.siblingPairing)
// 'Krümel': Eltern Fridolin × Luna aus verschiedenen Würfen → kein Hinweis.
await page.goto('/rennmaeuse/kruemel')
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
await expect(page.locator('.ak-fact--sibling')).toHaveCount(0)
})
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => { test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
skipUnlessMock() skipUnlessMock()
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link // Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link

View File

@@ -0,0 +1,37 @@
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
import { api } from './client'
const RESOURCE = '/feedback'
/** Which view a report was filed from (matches the backend Context contract). */
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
/** Payload for POST /feedback. Debug fields are captured automatically by the caller. */
export interface FeedbackInput {
message: string
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
contactId?: string | null
entityName?: string | null
url?: string | null
clientTimestamp?: string | null
}
export interface Feedback {
id: string
message: string
context: string
gerbilId: string | null
litterId: string | null
contactId: string | null
entityName: string | null
url: string | null
clientTimestamp: string | null
userAgent: string | null
createdAt: string
}
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
return api.post<Feedback>(RESOURCE, body)
}

View File

@@ -62,8 +62,48 @@ export interface Gerbil {
profilePhotoUrl?: string | null profilePhotoUrl?: string | null
spottingType?: string | null spottingType?: string | null
isCastrated?: boolean isCastrated?: boolean
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link GerbilProvenance} für die geparste Form).
* null = manuell angelegtes Tier / keine Importdaten.
*/
provenance?: string | null
} }
/**
* NACHVERFOLGUNG: geparste Datenherkunft eines importierten Tiers (JSON aus
* {@link Gerbil.provenance}). Beschreibt, WELCHE Quellinformationen den
* Datenbankeintrag erzeugt haben.
*/
export interface GerbilProvenance {
/** Alle Quelldateien (Stammbäume / Wurfchronik), die beigetragen haben. */
sourceFiles: string[]
/** Anzahl der Rohdatensätze, die zu diesem Tier zusammengeführt wurden. */
mergedRecordCount: number
/** Hat ein Wurfchronik-Treffer beigetragen? */
fromWurfchronik: boolean
/** Wie die Eltern abgeleitet wurden (z. B. "chart-position", "decision"). */
parentMethod?: string
/** Konfidenz der Eltern-Ableitung (z. B. "low" | "medium" | "high"). */
parentConfidence?: string
/** Menschlich lesbare Herkunftshinweise (deutsch). */
notes: string[]
/**
* Chronologischer, dateibezogener Verlauf (deutsch): liest sich wie ein
* Protokoll, WELCHE Quelldatei WELCHE Angabe beigetragen hat (z. B.
* „Geburtsdatum (27.03.2022) aus Stammbaum von X.xlsx'."). Primärinhalt des
* Datenherkunft-Dialogs.
*/
history: string[]
}
/**
* NACHVERFOLGUNG: geparste Datenherkunft eines beliebigen importierten Eintrags
* (Tier, Kontakt oder Wurf). {@link GerbilProvenance} ist die Tier-Spezialisierung
* mit zusätzlichen Eltern-Feldern; Kontakte/Würfe nutzen dieselbe Grundform.
*/
export type EntityProvenance = GerbilProvenance
/** Payload for POST /gerbils. */ /** Payload for POST /gerbils. */
export interface CreateGerbil { export interface CreateGerbil {
name: string name: string
@@ -116,6 +156,11 @@ export interface Contact {
isReceiver: boolean isReceiver: boolean
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */ /** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
nameSuffix: string | null nameSuffix: string | null
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
*/
provenance?: string | null
} }
export interface Litter { export interface Litter {
@@ -136,6 +181,11 @@ export interface Litter {
motherId: string | null motherId: string | null
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */ /** LITTER-MORTALITY: pups that died within the first 8 weeks. */
deathsWithin8Weeks?: number | null deathsWithin8Weeks?: number | null
/**
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
*/
provenance?: string | null
} }
/** Payload for POST /litters. */ /** Payload for POST /litters. */

View File

@@ -0,0 +1,173 @@
/**
* NACHVERFOLGUNG: "Nachverfolgungsinformationen" — read-only modal showing the
* data provenance of an imported gerbil (which source files contributed, how many
* raw records were merged, how the parents were derived, and human-readable notes).
*
* The provenance arrives as a JSON string on the Gerbil DTO (produced by the Python
* merge step). We parse it here defensively; anything unparseable / absent is shown
* as "Keine Herkunftsdaten vorhanden." Mirrors the ReportErrorDialog modal pattern.
*/
import { useEffect } from 'react'
import { de } from '../strings/de'
import type { EntityProvenance } from '../api/types'
import './provenanceDialog.css'
interface ProvenanceDialogProps {
open: boolean
onClose: () => void
/** Raw provenance JSON string from the entity (Gerbil/Contact/Litter; null = no import data). */
provenance?: string | null
/** Optional entity label (e.g. "dieses Tiers", "dieses Kontakts") for the intro line. */
entityLabel?: string
}
/** Parse the JSON provenance string defensively; null on absence / parse error. */
function parseProvenance(raw?: string | null): EntityProvenance | null {
if (!raw || !raw.trim()) return null
try {
const p = JSON.parse(raw) as Partial<EntityProvenance>
return {
sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [],
mergedRecordCount: typeof p.mergedRecordCount === 'number' ? p.mergedRecordCount : 0,
fromWurfchronik: Boolean(p.fromWurfchronik),
parentMethod: p.parentMethod,
parentConfidence: p.parentConfidence,
notes: Array.isArray(p.notes) ? p.notes : [],
history: Array.isArray(p.history) ? p.history : [],
}
} catch {
return null
}
}
export default function ProvenanceDialog({ open, onClose, provenance, entityLabel }: ProvenanceDialogProps) {
// Close on Escape (only while mounted).
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
const t = de.provenance
const p = parseProvenance(provenance)
const method = p?.parentMethod ? (t.methods[p.parentMethod] ?? p.parentMethod) : null
const confidence = p?.parentConfidence
? (t.confidences[p.parentConfidence] ?? p.parentConfidence)
: null
return (
<div className="provenance__backdrop" onClick={onClose} role="presentation">
<div
className="provenance__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="provenance-title"
onClick={(e) => e.stopPropagation()}
>
<div className="provenance__header">
<h2 id="provenance-title" className="provenance__title">
{t.dialogTitle}
</h2>
<button
type="button"
className="provenance__close"
onClick={onClose}
aria-label={t.close}
>
</button>
</div>
{!p ? (
<p className="provenance__empty">{t.empty}</p>
) : (
<>
<p className="provenance__intro">{entityLabel ? t.introFor(entityLabel) : t.intro}</p>
{p.history.length > 0 && (
<section className="provenance__section">
<div className="provenance__section-title">{t.historyTitle}</div>
<ol className="provenance__history">
{p.history.map((step, i) => (
<li key={`${i}-${step}`} className="provenance__history-step">
{step}
</li>
))}
</ol>
</section>
)}
<section className="provenance__section">
<div className="provenance__section-title">
{t.mergedTitle}
</div>
<p className="provenance__merged">{t.mergedCount(p.mergedRecordCount)}</p>
{p.fromWurfchronik && (
<p className="provenance__wurfchronik">📖 {t.fromWurfchronik}</p>
)}
</section>
<section className="provenance__section">
<div className="provenance__section-title">
{t.sourceFilesTitle} {t.sourceFilesCount(p.sourceFiles.length)}
</div>
{p.sourceFiles.length === 0 ? (
<p className="provenance__empty">{t.empty}</p>
) : (
<ul className="provenance__files">
{p.sourceFiles.map((f) => (
<li key={f} className="provenance__file">
{f}
</li>
))}
</ul>
)}
</section>
{(method || confidence) && (
<section className="provenance__section">
<div className="provenance__section-title">{t.parentsTitle}</div>
<dl className="provenance__kvlist">
{method && (
<div className="provenance__kv">
<dt>{t.parentMethodLabel}</dt>
<dd>{method}</dd>
</div>
)}
{confidence && (
<div className="provenance__kv">
<dt>{t.parentConfidenceLabel}</dt>
<dd>{confidence}</dd>
</div>
)}
</dl>
</section>
)}
{p.notes.length > 0 && (
<section className="provenance__section">
<div className="provenance__section-title">{t.notesTitle}</div>
<ul className="provenance__notes">
{p.notes.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
</section>
)}
</>
)}
<div className="provenance__actions">
<button type="button" className="btn btn--primary" onClick={onClose}>
{t.close}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
/**
* FEEDBACK: "Fehler melden" — a small accessible modal with a textarea + submit.
*
* The caller passes the debug context (which view, the gerbil/litter id + name); the
* dialog captures the current URL and a client timestamp itself and POSTs everything
* to /feedback. Success/error is surfaced via the existing toast system.
*/
import { useEffect, useRef, useState } from 'react'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
import { submitFeedback, type FeedbackContext } from '../api/feedback'
import { useToast } from './toast'
import './reportErrorDialog.css'
export interface ReportErrorContext {
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
contactId?: string | null
entityName?: string | null
}
interface ReportErrorDialogProps {
open: boolean
onClose: () => void
context: ReportErrorContext
}
/**
* Thin wrapper: mounts the dialog body only while open, so its form state starts
* fresh on every open (no reset-in-effect needed).
*/
export default function ReportErrorDialog({ open, onClose, context }: ReportErrorDialogProps) {
if (!open) return null
return <ReportErrorDialogBody onClose={onClose} context={context} />
}
function ReportErrorDialogBody({
onClose,
context,
}: {
onClose: () => void
context: ReportErrorContext
}) {
const t = de.feedback
const toast = useToast()
const [message, setMessage] = useState('')
const [submitting, setSubmitting] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
// Focus the textarea once on mount.
useEffect(() => {
const id = window.setTimeout(() => textareaRef.current?.focus(), 0)
return () => window.clearTimeout(id)
}, [])
// Close on Escape.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
const contextLabel = t.contexts[context.context]
async function handleSubmit() {
const trimmed = message.trim()
if (!trimmed) {
toast.error(t.emptyMessage)
textareaRef.current?.focus()
return
}
setSubmitting(true)
try {
await submitFeedback({
message: trimmed,
context: context.context,
gerbilId: context.gerbilId ?? null,
litterId: context.litterId ?? null,
contactId: context.contactId ?? null,
entityName: context.entityName ?? null,
url: window.location.href,
clientTimestamp: new Date().toISOString(),
})
toast.success(t.success)
onClose()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : t.error)
setSubmitting(false)
}
}
return (
<div
className="report-error__backdrop"
onClick={onClose}
role="presentation"
>
<div
className="report-error__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="report-error-title"
onClick={(e) => e.stopPropagation()}
>
<div className="report-error__header">
<h2 id="report-error-title" className="report-error__title">
{t.dialogTitle}
</h2>
<button
type="button"
className="report-error__close"
onClick={onClose}
aria-label={t.close}
>
</button>
</div>
<p className="report-error__intro">{t.intro}</p>
<label className="report-error__label" htmlFor="report-error-message">
{t.label}
</label>
<textarea
id="report-error-message"
ref={textareaRef}
className="report-error__textarea"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={t.placeholder}
rows={5}
/>
<div className="report-error__debug">
<div className="report-error__debug-title">{t.debugTitle}</div>
<dl className="report-error__debug-list">
<div className="report-error__debug-row">
<dt>{t.debugFields.context}</dt>
<dd>{contextLabel}</dd>
</div>
{context.entityName && (
<div className="report-error__debug-row">
<dt>{t.debugFields.entity}</dt>
<dd>{context.entityName}</dd>
</div>
)}
<div className="report-error__debug-row">
<dt>{t.debugFields.url}</dt>
<dd className="report-error__debug-url">{window.location.href}</dd>
</div>
</dl>
</div>
<div className="report-error__actions">
<button type="button" className="btn" onClick={onClose} disabled={submitting}>
{t.cancel}
</button>
<button
type="button"
className="btn btn--primary"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? t.submitting : t.submit}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,171 @@
/* NACHVERFOLGUNG: "Nachverfolgungsinformationen" modal dialog. Mirrors the
reportErrorDialog modal pattern. */
.provenance__backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(43, 33, 25, 0.45);
}
.provenance__dialog {
width: 100%;
max-width: 32rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
background: var(--color-bg, #fff);
color: var(--color-text);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(43, 33, 25, 0.32);
padding: 1.25rem;
animation: provenanceIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes provenanceIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
.provenance__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.provenance__title {
margin: 0;
font-size: 1.15rem;
}
.provenance__close {
background: none;
border: none;
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted, #666);
padding: 0.25rem 0.4rem;
border-radius: 6px;
}
.provenance__close:hover {
background: var(--color-border, #eee);
}
.provenance__intro {
margin: 0 0 0.85rem;
font-size: 0.9rem;
color: var(--color-text-muted, #666);
}
.provenance__empty {
margin: 0.4rem 0;
font-size: 0.9rem;
color: var(--color-text-muted, #888);
font-style: italic;
}
.provenance__section {
margin-top: 0.9rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 8px;
background: var(--color-surface-2, rgba(0, 0, 0, 0.03));
}
.provenance__section-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.provenance__merged {
margin: 0;
font-size: 0.9rem;
}
.provenance__wurfchronik {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: var(--color-text-muted, #666);
}
.provenance__files {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
}
.provenance__file {
font-size: 0.85rem;
word-break: break-word;
}
.provenance__kvlist {
margin: 0;
display: grid;
gap: 0.25rem;
}
.provenance__kv {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
}
.provenance__kv dt {
flex: 0 0 6rem;
font-weight: 600;
color: var(--color-text-muted, #777);
}
.provenance__kv dd {
margin: 0;
min-width: 0;
word-break: break-word;
}
.provenance__notes {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
font-size: 0.85rem;
}
/* Chronologischer Verlauf: liest sich wie ein Protokoll der Datenherkunft. */
.provenance__history {
margin: 0;
padding-left: 1.25rem;
display: grid;
gap: 0.35rem;
}
.provenance__history-step {
font-size: 0.88rem;
line-height: 1.35;
word-break: break-word;
}
.provenance__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.1rem;
}

View File

@@ -0,0 +1,149 @@
/* FEEDBACK: "Fehler melden" modal dialog. */
.report-error__backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(43, 33, 25, 0.45);
}
.report-error__dialog {
width: 100%;
max-width: 32rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
background: var(--color-bg, #fff);
color: var(--color-text);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(43, 33, 25, 0.32);
padding: 1.25rem;
animation: reportErrorIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes reportErrorIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
.report-error__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.report-error__title {
margin: 0;
font-size: 1.15rem;
}
.report-error__close {
background: none;
border: none;
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted, #666);
padding: 0.25rem 0.4rem;
border-radius: 6px;
}
.report-error__close:hover {
background: var(--color-border, #eee);
}
.report-error__intro {
margin: 0 0 0.85rem;
font-size: 0.9rem;
color: var(--color-text-muted, #666);
}
.report-error__label {
display: block;
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.3rem;
}
.report-error__textarea {
width: 100%;
box-sizing: border-box;
font: inherit;
font-size: 0.95rem;
padding: 0.6rem 0.7rem;
border: 1px solid var(--color-border, #ccc);
border-radius: 8px;
resize: vertical;
min-height: 5.5rem;
background: var(--color-bg, #fff);
color: var(--color-text);
}
.report-error__textarea:focus {
outline: 2px solid var(--color-accent, #2563eb);
outline-offset: 1px;
}
.report-error__debug {
margin-top: 0.9rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 8px;
background: var(--color-surface-2, rgba(0, 0, 0, 0.03));
}
.report-error__debug-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.report-error__debug-list {
margin: 0;
display: grid;
gap: 0.25rem;
}
.report-error__debug-row {
display: flex;
gap: 0.5rem;
font-size: 0.85rem;
}
.report-error__debug-row dt {
flex: 0 0 5.5rem;
font-weight: 600;
color: var(--color-text-muted, #777);
}
.report-error__debug-row dd {
margin: 0;
min-width: 0;
word-break: break-word;
}
.report-error__debug-url {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
}
.report-error__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.1rem;
}

View File

@@ -14,6 +14,8 @@ import GerbilHealthTab from '../components/GerbilHealthTab'
import GerbilPhotosTab from '../components/GerbilPhotosTab' import GerbilPhotosTab from '../components/GerbilPhotosTab'
import GerbilProfilePhoto from '../components/GerbilProfilePhoto' import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
import GerbilWeightTab from '../components/GerbilWeightTab' import GerbilWeightTab from '../components/GerbilWeightTab'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix' import { useGerbilName } from '../components/breederSuffix'
import { useToast } from '../components/toast' import { useToast } from '../components/toast'
import './rennmausakte.css' import './rennmausakte.css'
@@ -57,6 +59,8 @@ export default function GerbilDetailPage() {
const toast = useToast() const toast = useToast()
const { id = '' } = useParams() const { id = '' } = useParams()
const [tab, setTab] = useState<DetailTab>('photos') const [tab, setTab] = useState<DetailTab>('photos')
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const gerbil = useApi(() => getGerbil(id), [id]) const gerbil = useApi(() => getGerbil(id), [id])
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' })) const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
@@ -137,6 +141,17 @@ export default function GerbilDetailPage() {
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—') const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
// Geschwisterverpaarung: Vater und Mutter dieses Tiers sind Vollgeschwister —
// erkennbar an gemeinsamer litterId ODER gleichem Geburtsdatum (starkes Indiz:
// ein identisches Datum entsteht praktisch nur durch einen gemeinsamen Wurf,
// und die Eltern-Würfe sind in den Daten nicht immer verknüpft).
const isSiblingPairing =
!!father.data &&
!!mother.data &&
father.data.id !== mother.data.id &&
((!!father.data.litterId && father.data.litterId === mother.data.litterId) ||
(!!father.data.dateOfBirth && father.data.dateOfBirth === mother.data.dateOfBirth))
const parentLink = ( const parentLink = (
pid: string | null, pid: string | null,
p: Parameters<typeof gerbilName>[0] | null, p: Parameters<typeof gerbilName>[0] | null,
@@ -204,6 +219,12 @@ export default function GerbilDetailPage() {
{storedColorName} {storedColorName}
</span> </span>
)} )}
{isSiblingPairing && (
<span className="ak-fact ak-fact--sibling" title={t.detail.siblingPairingTitle}>
<span aria-hidden="true"></span>
{t.detail.siblingPairing}
</span>
)}
</div> </div>
<div className="ak-actions"> <div className="ak-actions">
@@ -239,6 +260,12 @@ export default function GerbilDetailPage() {
{de.pages.vertraege.wizard.title} {de.pages.vertraege.wizard.title}
</Link> </Link>
)} )}
<button type="button" className="ak-btn" onClick={() => setProvenanceOpen(true)}>
{de.provenance.button}
</button>
<button type="button" className="ak-btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<Link to="/rennmaeuse" className="ak-btn"> <Link to="/rennmaeuse" className="ak-btn">
{t.detail.back} {t.detail.back}
</Link> </Link>
@@ -454,6 +481,18 @@ export default function GerbilDetailPage() {
</div> </div>
</section> </section>
</div> </div>
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'gerbil-detail', gerbilId: g.id, entityName: g.name || de.pages.gerbils.nameless }}
/>
<ProvenanceDialog
open={provenanceOpen}
onClose={() => setProvenanceOpen(false)}
provenance={g.provenance}
/>
</section> </section>
) )
} }

View File

@@ -12,12 +12,16 @@ import { listGerbils } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups' import { listColorVarieties } from '../api/lookups'
import { condition } from '../api/gridify' import { condition } from '../api/gridify'
import { useApi, useMutation } from '../hooks/useApi' import { useApi, useMutation } from '../hooks/useApi'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
export default function KontaktDetailPage() { export default function KontaktDetailPage() {
const t = de.pages.kontakte const t = de.pages.kontakte
const { id = '' } = useParams() const { id = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const [deleteError, setDeleteError] = useState<string | null>(null) const [deleteError, setDeleteError] = useState<string | null>(null)
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const contact = useApi(() => getContact(id), [id]) const contact = useApi(() => getContact(id), [id])
// Verknüpfte Tiere: Kontakt ist Herkunft ODER Abnehmer (Gridify-OR via |). // Verknüpfte Tiere: Kontakt ist Herkunft ODER Abnehmer (Gridify-OR via |).
@@ -79,6 +83,12 @@ export default function KontaktDetailPage() {
<Link to={`/kontakte/${c.id}/bearbeiten`} className="btn btn--primary"> <Link to={`/kontakte/${c.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit} {t.detail.edit}
</Link> </Link>
<button type="button" className="btn" onClick={() => setProvenanceOpen(true)}>
{de.provenance.button}
</button>
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<button <button
type="button" type="button"
className="btn btn--danger" className="btn btn--danger"
@@ -161,6 +171,19 @@ export default function KontaktDetailPage() {
))} ))}
</ul> </ul>
)} )}
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'contact-detail', contactId: c.id, entityName: c.name }}
/>
<ProvenanceDialog
open={provenanceOpen}
onClose={() => setProvenanceOpen(false)}
provenance={c.provenance}
entityLabel={de.provenance.entityLabels.contact}
/>
</section> </section>
) )
} }

View File

@@ -16,7 +16,7 @@
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per * - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export. * @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
*/ */
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import Tree from 'react-d3-tree' import Tree from 'react-d3-tree'
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree' import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
@@ -29,6 +29,8 @@ import type { Gender, Gerbil, Litter } from '../api/types'
import { useApi } from '../hooks/useApi' import { useApi } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels' import { formatDate, genderLabel } from '../format/labels'
import GerbilIcon from '../components/GerbilIcon' import GerbilIcon from '../components/GerbilIcon'
import ReportErrorDialog, { type ReportErrorContext } from '../components/ReportErrorDialog'
import { useToast } from '../components/toast'
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics' import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
import { import {
DEFAULT_GENERATIONS, DEFAULT_GENERATIONS,
@@ -90,6 +92,73 @@ export default function StammbaumPage() {
const t = de.pages.stammbaum const t = de.pages.stammbaum
const { id = '' } = useParams() const { id = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast()
/* ── Rechtsklick-Kontextmenü auf einer Ahnenkarte ── */
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
gerbil: Gerbil
} | null>(null)
const [reportContext, setReportContext] = useState<ReportErrorContext | null>(null)
const openContextMenuAt = useCallback((x: number, y: number, gerbil: Gerbil) => {
setContextMenu({ x, y, gerbil })
}, [])
// Rechtsklick (Desktop). Auf Touch-Geräten gibt es keinen Rechtsklick — dort
// öffnet ein langer Druck (Long-Press) dasselbe Menü, siehe PedigreeCard.
const openContextMenu = useCallback(
(e: ReactMouseEvent, gerbil: Gerbil) => {
e.preventDefault()
openContextMenuAt(e.clientX, e.clientY, gerbil)
},
[openContextMenuAt],
)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
const copyId = useCallback(
async (gerbilId: string) => {
closeContextMenu()
try {
await navigator.clipboard.writeText(gerbilId)
toast.success(de.feedback.idCopied)
} catch {
toast.error(de.feedback.idCopyFailed)
}
},
[closeContextMenu, toast],
)
const openReport = useCallback(
(gerbil: Gerbil) => {
closeContextMenu()
setReportContext({
context: 'stammbaum',
gerbilId: gerbil.id,
entityName: gerbil.name || de.pages.gerbils.nameless,
})
},
[closeContextMenu],
)
/* Menü schließt bei Klick/Scroll/Escape außerhalb. */
useEffect(() => {
if (!contextMenu) return
const onAway = () => closeContextMenu()
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeContextMenu()
}
window.addEventListener('click', onAway)
window.addEventListener('scroll', onAway, true)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('click', onAway)
window.removeEventListener('scroll', onAway, true)
window.removeEventListener('keydown', onKey)
}
}, [contextMenu, closeContextMenu])
/* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */ /* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */
const [source] = useState(createApiPedigreeSource) const [source] = useState(createApiPedigreeSource)
@@ -168,6 +237,13 @@ export default function StammbaumPage() {
/* ── react-d3-tree-Daten ── */ /* ── react-d3-tree-Daten ── */
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root]) const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t]) const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
const hasSiblingPairing = useMemo(
() =>
nodesByPath
? [...nodesByPath.values()].some((n) => n.kind === 'animal' && n.siblingPairing != null)
: false,
[nodesByPath],
)
/* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */ /* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */
const canvasRef = useRef<HTMLDivElement | null>(null) const canvasRef = useRef<HTMLDivElement | null>(null)
@@ -183,16 +259,27 @@ export default function StammbaumPage() {
return () => observer.disconnect() return () => observer.disconnect()
}, [hasRoot]) }, [hasRoot])
/* ── Ansicht (Zoom/Position): Wurzelkarte links, vertikal zentriert — /* ── Ansicht (Zoom/Position) ──
so sind die Eltern auch auf dem Smartphone sofort sichtbar. ── */ Desktop: den gesamten geladenen Baum einpassen, aber nie über 1
vergrößern (Karten bleiben lesbar). So sind alle geladenen Generationen
sofort sichtbar und die Fläche wird genutzt, statt rechte Generationen
abzuschneiden. Mobil: Wurzel links, vertikal zentriert (Pan), damit die
Karten auf dem kleinen Schirm nicht winzig werden. ── */
const defaultView = useMemo<View | null>(() => { const defaultView = useMemo<View | null>(() => {
if (!size) return null if (!size) return null
const zoom = size.w < 520 ? 0.8 : 1 const isPhone = size.w < 520
let zoom = isPhone ? 0.8 : 1
if (!isPhone && datum) {
const extent = datumExtent(datum)
const width = extent.depth * NODE_X + CARD_W + 32
const height = extent.leaves * NODE_Y + 32
zoom = clamp(Math.min(1, size.w / width, size.h / height), ZOOM_MIN, ZOOM_MAX)
}
return { return {
zoom, zoom,
translate: { x: (CARD_W / 2) * zoom + 24, y: size.h / 2 }, translate: { x: (CARD_W / 2) * zoom + 24, y: size.h / 2 },
} }
}, [size]) }, [size, datum])
const [viewOverride, setViewOverride] = useState<(View & { forId: string }) | null>(null) const [viewOverride, setViewOverride] = useState<(View & { forId: string }) | null>(null)
const view = viewOverride && viewOverride.forId === id ? viewOverride : defaultView const view = viewOverride && viewOverride.forId === id ? viewOverride : defaultView
@@ -246,6 +333,28 @@ export default function StammbaumPage() {
const renderNode = useCallback( const renderNode = useCallback(
({ nodeDatum }: CustomNodeElementProps) => { ({ nodeDatum }: CustomNodeElementProps) => {
const path = String(nodeDatum.attributes?.path ?? '') const path = String(nodeDatum.attributes?.path ?? '')
// Verweis-Knoten der Geschwister-Verpaarung: rein aus den Attributen
// gerendert (kein Eintrag in nodesByPath).
if (nodeDatum.attributes?.kind === 'sibling-ref') {
const refName = String(nodeDatum.attributes.refName ?? '') || de.pages.gerbils.nameless
return (
<g>
<foreignObject width={CARD_W} height={CARD_H} x={-CARD_W / 2} y={-CARD_H / 2}>
<div className="pedigree-card pedigree-card--sibling">
<span className="pedigree-card--sibling__icon" aria-hidden="true">
</span>
<div className="pedigree-card--sibling__body">
<span className="pedigree-card--sibling__label">
{t.siblingPairing.refLabel(refName)}
</span>
<span className="pedigree-card--sibling__hint">{t.siblingPairing.refHint}</span>
</div>
</div>
</foreignObject>
</g>
)
}
const node = nodesByPath?.get(path) const node = nodesByPath?.get(path)
return ( return (
<g> <g>
@@ -259,13 +368,15 @@ export default function StammbaumPage() {
farbschlag={farbschlagOf(node.gerbil)} farbschlag={farbschlagOf(node.gerbil)}
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)} onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
onExpand={() => handleExpand(path)} onExpand={() => handleExpand(path)}
onContextMenu={(e) => openContextMenu(e, node.gerbil)}
onLongPress={(x, y) => openContextMenuAt(x, y, node.gerbil)}
/> />
)} )}
</foreignObject> </foreignObject>
</g> </g>
) )
}, },
[nodesByPath, farbschlagOf, navigate, handleExpand, t], [nodesByPath, farbschlagOf, navigate, handleExpand, openContextMenu, openContextMenuAt, t],
) )
/* ── Zustände: Laden / Fehler ── */ /* ── Zustände: Laden / Fehler ── */
@@ -353,11 +464,49 @@ export default function StammbaumPage() {
<li>{t.tapHint}</li> <li>{t.tapHint}</li>
<li>{t.hintName}</li> <li>{t.hintName}</li>
<li>{t.hintExpand}</li> <li>{t.hintExpand}</li>
<li>{t.hintContext}</li>
{hasSiblingPairing && <li>{t.hintSiblings}</li>}
</ul> </ul>
</section> </section>
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */} {/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
<PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} /> <PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} />
{contextMenu && (
<ul
className="stammbaum-context-menu"
role="menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<li role="none">
<button
type="button"
role="menuitem"
className="stammbaum-context-menu__item"
onClick={() => copyId(contextMenu.gerbil.id)}
>
{t.contextMenu.copyId}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitem"
className="stammbaum-context-menu__item"
onClick={() => openReport(contextMenu.gerbil)}
>
{t.contextMenu.reportError}
</button>
</li>
</ul>
)}
<ReportErrorDialog
open={reportContext !== null}
onClose={() => setReportContext(null)}
context={reportContext ?? { context: 'stammbaum' }}
/>
</> </>
) )
} }
@@ -370,12 +519,16 @@ function PedigreeCard({
farbschlag, farbschlag,
onOpen, onOpen,
onExpand, onExpand,
onContextMenu,
onLongPress,
}: { }: {
node: AnimalNode node: AnimalNode
isRoot: boolean isRoot: boolean
farbschlag: string | null farbschlag: string | null
onOpen: () => void onOpen: () => void
onExpand: () => void onExpand: () => void
onContextMenu: (e: ReactMouseEvent) => void
onLongPress: (x: number, y: number) => void
}) { }) {
const t = de.pages.stammbaum const t = de.pages.stammbaum
const g = node.gerbil const g = node.gerbil
@@ -388,10 +541,57 @@ function PedigreeCard({
: `${API_BASE_URL}${g.profilePhotoUrl}` : `${API_BASE_URL}${g.profilePhotoUrl}`
: null : null
const photoUrl = rawUrl !== failedUrl ? rawUrl : null const photoUrl = rawUrl !== failedUrl ? rawUrl : null
/* Long-Press (Touch) = Rechtsklick-Äquivalent: ~500 ms halten ohne zu
verschieben öffnet das Kontextmenü. Der danach folgende Klick (Umwurzeln)
wird unterdrückt. */
const pressTimer = useRef<number | null>(null)
const pressStart = useRef<{ x: number; y: number } | null>(null)
const longPressed = useRef(false)
const cancelPress = () => {
if (pressTimer.current != null) {
clearTimeout(pressTimer.current)
pressTimer.current = null
}
}
const onTouchStart = (e: ReactTouchEvent) => {
const tch = e.touches[0]
if (!tch) return
pressStart.current = { x: tch.clientX, y: tch.clientY }
longPressed.current = false
cancelPress()
pressTimer.current = window.setTimeout(() => {
pressTimer.current = null
longPressed.current = true
onLongPress(pressStart.current!.x, pressStart.current!.y)
}, 500)
}
const onTouchMove = (e: ReactTouchEvent) => {
const tch = e.touches[0]
if (!tch || !pressStart.current) return
if (Math.abs(tch.clientX - pressStart.current.x) > 10 || Math.abs(tch.clientY - pressStart.current.y) > 10) {
cancelPress()
}
}
const onTouchEnd = (e: ReactTouchEvent) => {
cancelPress()
if (longPressed.current) {
// Synthetischen Klick verhindern (sonst würde umgewurzelt / Menü sofort
// wieder geschlossen).
e.preventDefault()
longPressed.current = false
}
}
return ( return (
<div <div
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'} className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
onClick={isRoot ? undefined : onOpen} onClick={isRoot ? undefined : onOpen}
onContextMenu={onContextMenu}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={cancelPress}
role={isRoot ? undefined : 'button'} role={isRoot ? undefined : 'button'}
title={isRoot ? undefined : t.tapHint} title={isRoot ? undefined : t.tapHint}
> >
@@ -429,7 +629,7 @@ function PedigreeCard({
)} )}
{dob && <span className="pedigree-card__year">* {dob}</span>} {dob && <span className="pedigree-card__year">* {dob}</span>}
</div> </div>
{node.expandable && ( {node.expandable && !node.siblingPairing && (
<button <button
type="button" type="button"
className="pedigree-card__expand" className="pedigree-card__expand"

View File

@@ -13,6 +13,8 @@ import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics' import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView' import BreedingResultView from '../components/BreedingResultView'
import GerbilIcon from '../components/GerbilIcon' import GerbilIcon from '../components/GerbilIcon'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix' import { useGerbilName } from '../components/breederSuffix'
import './wuerfe.css' import './wuerfe.css'
@@ -64,6 +66,8 @@ export default function WurfDetailPage() {
const t = de.pages.litters const t = de.pages.litters
const gerbilName = useGerbilName() const gerbilName = useGerbilName()
const { id = '' } = useParams() const { id = '' } = useParams()
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const litter = useApi(() => getLitter(id), [id]) const litter = useApi(() => getLitter(id), [id])
const fatherId = litter.data?.fatherId ?? null const fatherId = litter.data?.fatherId ?? null
@@ -151,6 +155,12 @@ export default function WurfDetailPage() {
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary"> <Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit} {t.detail.edit}
</Link> </Link>
<button type="button" className="btn" onClick={() => setProvenanceOpen(true)}>
{de.provenance.button}
</button>
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
{de.feedback.button}
</button>
<Link to="/wuerfe" className="btn"> <Link to="/wuerfe" className="btn">
{t.detail.back} {t.detail.back}
</Link> </Link>
@@ -224,6 +234,19 @@ export default function WurfDetailPage() {
) : ( ) : (
<p className="muted">{t.detail.needParentsGenotype}</p> <p className="muted">{t.detail.needParentsGenotype}</p>
)} )}
<ReportErrorDialog
open={reportOpen}
onClose={() => setReportOpen(false)}
context={{ context: 'litter-detail', litterId: l.id, entityName: l.name }}
/>
<ProvenanceDialog
open={provenanceOpen}
onClose={() => setProvenanceOpen(false)}
provenance={l.provenance}
entityLabel={de.provenance.entityLabels.litter}
/>
</section> </section>
) )
} }

View File

@@ -139,6 +139,12 @@
font-weight: 600; font-weight: 600;
color: var(--color-text); color: var(--color-text);
} }
/* Geschwisterverpaarung: hervorgehoben, da zuchtrelevant (Inzucht). */
.ak-fact--sibling {
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
border-color: var(--color-accent);
color: var(--color-accent);
}
/* ---------- Actions ---------- */ /* ---------- Actions ---------- */
.ak-actions { .ak-actions {

View File

@@ -171,7 +171,7 @@
.stammbaum-canvas { .stammbaum-canvas {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
height: clamp(18rem, 62dvh, 46rem); height: clamp(18rem, 72dvh, 58rem);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 0.6rem; border-radius: 0.6rem;
background: background:
@@ -231,6 +231,44 @@
cursor: default; cursor: default;
} }
/* Verweis-Knoten der Geschwister-Verpaarung: Vater & Mutter aus demselben
Wurf — der Vorfahren-Ast verweist auf die Vaterlinie statt ihn zu doppeln. */
.pedigree-card--sibling {
border-style: dashed;
border-color: var(--color-accent);
background: var(--color-accent-soft);
cursor: default;
}
.pedigree-card--sibling__icon {
flex: none;
font-size: 1.4rem;
line-height: 1;
color: var(--color-accent);
}
.pedigree-card--sibling__body {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.pedigree-card--sibling__label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pedigree-card--sibling__hint {
font-size: 0.72rem;
font-style: italic;
color: var(--color-text-muted);
}
.pedigree-card__photo { .pedigree-card__photo {
flex: none; flex: none;
width: 42px; width: 42px;
@@ -450,3 +488,38 @@
font-size: 0.8em; font-size: 0.8em;
color: #555; color: #555;
} }
/* FEEDBACK: Rechtsklick-Kontextmenü auf einer Ahnenkarte. */
.stammbaum-context-menu {
position: fixed;
z-index: 150;
margin: 0;
padding: 0.25rem;
list-style: none;
min-width: 11rem;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
box-shadow: 0 8px 28px rgba(43, 33, 25, 0.22);
}
.stammbaum-context-menu__item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
font: inherit;
font-size: 0.9rem;
color: var(--color-text);
padding: 0.5rem 0.7rem;
border-radius: 5px;
cursor: pointer;
}
.stammbaum-context-menu__item:hover,
.stammbaum-context-menu__item:focus-visible {
background: var(--color-accent, #2563eb);
color: #fff;
outline: none;
}

View File

@@ -183,6 +183,74 @@ describe('buildPedigree', () => {
expect(aAgain.expandable).toBe(false) expect(aAgain.expandable).toBe(false)
}) })
it('markiert Geschwister-Verpaarung: Vater & Mutter aus demselben Wurf', async () => {
// Vater und Mutter sind Vollgeschwister (gleiche litterId 'w-grand').
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const vater = animal(root!.father)
const mutter = animal(root!.mother)
// Mutter trägt den Verweis auf die Vaterlinie; Vater selbst nicht.
expect(mutter.siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
expect(vater.siblingPairing).toBeUndefined()
// Echte Vorfahren bleiben im Modell (Druck-Ahnentafel nutzt sie weiter).
expect(animal(mutter.father).gerbil.name).toBe('Opa')
expect(animal(mutter.mother).gerbil.name).toBe('Oma')
})
it('erkennt Geschwister-Verpaarung am gleichen Geburtsdatum (litterId fehlt/verschieden)', async () => {
// Vater und Mutter haben verschiedene litterId, aber dasselbe Geburtsdatum
// (starkes Indiz: gemeinsamer Wurf, der in den Daten nicht verknüpft ist).
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
{ ...makeGerbil('vater', 'Vater', 'w-v'), dateOfBirth: '2018-09-22' },
{ ...makeGerbil('mutter', 'Mutter', 'w-m'), dateOfBirth: '2018-09-22' },
],
[makeLitter('w-kind', 'vater', 'mutter')],
)
const root = await buildPedigree(source, 'kind')
expect(animal(root!.mother).siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
})
it('gleiches Geburtsdatum nur bei BEIDEN Eltern (unterschiedliches DOB → keine Verpaarung)', async () => {
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
{ ...makeGerbil('vater', 'Vater', 'w-v'), dateOfBirth: '2018-09-22' },
{ ...makeGerbil('mutter', 'Mutter', 'w-m'), dateOfBirth: '2019-05-15' },
],
[makeLitter('w-kind', 'vater', 'mutter')],
)
const root = await buildPedigree(source, 'kind')
expect(animal(root!.mother).siblingPairing).toBeUndefined()
})
it('verlangt für Geschwister-Verpaarung dieselbe litterId (Halbgeschwister zählen nicht)', async () => {
// Gemeinsamer Vater (Opa), aber verschiedene Würfe → keine Verpaarung-Marke.
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w1'),
makeGerbil('vater', 'Vater', 'w2'),
makeGerbil('mutter', 'Mutter', 'w3'),
makeGerbil('opa', 'Opa', null),
],
[makeLitter('w1', 'vater', 'mutter'), makeLitter('w2', 'opa', null), makeLitter('w3', 'opa', null)],
)
const root = await buildPedigree(source, 'kind')
expect(animal(root!.mother).siblingPairing).toBeUndefined()
})
it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => { it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => {
// Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus. // Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus.
const source = makeSource( const source = makeSource(
@@ -260,6 +328,36 @@ describe('toRawNodeDatum', () => {
expect(datum.children![0].children).toBeUndefined() expect(datum.children![0].children).toBeUndefined()
}) })
it('führt den Vorfahren-Ast einer Geschwister-Verpaarung zu einem Verweis zusammen', async () => {
const source = makeSource(
[
makeGerbil('kind', 'Kind', 'w-kind'),
makeGerbil('vater', 'Vater', 'w-grand'),
makeGerbil('mutter', 'Mutter', 'w-grand'),
makeGerbil('opa', 'Opa', null),
makeGerbil('oma', 'Oma', null),
],
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
)
const root = await buildPedigree(source, 'kind')
const datum = toRawNodeDatum(root!, 'unbekannt')
// Vater behält seinen vollen Vorfahren-Ast …
const vater = datum.children![0]
expect(vater.name).toBe('Vater')
expect(vater.children!.map((c) => c.name)).toEqual(['Opa', 'Oma'])
// … Mutter wird zu EINEM Verweis-Knoten zusammengeführt.
const mutter = datum.children![1]
expect(mutter.name).toBe('Mutter')
expect(mutter.children).toHaveLength(1)
expect(mutter.children![0].attributes).toMatchObject({
kind: 'sibling-ref',
refName: 'Vater',
refPath: 'v',
})
})
it('rendert Platzhalter mit dem übergebenen Label', async () => { it('rendert Platzhalter mit dem übergebenen Label', async () => {
const root = await buildPedigree(familySource(), 'mutter') const root = await buildPedigree(familySource(), 'mutter')
const datum = toRawNodeDatum(root!, 'unbekannt') const datum = toRawNodeDatum(root!, 'unbekannt')

View File

@@ -70,6 +70,29 @@ async function buildAnimal(
parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors), parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors),
parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors), parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors),
]) ])
// Geschwister-Verpaarung: Vater und Mutter sind Vollgeschwister, wenn sie aus
// DEMSELBEN Wurf stammen (gleiche litterId) ODER am selben Tag geboren sind
// (gleiches Geburtsdatum ist ein starkes Indiz — Würfe sind praktisch der
// einzige Grund für ein identisches Datum, und die Daten verknüpfen die
// Eltern-Würfe nicht immer). Wir markieren dann die Mutter, damit das Diagramm
// ihren Ast zu einem Verweis auf die Vaterlinie zusammenführt. Die echten
// Vorfahren bleiben im Modell erhalten (Druck-Ahnentafel nutzt sie weiter).
if (
father.kind === 'animal' &&
mother.kind === 'animal' &&
father.gerbil.id !== mother.gerbil.id &&
((father.gerbil.litterId && father.gerbil.litterId === mother.gerbil.litterId) ||
(father.gerbil.dateOfBirth != null &&
father.gerbil.dateOfBirth === mother.gerbil.dateOfBirth))
) {
const markedMother: AnimalNode = {
...mother,
siblingPairing: { siblingName: father.gerbil.name, siblingPath: father.path },
}
return { kind: 'animal', path, gerbil, father, mother: markedMother, expandable: false }
}
return { kind: 'animal', path, gerbil, father, mother, expandable: false } return { kind: 'animal', path, gerbil, father, mother, expandable: false }
} }
@@ -143,6 +166,24 @@ export function toRawNodeDatum(node: PedigreeNode, unknownLabel: string): RawNod
name: node.gerbil.name, name: node.gerbil.name,
attributes: { path: node.path, kind: 'animal', expandable: node.expandable }, attributes: { path: node.path, kind: 'animal', expandable: node.expandable },
} }
// Geschwister-Verpaarung: Vorfahren-Ast zu EINEM Verweis-Knoten
// zusammenführen (gemeinsame Vorfahren stehen bereits an der Vaterlinie).
if (node.siblingPairing) {
datum.children = [
{
name: node.siblingPairing.siblingName,
attributes: {
// Synthetischer Pfad (nicht in collectNodes) — die Karte rendert
// ausschließlich aus diesen Attributen.
path: node.path + '§',
kind: 'sibling-ref',
refName: node.siblingPairing.siblingName,
refPath: node.siblingPairing.siblingPath,
},
},
]
return datum
}
if (node.father && node.mother) { if (node.father && node.mother) {
datum.children = [ datum.children = [
toRawNodeDatum(node.father, unknownLabel), toRawNodeDatum(node.father, unknownLabel),

View File

@@ -38,6 +38,15 @@ export interface AnimalNode {
readonly mother?: PedigreeNode readonly mother?: PedigreeNode
/** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */ /** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */
readonly expandable: boolean readonly expandable: boolean
/**
* Geschwister-Verpaarung: gesetzt, wenn dieses Tier und sein Geschwister
* (an `siblingPath`) aus DEMSELBEN Wurf stammen und beide Eltern des
* Wurzeltiers sind (Vollgeschwister-Verpaarung — in der Zucht häufig).
* Im interaktiven Diagramm wird der Vorfahren-Ast dieses Knotens dann zu
* einem Verweis kollabiert (gemeinsame Vorfahren siehe `siblingPath`); die
* echten Vorfahren bleiben im Modell (Druck-Ahnentafel zeigt sie weiterhin).
*/
readonly siblingPairing?: { readonly siblingName: string; readonly siblingPath: PedigreePath }
} }
/** /**

View File

@@ -122,6 +122,10 @@ export const de = {
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.', parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
parentLittersRoleVater: 'Vater', parentLittersRoleVater: 'Vater',
parentLittersRoleMutter: 'Mutter', parentLittersRoleMutter: 'Mutter',
// Hinweis: Eltern dieses Tiers sind Vollgeschwister (gleicher Wurf).
siblingPairing: 'Geschwisterverpaarung',
siblingPairingTitle:
'Die Eltern dieses Tiers stammen aus demselben Wurf (Vollgeschwister).',
}, },
// Formular (anlegen/bearbeiten) // Formular (anlegen/bearbeiten)
form: { form: {
@@ -411,6 +415,13 @@ export const de = {
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */ /** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
hintName: 'Namenslink: Tierakte öffnen', hintName: 'Namenslink: Tierakte öffnen',
hintExpand: ': weitere Vorfahren nachladen', hintExpand: ': weitere Vorfahren nachladen',
hintContext: 'Rechtsklick (oder Karte lange gedrückt halten): ID kopieren / Fehler melden',
hintSiblings: '⟲: Eltern sind Geschwister — gemeinsame Vorfahren siehe Vaterlinie',
/** Verweis-Knoten bei Geschwister-Verpaarung (Vater & Mutter aus demselben Wurf). */
siblingPairing: {
refLabel: (name: string) => `Geschwister von ${name}`,
refHint: 'Eltern siehe oben',
},
/** Würfe-Panel links (STAMMBAUM-LITTERS). */ /** Würfe-Panel links (STAMMBAUM-LITTERS). */
littersTitle: 'Würfe', littersTitle: 'Würfe',
littersJunge: 'Junge', littersJunge: 'Junge',
@@ -428,6 +439,11 @@ export const de = {
createdOn: 'Erstellt am', createdOn: 'Erstellt am',
born: 'geb.', born: 'geb.',
}, },
/** FEEDBACK: Rechtsklick-Kontextmenü auf einer Stammbaum-Karte. */
contextMenu: {
copyId: 'ID kopieren',
reportError: 'Fehler melden',
},
}, },
// ── FEAT-7 (Kelly): Statistik & Berichte ── // ── FEAT-7 (Kelly): Statistik & Berichte ──
statistik: { statistik: {
@@ -832,6 +848,79 @@ export const de = {
unknown: 'Ein unbekannter Fehler ist aufgetreten.', unknown: 'Ein unbekannter Fehler ist aufgetreten.',
}, },
}, },
// ── FEEDBACK: "Fehler melden" Dialog + "ID kopieren" Aktion ──
feedback: {
/** Button-Beschriftung (Tierakte, Wurf-Ansicht). */
button: 'Fehler melden',
dialogTitle: 'Fehler melden',
intro: 'Beschreibe kurz, was nicht stimmt. Technische Angaben werden automatisch mitgesendet.',
label: 'Beschreibung',
placeholder: 'Was funktioniert nicht oder ist falsch dargestellt?',
/** Überschrift über den automatisch erfassten Debug-Angaben. */
debugTitle: 'Automatisch erfasst',
debugFields: {
context: 'Ansicht',
entity: 'Datensatz',
url: 'Adresse',
},
contexts: {
stammbaum: 'Stammbaum',
'gerbil-detail': 'Rennmausakte',
'litter-detail': 'Wurf',
'contact-detail': 'Kontakt',
},
submit: 'Senden',
submitting: 'Wird gesendet …',
cancel: 'Abbrechen',
close: 'Schließen',
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
error: 'Fehlerbericht konnte nicht gesendet werden.',
emptyMessage: 'Bitte beschreibe den Fehler.',
/** Toast nach erfolgreichem "ID kopieren". */
idCopied: 'ID kopiert',
idCopyFailed: 'ID konnte nicht kopiert werden.',
},
/** NACHVERFOLGUNG: Datenherkunft eines importierten Tiers (Rennmausakte). */
provenance: {
/** Button-Beschriftung in den Akten-Aktionen. */
button: 'Datenherkunft',
dialogTitle: 'Nachverfolgungsinformationen',
intro: 'Woher stammen die Daten dieses Eintrags? Diese Angaben werden beim Import automatisch erfasst.',
/** Wie {@link intro}, aber mit Entitätsbezeichnung (z. B. „dieses Kontakts"). */
introFor: (label: string) =>
`Woher stammen die Daten ${label}? Diese Angaben werden beim Import automatisch erfasst.`,
/** Entitätsbezeichnungen für introFor (Genitiv). */
entityLabels: {
contact: 'dieses Kontakts',
litter: 'dieses Wurfs',
},
/** Überschrift des chronologischen, dateibezogenen Verlaufs (Primärinhalt). */
historyTitle: 'Verlauf',
/** Überschriften / Feldbeschriftungen. */
sourceFilesTitle: 'Quelldateien',
sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`),
mergedTitle: 'Datensätze',
mergedCount: (n: number) =>
n === 1 ? 'aus 1 Datensatz' : `zusammengeführt aus ${n} Datensätzen`,
fromWurfchronik: 'Enthält Angaben aus der Wurfchronik',
parentsTitle: 'Eltern-Herkunft',
parentMethodLabel: 'Methode',
parentConfidenceLabel: 'Konfidenz',
/** Übersetzungen für die technischen Methoden-/Konfidenz-Werte. */
methods: {
'chart-position': 'Position im Stammbaum',
decision: 'Manuelle Entscheidung',
} as Record<string, string>,
confidences: {
low: 'niedrig',
medium: 'mittel',
high: 'hoch',
} as Record<string, string>,
notesTitle: 'Hinweise',
/** Wenn ein Tier keine Importdaten hat (manuell angelegt). */
empty: 'Keine Herkunftsdaten vorhanden.',
close: 'Schließen',
},
common: { common: {
loading: 'Lädt …', loading: 'Lädt …',
retry: 'Erneut versuchen', retry: 'Erneut versuchen',

View File

@@ -341,14 +341,22 @@ def _attach_photos(z, sheets, animals, fname):
if not anchors: if not anchors:
return return
# Detect left_style: whether photo is to the left or to the right of the name cell by_gen = {}
a_count = sum(1 for a in anchors if a[1] == 1) for a in animals:
d_count = sum(1 for a in anchors if a[1] == 4) by_gen.setdefault(a["_gen"], []).append(a)
has_proband_in_d = any(a[1] == 4 and 50 <= a[2] <= 70 for a in anchors) media_dir = os.path.join(OUT, "photos")
left_style = a_count > 0 or (d_count > 0 and not has_proband_in_d) col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3
def get_anchor_gen(colnum, offset=0): # A photo sits one column either side of (or on) its animal's name cell;
effective_col = colnum - offset # the generation is read from the photo's column. Whether photos are LEFT or
# RIGHT of the name varies, and the old fixed-column heuristic (col 1 / col 4)
# misclassified sheets that have neither, shifting every photo one generation
# toward the proband (e.g. „Stammbaum von Kazuya“ / „Picus Son“: Kazuya wore
# his father's photo, the father his grandfather's). Instead, try BOTH
# interpretations and keep the one that places photos closest to their
# assigned animal's name column (minimal horizontal misalignment).
def get_anchor_gen(colnum, left_style):
effective_col = colnum - col_offset
if left_style: if left_style:
if effective_col <= 3: return 0 if effective_col <= 3: return 0
if effective_col <= 6: return 1 if effective_col <= 6: return 1
@@ -364,18 +372,26 @@ def _attach_photos(z, sheets, animals, fname):
if effective_col <= 16: return 4 if effective_col <= 16: return 4
return 5 return 5
by_gen = {} def targets_for(left_style):
for a in animals: out = []
by_gen.setdefault(a["_gen"], []).append(a) for (sp, col, row, media) in anchors:
media_dir = os.path.join(OUT, "photos") cands = by_gen.get(get_anchor_gen(col, left_style), []) or animals
col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3 out.append(min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None)
for i, (sp, col, row, media) in enumerate(anchors): return out
g = get_anchor_gen(col, col_offset)
cands = by_gen.get(g, []) def mean_col_offset(targets):
if not cands: vals = [abs(col - t["_col"]) for (sp, col, row, m), t in zip(anchors, targets) if t]
# fall back to nearest animal by row across all gens return sum(vals) / len(vals) if vals else 0.0
cands = animals
target = min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None left_targets = targets_for(True)
right_targets = targets_for(False)
targets = (
left_targets
if mean_col_offset(left_targets) <= mean_col_offset(right_targets)
else right_targets
)
for (sp, col, row, media), target in zip(anchors, targets):
if not target: if not target:
continue continue
ext = os.path.splitext(media)[1] or ".img" ext = os.path.splitext(media)[1] or ".img"

View File

@@ -22,6 +22,86 @@ def generate_guid(key_str):
"""Generate a stable UUID string based on a key.""" """Generate a stable UUID string based on a key."""
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str)) return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
def build_entity_provenance(source_files, merged_record_count, notes=None,
from_wurfchronik=None, extra=None, history=None):
"""Generic data-provenance JSON builder shared by gerbils, contacts and
litters. Mirrors the GerbilProvenance frontend contract:
{ sourceFiles, mergedRecordCount, fromWurfchronik, notes, history, ... }
`source_files` is any iterable of filenames; `from_wurfchronik` is auto-
derived from the filenames when left as None. `extra` may carry entity-
specific keys (e.g. parentMethod/parentConfidence for gerbils). `history`
is an ordered list of human-readable German lines that read like a
chronological log of where each fact came from (the primary content shown
in the Datenherkunft dialog). Returns a JSON string (stored on the nullable
Provenance text column)."""
files = sorted({f for f in source_files if f})
if from_wurfchronik is None:
from_wurfchronik = any("wurfchronik" in f.lower() for f in files)
prov = {
"sourceFiles": files,
"mergedRecordCount": merged_record_count,
"fromWurfchronik": bool(from_wurfchronik),
"notes": list(notes or []),
"history": list(history or []),
}
if extra:
for k, v in extra.items():
if v is not None:
prov[k] = v
return json.dumps(prov, ensure_ascii=False)
def _quote_file(fname):
"""German typographic quotes around a source filename for history lines."""
return f"{fname}"
# Human-readable German labels for the significant fields we attribute to files.
PROV_FIELD_LABELS = {
"DateOfBirth": "Geburtsdatum",
"DateOfDeath": "Sterbedatum",
"Gender": "Geschlecht",
"Genotype": "Genotyp",
"ColorVarietyId": "Farbschlag",
"Name": "Name",
}
def _primary_file_of(record):
"""The single most representative source file of one raw record.
Prefers an explicit Stammbaum/Wurfchronik filename from ImportSource, else
the record's _filename. Used to attribute a field value to a concrete file
in the history log."""
fn = record.get("_filename")
if fn:
return fn
imp = record.get("ImportSource")
if imp:
first = str(imp).split(",")[0].strip()
if first:
return first
return None
def _record_source_files(g):
"""All distinct source files a single raw gerbil record drew from.
ImportSource is either a comma-joined Stammbaum file list, a single
Wurfchronik filename, or the per-litter filename; _filename is the primary
file. We union both so nothing is lost."""
files = set()
imp = g.get("ImportSource")
if imp:
for part in str(imp).split(","):
part = part.strip()
if part:
files.add(part)
fn = g.get("_filename")
if fn:
files.add(fn)
return files
def to_valid_guid(val): def to_valid_guid(val):
if not val: if not val:
return None return None
@@ -509,6 +589,238 @@ def parse_death_info(notes, status, existing_dod, existing_cod):
return resolved_status, dod, cod return resolved_status, dod, cod
# ── Litter dedup & parent-role helpers (pure, unit-tested in test_merge_resolve.py) ──
def _norm_pname(s):
return normalize_name(s) if s else ""
def names_no_conflict(l1, l2):
"""Parent names don't contradict (equal per role, or one side empty)."""
f1, f2 = _norm_pname(l1.get("_father_name")), _norm_pname(l2.get("_father_name"))
m1, m2 = _norm_pname(l1.get("_mother_name")), _norm_pname(l2.get("_mother_name"))
f_ok = (not f1) or (not f2) or (f1 == f2)
m_ok = (not m1) or (not m2) or (m1 == m2)
return f_ok and m_ok
def names_overlap(l1, l2):
"""At least one role has a non-empty matching name (positive evidence)."""
f1, f2 = _norm_pname(l1.get("_father_name")), _norm_pname(l2.get("_father_name"))
m1, m2 = _norm_pname(l1.get("_mother_name")), _norm_pname(l2.get("_mother_name"))
return bool((f1 and f1 == f2) or (m1 and m1 == m2))
def litter_compatible(l1, l2):
"""Two litter records describe the same litter: same date and compatible parents.
- Both sides have both parents → must match exactly.
- Asymmetric (one side resolved, the other not) → merge only if names don't
contradict; for dateless litters require a POSITIVE name match (a shared
null date is no evidence), so unrelated nameless stubs stay separate.
- Neither side has parents → never blind-merge.
"""
if l1["Date"] != l2["Date"]:
return False
f1, m1 = l1.get("FatherId"), l1.get("MotherId")
f2, m2 = l2.get("FatherId"), l2.get("MotherId")
if f1 and f2 and m1 and m2:
return f1 == f2 and m1 == m2
asymmetric = (bool(f1 or m1) and not (f2 or m2)) or (bool(f2 or m2) and not (f1 or m1))
if asymmetric:
if not names_no_conflict(l1, l2):
return False
if l1["Date"] is None:
return names_overlap(l1, l2)
return True
return False
def assign_parent_roles(father_id, mother_id, gender_of):
"""Assign two resolved parent IDs to father/mother roles by gender.
Drops self-pairing duplicates (same animal in both roles) and never returns
two same-role parents. `gender_of` maps an id to 'male'|'female'|'unknown'|None.
Returns (father_id, mother_id).
"""
ids = []
for gid in (father_id, mother_id):
if gid and gid not in ids:
ids.append(gid)
males = [g for g in ids if gender_of(g) == "male"]
females = [g for g in ids if gender_of(g) == "female"]
unknowns = [g for g in ids if gender_of(g) == "unknown"]
father = males[0] if males else (unknowns.pop(0) if unknowns else None)
mother = females[0] if females else (unknowns.pop(0) if unknowns else None)
return father, mother
# A gerbil lives at most ~6 years, so a parent can be at most ~6 years older than
# its offspring (and must be born before it). Links outside this window are
# impossible — e.g. a 2013 animal resolved onto a 2022 litter (Jayjay → Solice).
MAX_PARENT_AGE_DAYS = 6 * 366
def parent_age_plausible(parent_dob, litter_date):
"""Could a parent born `parent_dob` have offspring born on `litter_date`?
Requires birth strictly before the litter and within the gerbil lifespan.
Unknown/unparseable dates return True (cannot disprove). Accepts any date
format parse_date understands.
"""
pd = date_to_days(parse_date(parent_dob)) if parent_dob else None
ld = date_to_days(parse_date(litter_date)) if litter_date else None
if pd is None or ld is None:
return True
return 0 < (ld - pd) <= MAX_PARENT_AGE_DAYS
def pick_parent_ref(parent_refs, role, child_dob, avoid_name=None, gender_of=None):
"""Choose the best parent ref for a role from possibly-conflicting chart refs.
A Stammbaum lists an animal at several positions, so its parentRefs can carry
contradictory guesses (the first one is not necessarily right). Rank candidates
(lower = better):
0 right/unknown gender for the role, age-plausible dated ref
1 right/unknown gender, no DOB (usable, but a plausible dated ref wins)
2 right/unknown gender, dated but age-impossible
3 resolved gender is clearly WRONG for the role (e.g. a female father)
4 would duplicate the animal chosen for the other role
Gender is decisive over DOB: a dated female ref must not win the father slot
over an undated male/unknown one. `gender_of(name)` returns 'male'/'female'
or None (unknown/ambiguous → not penalised). Returns the chosen ref or None.
"""
role_refs = [p for p in parent_refs if p.get("roleGuess") == role]
if not role_refs:
return None
avoid = normalize_name(avoid_name) if avoid_name else None
expected = "male" if role == "father" else "female"
def rank(p):
if avoid is not None and normalize_name(p.get("name")) == avoid:
return 4 # would duplicate the other parent role
g = gender_of(p.get("name")) if gender_of else None
if g in ("male", "female") and g != expected:
return 3 # wrong sex for this role
dob = p.get("dob")
if not dob:
return 1
return 0 if parent_age_plausible(dob, child_dob) else 2
order = sorted(range(len(role_refs)), key=lambda i: (rank(role_refs[i]), i))
return role_refs[order[0]]
def _build_gerbil_history(records, best_g, field_source, parent_method=None,
any_decision=False, any_conflict=False, conflict_notes=None):
"""Build an ordered, file-attributed German history for a resolved gerbil.
Reads like a chronological log:
• „In X.xlsx' gefunden."
• „Geburtsdatum (27.03.2022) aus X.xlsx'."
• „Auch in Y.xlsx' gefunden → Datensätze zusammengeführt."
• „Genotyp aus Z.xlsx'."
• „Eltern über Position im Stammbaum erkannt (Quelle: X.xlsx')."
• „Aus Wurfchronik übernommen."
`field_source` maps a field name to the raw record that supplied its final
value; when present we name that record's file, otherwise we fall back to
the primary record. The records are visited in a stable order (primary
first, then the rest sorted by file) so the log is deterministic."""
conflict_notes = conflict_notes or []
history = []
# Order records: best_g first, then others by primary file name (stable).
others = [r for r in records if r is not best_g]
others.sort(key=lambda r: (_primary_file_of(r) or ""))
ordered = [best_g] + others
best_file = _primary_file_of(best_g)
if best_file:
history.append(f"In {_quote_file(best_file)} gefunden.")
else:
history.append("Im Import gefunden.")
# Field-by-field attribution: name the file that supplied each fact.
def attr_line(field, formatter):
rec = field_source.get(field) or best_g
val = best_g.get(field)
if not val or val == "unknown":
return
fname = _primary_file_of(rec)
label = PROV_FIELD_LABELS.get(field, field)
text = formatter(label, val)
if fname:
history.append(f"{text} aus {_quote_file(fname)}.")
else:
history.append(f"{text} (Quelle unbekannt).")
attr_line("DateOfBirth", lambda label, v: f"{label} ({_de_date(v)})")
attr_line("Gender", lambda label, v: f"{label} ({_de_gender(v)})")
attr_line("Genotype", lambda label, v: f"{label}")
attr_line("ColorVarietyId", lambda label, v: f"{label}")
attr_line("DateOfDeath", lambda label, v: f"{label} ({_de_date(v)})")
# Merge step: every additional record that contributed.
for r in others:
fname = _primary_file_of(r)
if fname:
history.append(
f"Auch in {_quote_file(fname)} gefunden → Datensätze zusammengeführt."
)
else:
history.append("In weiterem Datensatz gefunden → Datensätze zusammengeführt.")
# Parent derivation.
if parent_method:
method_label = {
"chart-position": "Position im Stammbaum",
"geburtsdatum+eltern": "Geburtsdatum und Elternnamen",
"nur-geburtsdatum": "Geburtsdatum",
"decision": "manuelle Entscheidung",
}.get(parent_method, parent_method)
# The parent evidence comes from a Stammbaum chart — attribute to the
# primary record's file when it is a Stammbaum.
parent_file = best_file if best_file and "stammbaum" in best_file.lower() else None
if parent_file:
history.append(
f"Eltern über {method_label} erkannt (Quelle: {_quote_file(parent_file)})."
)
else:
history.append(f"Eltern über {method_label} erkannt.")
# Manual decisions / conflicts.
if any_decision:
history.append("Zuordnung per manueller Entscheidung getroffen.")
if any_conflict:
history.append("Konflikt per Entscheidung gelöst.")
for cn in conflict_notes:
if cn and cn not in history:
history.append(cn + ".")
# Wurfchronik provenance line.
if any("wurfchronik" in f.lower() for r in records for f in _record_source_files(r)):
history.append("Angaben aus der Wurfchronik übernommen.")
return history
def _de_date(iso):
"""YYYY-MM-DD → DD.MM.YYYY for display; pass through anything else."""
if not iso:
return iso
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", str(iso))
if m:
return f"{m.group(3)}.{m.group(2)}.{m.group(1)}"
return iso
def _de_gender(g):
return {"male": "männlich", "female": "weiblich"}.get(g, g)
def main(): def main():
print("Loading color variety seeds...") print("Loading color variety seeds...")
variety_map = {} variety_map = {}
@@ -846,12 +1158,35 @@ def main():
key = (normalize_name(f_name), normalize_name(m_name), ldate) key = (normalize_name(f_name), normalize_name(m_name), ldate)
md_litters_idx[key] = rl md_litters_idx[key] = rl
# Gender index for parent-ref selection: normalized name → 'male' | 'female'
# | 'ambiguous'. Drives the gender-aware ranking in pick_parent_ref so a dated
# but wrong-sex ref (e.g. female „Danielle“) cannot win the father slot over
# an undated male/unknown one (e.g. „Hagrid Rubeus“).
gender_idx = {}
for a in stammbaum_only_animals:
g = (a.get("gender") or "").lower().strip()
g = g if g in ("male", "female") else None
for key in {normalize_name(a.get("name")), normalize_name(get_call_name(a.get("name") or ""))}:
if not key:
continue
if key not in gender_idx:
gender_idx[key] = g
elif gender_idx[key] != g:
gender_idx[key] = "ambiguous"
def gender_of_name(name):
v = gender_idx.get(normalize_name(name))
return v if v in ("male", "female") else None
# Create virtual litters for stammbaum animals # Create virtual litters for stammbaum animals
created_virtual_litters = {} created_virtual_litters = {}
for a in stammbaum_only_animals: for a in stammbaum_only_animals:
parent_refs = a.get("parentRefs", []) parent_refs = a.get("parentRefs", [])
father_ref = next((p for p in parent_refs if p.get("roleGuess") == "father"), None) child_dob_raw = a.get("dob")
mother_ref = next((p for p in parent_refs if p.get("roleGuess") == "mother"), None) father_ref = pick_parent_ref(parent_refs, "father", child_dob_raw, gender_of=gender_of_name)
mother_ref = pick_parent_ref(parent_refs, "mother", child_dob_raw,
avoid_name=father_ref.get("name") if father_ref else None,
gender_of=gender_of_name)
a["_mapped_litter_scoped_id"] = None a["_mapped_litter_scoped_id"] = None
if father_ref and mother_ref: if father_ref and mother_ref:
@@ -954,6 +1289,7 @@ def main():
norm_name = normalize_name(canon_name) norm_name = normalize_name(canon_name)
rc_file = rc.get("_filename")
if norm_name not in contact_by_norm_name: if norm_name not in contact_by_norm_name:
global_guid = generate_guid(f"contact-{norm_name}") global_guid = generate_guid(f"contact-{norm_name}")
contact_by_norm_name[norm_name] = { contact_by_norm_name[norm_name] = {
@@ -962,7 +1298,10 @@ def main():
"Email": rc.get("Email") or rc.get("email"), "Email": rc.get("Email") or rc.get("email"),
"Phone": rc.get("Phone") or rc.get("phone"), "Phone": rc.get("Phone") or rc.get("phone"),
"Address": rc.get("Address") or rc.get("address"), "Address": rc.get("Address") or rc.get("address"),
"Notes": rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note") "Notes": rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note"),
# Provenance accumulators (consumed below, stripped from helper keys).
"_source_files": set([rc_file]) if rc_file else set(),
"_merged_count": 1,
} }
else: else:
gc = contact_by_norm_name[norm_name] gc = contact_by_norm_name[norm_name]
@@ -974,6 +1313,9 @@ def main():
gc["Address"] = rc.get("Address") or rc.get("address") gc["Address"] = rc.get("Address") or rc.get("address")
if not gc["Notes"] and (rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")): if not gc["Notes"] and (rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")):
gc["Notes"] = rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note") gc["Notes"] = rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")
if rc_file:
gc["_source_files"].add(rc_file)
gc["_merged_count"] += 1
if scoped_id: if scoped_id:
contact_id_map[scoped_id] = contact_by_norm_name[norm_name]["Id"] contact_id_map[scoped_id] = contact_by_norm_name[norm_name]["Id"]
@@ -1042,13 +1384,77 @@ def main():
"LitterLetter": rl.get("LitterLetter") or rl.get("litterLetter"), "LitterLetter": rl.get("LitterLetter") or rl.get("litterLetter"),
"_father_name": father_name, "_father_name": father_name,
"_mother_name": mother_name, "_mother_name": mother_name,
"_filename": filename "_filename": filename,
# Provenance accumulators (canonical absorbs these during dedup below).
"_source_files": set([filename]) if filename else set(),
"_merged_count": 1,
# Virtual litters are reconstructed from a Stammbaum chart, not the
# Wurfchronik — flagged on the raw record's _filename == "Stammbaum".
"_virtual": filename == "Stammbaum",
} }
resolved_litters.append(l_record) resolved_litters.append(l_record)
litter_by_scoped_id[new_guid] = l_record litter_by_scoped_id[new_guid] = l_record
print(f"Processed {len(resolved_litters)} litters.") print(f"Processed {len(resolved_litters)} litters.")
# 3b. Deduplicate litters: same date + compatible parents → merge
# This handles the "sibling pairing" case: Stammbaum shows the same parental
# litter twice (once under the father branch, once under the mother branch),
# generating two separate litter records with the same date but only one of
# them has FatherId/MotherId resolved.
litter_canonical_map = {} # old_id -> canonical_id (for dedup within this step)
# Group by date for efficiency
by_date = {}
for l in resolved_litters:
by_date.setdefault(l["Date"], []).append(l)
litter_dedup_canonical = {} # old_litter_id -> canonical_litter_id
deduped_litters = []
for date_val, group in by_date.items():
# Partition into compatible subsets
sub_groups = []
for l in group:
placed = False
for sub in sub_groups:
if all(litter_compatible(l, member) for member in sub):
sub.append(l)
placed = True
break
if not placed:
sub_groups.append([l])
for sub in sub_groups:
if len(sub) == 1:
deduped_litters.append(sub[0])
litter_dedup_canonical[sub[0]["Id"]] = sub[0]["Id"]
continue
# Pick the canonical record: prefer the one with parents set
canonical = next((l for l in sub if l.get("FatherId") or l.get("MotherId")), sub[0])
for l in sub:
litter_dedup_canonical[l["Id"]] = canonical["Id"]
if l is not canonical:
litter_id_map[l["Id"]] = canonical["Id"]
canonical["_source_files"] |= l.get("_source_files", set())
canonical["_merged_count"] += l.get("_merged_count", 1)
if not l.get("_virtual"):
canonical["_virtual"] = False
deduped_litters.append(canonical)
if len(sub) > 1:
merged_names = [l["Id"] for l in sub if l is not canonical]
print(f"Litter-Dedup: merged {len(sub)} same-date litters on {date_val}{canonical['Name']} (absorbed: {', '.join(merged_names)})")
n_merged = len(resolved_litters) - len(deduped_litters)
if n_merged:
print(f"Litter-Dedup: {n_merged} redundant litter record(s) removed.")
resolved_litters = deduped_litters
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
# 4. Normalize and group Gerbils
# Helper to lookup litter dates for birth date estimation # Helper to lookup litter dates for birth date estimation
def get_litter_date(l_id): def get_litter_date(l_id):
if l_id in litter_by_scoped_id: if l_id in litter_by_scoped_id:
@@ -1057,7 +1463,6 @@ def main():
return d return d
return None return None
# 4. Normalize and group Gerbils
all_processed_gerbils = [] all_processed_gerbils = []
for rg in raw_gerbils: for rg in raw_gerbils:
filename = rg.get("_filename") filename = rg.get("_filename")
@@ -1320,6 +1725,8 @@ def main():
"IsResident": a_id in stammbaum_resident_ids, "IsResident": a_id in stammbaum_resident_ids,
"parentRefs": a.get("parentRefs", []), "parentRefs": a.get("parentRefs", []),
"_photos": a.get("photos", []), "_photos": a.get("photos", []),
"_conflict": bool(a.get("conflict")),
"_resolved_by_decision": bool(a.get("resolvedByDecision")),
"_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",
"_birth_date": dob_val, "_birth_date": dob_val,
@@ -1478,6 +1885,69 @@ def main():
return True return True
def build_provenance(records, best_g, extra_notes=None, field_source=None):
"""Aggregate data-provenance across every raw record merged into one
resolved gerbil. Returns a JSON string (stored on the Gerbil entity as a
nullable text column) so the Rennmausakte can show where the entry came
from. `records` is the list of raw records that became this gerbil;
`best_g` is the chosen primary record. `field_source` maps a significant
field name (DateOfBirth/Genotype/…) to the raw record that supplied its
final value, so the history can name the exact file for each fact."""
source_files = set()
from_wurfchronik = False
any_conflict = False
any_decision = False
for r in records:
source_files |= _record_source_files(r)
for f in _record_source_files(r):
if "wurfchronik" in f.lower():
from_wurfchronik = True
if r.get("_conflict"):
any_conflict = True
if r.get("_resolved_by_decision"):
any_decision = True
notes = []
merged_count = len(records)
if merged_count > 1:
notes.append(f"aus {merged_count} Datensätzen zusammengeführt")
if any_decision:
notes.append("per manueller Entscheidung zugeordnet")
if any_conflict:
notes.append("Konflikt per Entscheidung gelöst")
# Parent derivation: surface the strongest parentRef method/confidence
# the primary record carries (chart-position etc.).
parent_method = None
parent_confidence = None
for ref in best_g.get("parentRefs", []) or []:
if ref.get("method") and not parent_method:
parent_method = ref.get("method")
if ref.get("confidence") and not parent_confidence:
parent_confidence = ref.get("confidence")
if extra_notes:
for n in extra_notes:
if n and n not in notes:
notes.append(n)
history = _build_gerbil_history(
records, best_g, field_source or {},
parent_method=parent_method,
any_decision=any_decision,
any_conflict=any_conflict,
conflict_notes=extra_notes or [],
)
return build_entity_provenance(
source_files,
merged_count,
notes=notes,
from_wurfchronik=from_wurfchronik,
extra={"parentMethod": parent_method, "parentConfidence": parent_confidence},
history=history,
)
# Group gerbils by name to perform deduplication # Group gerbils by name to perform deduplication
gerbil_groups = {} gerbil_groups = {}
for g in all_processed_gerbils: for g in all_processed_gerbils:
@@ -1501,6 +1971,7 @@ def main():
if is_placeholder: if is_placeholder:
# Placeholders: do NOT merge, keep all separate # Placeholders: do NOT merge, keep all separate
for g in group: for g in group:
g["Provenance"] = build_provenance([g], g)
resolved_gerbils.append(g) resolved_gerbils.append(g)
gerbil_id_map[g["Id"]] = g["Id"] gerbil_id_map[g["Id"]] = g["Id"]
continue continue
@@ -1521,6 +1992,7 @@ def main():
for sub in sub_groups: for sub in sub_groups:
if len(sub) == 1: if len(sub) == 1:
g = sub[0] g = sub[0]
g["Provenance"] = build_provenance([g], g)
resolved_gerbils.append(g) resolved_gerbils.append(g)
gerbil_id_map[g["Id"]] = g["Id"] gerbil_id_map[g["Id"]] = g["Id"]
continue continue
@@ -1546,13 +2018,24 @@ def main():
merged_notes = [] merged_notes = []
if best_g["Notes"]: if best_g["Notes"]:
merged_notes.append(best_g["Notes"]) merged_notes.append(best_g["Notes"])
# Merge photos # Merge photos
merged_photos = list(best_g.get("_photos", [])) merged_photos = list(best_g.get("_photos", []))
# Track sources for debugging # Track sources for debugging
sources = [best_g["_filename"]] sources = [best_g["_filename"]]
# Per-field file attribution: which raw record supplied each final
# field value. Seed with best_g for every field it already carries;
# the fill loop and voting loop update it as winners change.
ATTRIB_FIELDS = ["DateOfBirth", "DateOfDeath", "Gender", "Genotype",
"ColorVarietyId", "Name"]
field_source = {}
for fld in ATTRIB_FIELDS:
v = best_g.get(fld)
if v and v != "unknown":
field_source[fld] = best_g
for g in sub: for g in sub:
if g == best_g: if g == best_g:
continue continue
@@ -1569,16 +2052,20 @@ def main():
best_g["LitterId"] = g["LitterId"] best_g["LitterId"] = g["LitterId"]
if not best_g["DateOfBirth"] and g["DateOfBirth"]: if not best_g["DateOfBirth"] and g["DateOfBirth"]:
best_g["DateOfBirth"] = g["DateOfBirth"] best_g["DateOfBirth"] = g["DateOfBirth"]
field_source["DateOfBirth"] = g
if not best_g["DateOfDeath"] and g["DateOfDeath"]: if not best_g["DateOfDeath"] and g["DateOfDeath"]:
best_g["DateOfDeath"] = g["DateOfDeath"] best_g["DateOfDeath"] = g["DateOfDeath"]
field_source["DateOfDeath"] = g
if not best_g["CauseOfDeath"] and g["CauseOfDeath"]: if not best_g["CauseOfDeath"] and g["CauseOfDeath"]:
best_g["CauseOfDeath"] = g["CauseOfDeath"] best_g["CauseOfDeath"] = g["CauseOfDeath"]
if not best_g["GoHomeDate"] and g["GoHomeDate"]: if not best_g["GoHomeDate"] and g["GoHomeDate"]:
best_g["GoHomeDate"] = g["GoHomeDate"] best_g["GoHomeDate"] = g["GoHomeDate"]
if not best_g["Genotype"] and g["Genotype"]: if not best_g["Genotype"] and g["Genotype"]:
best_g["Genotype"] = g["Genotype"] best_g["Genotype"] = g["Genotype"]
field_source["Genotype"] = g
if not best_g["ColorVarietyId"] and g["ColorVarietyId"]: if not best_g["ColorVarietyId"] and g["ColorVarietyId"]:
best_g["ColorVarietyId"] = g["ColorVarietyId"] best_g["ColorVarietyId"] = g["ColorVarietyId"]
field_source["ColorVarietyId"] = g
if not best_g["OriginContactId"] and g["OriginContactId"]: if not best_g["OriginContactId"] and g["OriginContactId"]:
best_g["OriginContactId"] = g["OriginContactId"] best_g["OriginContactId"] = g["OriginContactId"]
if not best_g["ReceiverContactId"] and g["ReceiverContactId"]: if not best_g["ReceiverContactId"] and g["ReceiverContactId"]:
@@ -1589,10 +2076,12 @@ def main():
# Reconcile Gender: prefer a known gender over unknown, and prefer stammbaum over other sources # Reconcile Gender: prefer a known gender over unknown, and prefer stammbaum over other sources
if best_g["Gender"] == "unknown" and g["Gender"] != "unknown": if best_g["Gender"] == "unknown" and g["Gender"] != "unknown":
best_g["Gender"] = g["Gender"] best_g["Gender"] = g["Gender"]
field_source["Gender"] = g
elif best_g["Gender"] != "unknown" and g["Gender"] != "unknown" and best_g["Gender"] != g["Gender"]: elif best_g["Gender"] != "unknown" and g["Gender"] != "unknown" and best_g["Gender"] != g["Gender"]:
if g["ImportSource"] and "stammbaum" in g["ImportSource"].lower(): if g["ImportSource"] and "stammbaum" in g["ImportSource"].lower():
if not best_g["ImportSource"] or "stammbaum" not in best_g["ImportSource"].lower(): if not best_g["ImportSource"] or "stammbaum" not in best_g["ImportSource"].lower():
best_g["Gender"] = g["Gender"] best_g["Gender"] = g["Gender"]
field_source["Gender"] = g
# Status precedence: Deceased > GivenAway > Breeding/Pet # Status precedence: Deceased > GivenAway > Breeding/Pet
if g["Status"] == "Deceased": if g["Status"] == "Deceased":
@@ -1606,6 +2095,7 @@ def main():
merged_notes.append(g["Notes"]) merged_notes.append(g["Notes"])
# Reconcile fields based on number of source files supporting them # Reconcile fields based on number of source files supporting them
conflict_notes = []
for field in ["DateOfBirth", "DateOfDeath", "Gender", "Genotype", "ColorVarietyId"]: for field in ["DateOfBirth", "DateOfDeath", "Gender", "Genotype", "ColorVarietyId"]:
votes = {} votes = {}
for g in sub: for g in sub:
@@ -1616,7 +2106,18 @@ def main():
votes[val] = votes.get(val, 0) + sources_count votes[val] = votes.get(val, 0) + sources_count
if votes: if votes:
best_val = max(votes, key=votes.get) best_val = max(votes, key=votes.get)
# If the records disagreed on a field, the merge had to pick a
# winner — record that as a provenance note.
if len(votes) > 1:
conflict_notes.append(
f"Konflikt bei {PROV_FIELD_LABELS[field]} per Mehrheitsentscheidung gelöst"
)
best_g[field] = best_val best_g[field] = best_val
# Attribute the winning value to a record that actually holds
# it, so the history names the right file.
winner = next((g for g in sub if g.get(field) == best_val), None)
if winner is not None:
field_source[field] = winner
# Keep helper fields in sync if we changed DateOfBirth # Keep helper fields in sync if we changed DateOfBirth
if field == "DateOfBirth": if field == "DateOfBirth":
best_g["_birth_date"] = best_val best_g["_birth_date"] = best_val
@@ -1624,12 +2125,15 @@ def main():
if merged_notes: if merged_notes:
best_g["Notes"] = " | ".join(merged_notes) best_g["Notes"] = " | ".join(merged_notes)
best_g["_photos"] = merged_photos best_g["_photos"] = merged_photos
best_g["Provenance"] = build_provenance(
sub, best_g, extra_notes=conflict_notes, field_source=field_source
)
# Print merge trace # Print merge trace
print(f"Deduplicated same-animal name '{best_g['Name']}': merged {len(sub)} entries across files: {', '.join(sources)}") print(f"Deduplicated same-animal name '{best_g['Name']}': merged {len(sub)} entries across files: {', '.join(sources)}")
resolved_gerbils.append(best_g) resolved_gerbils.append(best_g)
gerbil_id_map[best_g["Id"]] = best_g["Id"] gerbil_id_map[best_g["Id"]] = best_g["Id"]
@@ -1685,6 +2189,8 @@ def main():
del g["_birth_date"] del g["_birth_date"]
del g["_filename"] del g["_filename"]
del g["_old_id"] del g["_old_id"]
g.pop("_conflict", None)
g.pop("_resolved_by_decision", None)
# Gather final valid gerbil IDs # Gather final valid gerbil IDs
valid_gerbil_ids = {g["Id"] for g in resolved_gerbils} valid_gerbil_ids = {g["Id"] for g in resolved_gerbils}
@@ -1716,59 +2222,71 @@ def main():
# Parent Resolver (Global Name Matching) # Parent Resolver (Global Name Matching)
resolved_fathers = 0 resolved_fathers = 0
resolved_mothers = 0 resolved_mothers = 0
gerbil_by_id_final = {g["Id"]: g for g in resolved_gerbils}
def _final_gender(gid):
g = gerbil_by_id_final.get(gid)
return g["Gender"] if g else None
def _resolve_name(name, prefer_gender, litter_date):
"""Resolve a parent name to the best matching final gerbil.
Gender is a PREFERENCE, not a hard filter: a reversed parent (e.g. a
female listed in the father position, as the Stammbaum often does) still
resolves to a gerbil — the role is corrected afterwards by gender. This
is what previously left FatherId/MotherId null (the candidate was
filtered out for having the "wrong" gender for its slot).
"""
if not name:
return None
cands = []
for c in gerbil_by_norm_name.get(normalize_name(name), []):
final_id = gerbil_id_map.get(c["Id"])
if not final_id:
continue
final_c = gerbil_by_id_final.get(final_id)
if not final_c:
continue
# Parent must be age-plausible: born before the litter and within the
# gerbil lifespan (skips e.g. a 2013 animal for a 2022 litter).
if not parent_age_plausible(final_c["DateOfBirth"], litter_date):
continue
cands.append(final_c)
if not cands:
return None
# Prefer the gender expected for this role, then unknown, then anything.
for pool in (
[c for c in cands if c["Gender"] == prefer_gender],
[c for c in cands if c["Gender"] == "unknown"],
cands,
):
if pool:
return pool[0]
return None
for l in resolved_litters: for l in resolved_litters:
# Match Father by Name # Pre-check: if _father_name points to a known female and _mother_name to a
f_name = l["_father_name"] # known male → swap names (Stammbaum positions reversed). Helps the name
if f_name and not l["FatherId"]: # resolver pick the right same-name candidate before role normalization.
f_norm = normalize_name(f_name) f_name_pre = l.get("_father_name", "")
candidates = gerbil_by_norm_name.get(f_norm, []) m_name_pre = l.get("_mother_name", "")
valid_candidates = [] if f_name_pre and m_name_pre:
for c in candidates: f_gender = next((g["Gender"] for g in gerbil_by_norm_name.get(normalize_name(f_name_pre), []) if g["Gender"] != "unknown"), None)
# Map to final deduplicated ID m_gender = next((g["Gender"] for g in gerbil_by_norm_name.get(normalize_name(m_name_pre), []) if g["Gender"] != "unknown"), None)
final_id = gerbil_id_map.get(c["Id"]) if f_gender == "female" and m_gender == "male":
if not final_id: l["_father_name"], l["_mother_name"] = m_name_pre, f_name_pre
continue
# Retrieve final record if l["_father_name"] and not l["FatherId"]:
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None) cand = _resolve_name(l["_father_name"], "male", l["Date"])
if final_c and final_c["Gender"] in ["male", "unknown"]: if cand:
# Ensure parent is born before litter if birth date is known l["FatherId"] = cand["Id"]
if l["Date"] and final_c["DateOfBirth"]:
if final_c["DateOfBirth"] < l["Date"]:
valid_candidates.append(final_c)
else:
valid_candidates.append(final_c)
if len(valid_candidates) == 1:
l["FatherId"] = valid_candidates[0]["Id"]
resolved_fathers += 1
elif len(valid_candidates) > 1:
l["FatherId"] = valid_candidates[0]["Id"]
resolved_fathers += 1 resolved_fathers += 1
# Match Mother by Name if l["_mother_name"] and not l["MotherId"]:
m_name = l["_mother_name"] cand = _resolve_name(l["_mother_name"], "female", l["Date"])
if m_name and not l["MotherId"]: if cand:
m_norm = normalize_name(m_name) l["MotherId"] = cand["Id"]
candidates = gerbil_by_norm_name.get(m_norm, [])
valid_candidates = []
for c in candidates:
final_id = gerbil_id_map.get(c["Id"])
if not final_id:
continue
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
if final_c and final_c["Gender"] in ["female", "unknown"]:
if l["Date"] and final_c["DateOfBirth"]:
if final_c["DateOfBirth"] < l["Date"]:
valid_candidates.append(final_c)
else:
valid_candidates.append(final_c)
if len(valid_candidates) == 1:
l["MotherId"] = valid_candidates[0]["Id"]
resolved_mothers += 1
elif len(valid_candidates) > 1:
l["MotherId"] = valid_candidates[0]["Id"]
resolved_mothers += 1 resolved_mothers += 1
# Cleanup internal keys # Cleanup internal keys
@@ -1776,8 +2294,159 @@ def main():
del l["_mother_name"] del l["_mother_name"]
del l["_filename"] del l["_filename"]
# Role normalization: assign each resolved parent to the role matching its
# gender, eliminate self-pairings (same animal in both roles), and never let
# impossible duplicates survive (two males / two females). This corrects
# reversed Stammbaum positions including the cases the simple swap missed
# (one parent of "unknown" gender, or a self-paired litter).
role_fixes = 0
for l in resolved_litters:
father, mother = assign_parent_roles(l.get("FatherId"), l.get("MotherId"), _final_gender)
if (l.get("FatherId"), l.get("MotherId")) != (father, mother):
role_fixes += 1
l["FatherId"] = father
l["MotherId"] = mother
if role_fixes:
print(f"Role-normalization: corrected {role_fixes} litter(s) (gender roles / self-pairings).")
# Parent-age sanity check: drop any resolved parent that cannot belong to the
# litter — born after the offspring, or more than a gerbil lifespan earlier.
# Catches mis-resolved links the name matcher still let through (e.g. Jayjay,
# *2013, wrongly attached to Solice's 2022 litter).
age_drops = []
for l in resolved_litters:
ldate = l.get("Date")
for role in ("FatherId", "MotherId"):
pid = l.get(role)
if not pid:
continue
p = gerbil_by_id_final.get(pid)
if p and not parent_age_plausible(p.get("DateOfBirth"), ldate):
age_drops.append((l.get("Name"), role, p.get("Name"), p.get("DateOfBirth"), ldate))
l[role] = None
if age_drops:
print(f"Parent-age sanity check: dropped {len(age_drops)} implausible parent link(s):")
for lname, role, pname, pdob, ldate in age_drops[:20]:
print(f" {lname}: {role}={pname} (*{pdob}) vs litter {ldate}")
print(f"Globally resolved {resolved_fathers} fathers and {resolved_mothers} mothers.") print(f"Globally resolved {resolved_fathers} fathers and {resolved_mothers} mothers.")
# 5b. Second-pass litter dedup: now that FatherId/MotherId are known,
# merge litters that have the same date AND the same parents.
# This is the core of the "sibling pairing" fix: Blue Wave and Sunny Sky
# both come from Wonderman × Unique — their two separate litter records
# must now become one, so their children share the same LitterId.
litter_by_id_post = {l["Id"]: l for l in resolved_litters}
gerbil_by_litter = {}
for g in resolved_gerbils:
lid = g.get("LitterId")
if lid:
gerbil_by_litter.setdefault(lid, []).append(g)
def _litter_same_parents(l1, l2):
"""Strict: same date + both parents known and matching."""
if l1["Date"] != l2["Date"]:
return False
f1, m1 = l1.get("FatherId"), l1.get("MotherId")
f2, m2 = l2.get("FatherId"), l2.get("MotherId")
if not f1 or not f2 or not m1 or not m2:
return False
return f1 == f2 and m1 == m2
by_date2 = {}
for l in resolved_litters:
by_date2.setdefault(l["Date"], []).append(l)
deduped2 = []
litter_remap2 = {} # old_id -> canonical_id
for date_val, group in by_date2.items():
sub_groups = []
for l in group:
placed = False
for sub in sub_groups:
if all(_litter_same_parents(l, m) for m in sub):
sub.append(l)
placed = True
break
if not placed:
sub_groups.append([l])
for sub in sub_groups:
# Prefer the canonical that has the most children
canonical = max(sub, key=lambda l: len(gerbil_by_litter.get(l["Id"], [])))
for l in sub:
litter_remap2[l["Id"]] = canonical["Id"]
if l is not canonical:
canonical["_source_files"] |= l.get("_source_files", set())
canonical["_merged_count"] += l.get("_merged_count", 1)
if not l.get("_virtual"):
canonical["_virtual"] = False
deduped2.append(canonical)
if len(sub) > 1:
siblings = [g["Name"] for l in sub for g in gerbil_by_litter.get(l["Id"], []) if l is not canonical]
print(f"Sibling-Litter-Merge on {date_val}: {canonical['Name']} absorbed sibling half — children now share LitterId: {[g['Name'] for g in gerbil_by_litter.get(canonical['Id'], [])] + siblings}")
# Remap LitterId in all gerbils
n_remapped = 0
for g in resolved_gerbils:
old_lid = g.get("LitterId")
if old_lid and old_lid in litter_remap2 and litter_remap2[old_lid] != old_lid:
g["LitterId"] = litter_remap2[old_lid]
n_remapped += 1
n_merged2 = len(resolved_litters) - len(deduped2)
if n_merged2:
print(f"Sibling-Litter-Dedup: {n_merged2} additional litter record(s) merged ({n_remapped} gerbil LitterIds remapped).")
resolved_litters = deduped2
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
# Datenherkunft for litters: which source files contributed, whether this is
# a Wurfchronik litter vs a Stammbaum-reconstructed ("virtual") litter, how
# many raw records merged into it, plus human-readable notes. Accumulators
# (_source_files/_merged_count/_virtual) were filled during the two dedup
# passes above; strip them after use.
for l in resolved_litters:
l_source_files = l.pop("_source_files", set())
l_merged_count = l.pop("_merged_count", 1)
is_virtual = l.pop("_virtual", False)
l_from_wurfchronik = any("wurfchronik" in str(f).lower() for f in l_source_files)
l_notes = []
if is_virtual and not l_from_wurfchronik:
l_notes.append("aus Stammbaum-Diagramm rekonstruiert")
elif l_from_wurfchronik:
l_notes.append("aus Wurfchronik")
if l_merged_count > 1:
l_notes.append(f"aus {l_merged_count} Datensätzen zusammengeführt")
l_notes.append("Geschwister-Würfe zusammengeführt")
# Chronological, file-attributed history for the litter.
l_files_sorted = sorted({f for f in l_source_files if f})
l_history = []
first_file = l_files_sorted[0] if l_files_sorted else None
if is_virtual and not l_from_wurfchronik:
if first_file:
l_history.append(
f"Aus Stammbaum-Diagramm rekonstruiert ({_quote_file(first_file)})."
)
else:
l_history.append("Aus Stammbaum-Diagramm rekonstruiert.")
elif first_file:
l_history.append(f"Wurf aus Wurfchronik {_quote_file(first_file)}.")
else:
l_history.append("Wurf im Import gefunden.")
for f in l_files_sorted[1:]:
l_history.append(
f"Auch in {_quote_file(f)} gefunden → Datensätze zusammengeführt."
)
if l_merged_count > 1:
l_history.append("Geschwister-Würfe zusammengeführt.")
l["Provenance"] = build_entity_provenance(
l_source_files, l_merged_count, notes=l_notes,
from_wurfchronik=l_from_wurfchronik, history=l_history,
)
# Set IsBreeder and IsReceiver flags on contacts # Set IsBreeder and IsReceiver flags on contacts
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")} breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")} receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}
@@ -1790,6 +2459,37 @@ def main():
c["IsBreeder"] = is_breeder c["IsBreeder"] = is_breeder
c["IsReceiver"] = is_receiver c["IsReceiver"] = is_receiver
# Datenherkunft: where this (deduplicated) contact came from, plus the
# role we inferred. Accumulator keys (_source_files/_merged_count) were
# filled during the contact dedup above; strip them after use.
c_source_files = c.pop("_source_files", set())
c_merged_count = c.pop("_merged_count", 1)
c_notes = []
if c_merged_count > 1:
c_notes.append(f"aus {c_merged_count} Datensätzen zusammengeführt")
if is_breeder:
c_notes.append("als Züchter erkannt")
if is_receiver:
c_notes.append("als Abnehmer erkannt")
# Chronological, file-attributed history for the contact.
c_files_sorted = sorted({f for f in c_source_files if f})
c_history = []
role_word = "Züchter" if is_breeder else "Abnehmer"
if c_files_sorted:
c_history.append(f"In {_quote_file(c_files_sorted[0])} als {role_word} erkannt.")
for f in c_files_sorted[1:]:
c_history.append(
f"Auch in {_quote_file(f)} gefunden → Datensätze zusammengeführt."
)
else:
c_history.append(f"Im Import als {role_word} erkannt.")
if is_breeder and is_receiver:
c_history.append("Sowohl als Züchter als auch als Abnehmer geführt.")
c["Provenance"] = build_entity_provenance(
c_source_files, c_merged_count, notes=c_notes, history=c_history
)
# Set and map gerbilPhotos # Set and map gerbilPhotos
resolved_photos = [] resolved_photos = []
for g in resolved_gerbils: for g in resolved_gerbils:

View File

@@ -4,32 +4,21 @@ _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: **2548**
- Nach Zusammenführung (eindeutige Tiere): **1000** - Nach Zusammenführung (eindeutige Tiere): **1044**
- davon mit Geburtsdatum: 677 - davon mit Geburtsdatum: 685
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 462 - in mehreren Dateien gefunden (Dubletten zusammengeführt): 476
- Konflikte zur Klärung: **4** - Konflikte zur Klärung: **7**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342** - Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **380**
- Fotos zugeordnet: **422** - Fotos zugeordnet: **435**
- Würfe aus der Wurfchronik: **752** - Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **269** (davon über Geburtsdatum **und** Eltern: 165, nur über Geburtsdatum: 104; mehrdeutig: 16) - Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 168, nur über Geburtsdatum: 102; mehrdeutig: 17)
- 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
Tiere wurden zusammengeführt über **normalisierter Rufname + Geburtsdatum**, mit der **Zucht als Unterscheidungsmerkmal** (Julians Regel: die `[Klammern]` in der Wurfchronik und das `of/von <Linie>`-Suffix der Stammbäume bezeichnen beide die Zucht und werden zusammengeführt — z. B. `[ZdkC]``von den Kleinen Chaoten`). Namensvarianten (z. B. `v.d.``von den`, `gen.`-Spitznamen) werden als `nameVariants` erhalten. Tiere wurden zusammengeführt über **normalisierter Rufname + Geburtsdatum**, mit der **Zucht als Unterscheidungsmerkmal** (Julians Regel: die `[Klammern]` in der Wurfchronik und das `of/von <Linie>`-Suffix der Stammbäume bezeichnen beide die Zucht und werden zusammengeführt — z. B. `[ZdkC]``von den Kleinen Chaoten`). Namensvarianten (z. B. `v.d.``von den`, `gen.`-Spitznamen) werden als `nameVariants` erhalten.
### Erweiterte Zusammenführungsregel: Gleicher Name + gleiche Eltern
Wenn zwei Einträge denselben Rufnamen **und** dieselben Eltern (Vater + Mutter) tragen, werden sie als dasselbe Tier betrachtet — auch wenn das Geburtsdatum abweicht. Das DOB des Eintrags, in dem das Tier Proband ist (`_gen == 0`), hat Priorität. Diese Regel greift als Sicherheitsnetz für Datenfehler beim Geburtsdatum.
**Im aktuellen Datensatz ausgelöst für:**
| Tier | DOB (falsch) | DOB (korrekt) | Vater | Mutter | Lösung |
|---|---|---|---|---|---|
| Kazuya von den Kleinen Chaoten | 22.08.2018 | 14.07.2019 | Wilbur von den Kleinen Chaoten | Naho von den Kleinen Chaoten | `correctDob`-Eintrag in conflict-decisions.json → DOB vor Dedup remapped |
### Gleicher Name + Geburtsdatum, aber unterschiedliche Zucht (NICHT zusammengeführt — bitte prüfen) ### Gleicher Name + Geburtsdatum, aber unterschiedliche Zucht (NICHT zusammengeführt — bitte prüfen)
| Tier | Geburtsdatum | Zuchten | Dateien | | Tier | Geburtsdatum | Zuchten | Dateien |
@@ -45,14 +34,17 @@ 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 | | Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 // 31.01.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Ella, Stammbaum von Picus Son, Stammbaum von Valentino Firehearts Kids |
| Lui von den Kleinen Chaoten | 16.04.2021 | Aa Cc[chm] DD ee[f] Gg PP Spsp // Aa c[chm]c[chm] D- Ee[f] Gg PP spsp | — | 15.12.2020 | Stammbaum von Alberto Kids, Stammbaum von Picus Son |
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| Quebec v.d. Kleinen Chaoten | 21.06.2014 | aa Cc[chm] dd Ee Gg Pp spsp // aa Cc[chm] dd Ee Uwuw[d] Pp spsp // aa Cc[chm] dd Ee gg Pp spsp | — | 21.10.2016 | Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
| | 20.04.2024 | Aa C- dd Ee Gg P- Spsp | Dilute Agouti Kragenschecke // Dilute Kohlfuchs Kragenschecke DP | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids | | | 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 | | | 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
342 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt. 380 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt.
- Oskar v.d. bunten Fellnase · Stammbaum von Akio Kids.xlsx - Oskar v.d. bunten Fellnase · Stammbaum von Akio Kids.xlsx
- Raya v.d. bunten Fellnasen · Stammbaum von Akio Kids.xlsx - Raya v.d. bunten Fellnasen · Stammbaum von Akio Kids.xlsx
@@ -97,7 +89,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
## Wahrscheinliche Zuordnungen unvollständiger Einträge ## Wahrscheinliche Zuordnungen unvollständiger Einträge
89 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung): 98 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
- „Tai of Lennylengo“ → Tai of Lennylengo (*01.10.2011) - „Tai of Lennylengo“ → Tai of Lennylengo (*01.10.2011)
- „Arrow PZ Niederlande“ → Arrow PZ Niederlande (*20.05.2014) - „Arrow PZ Niederlande“ → Arrow PZ Niederlande (*20.05.2014)
@@ -176,7 +168,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `!` | 2 | ? | | `!` | 2 | ? |
| `C(C)` | 2 | Schreibweise (C trägt c) | | `C(C)` | 2 | Schreibweise (C trägt c) |
| `(KW` | 2 | ? | | `(KW` | 2 | ? |
| `Cc[]` | 1 | ? |
| `[WFNZ-Maroon]` | 1 | ? | | `[WFNZ-Maroon]` | 1 | ? |
| `/+April'2017` | 1 | ? | | `/+April'2017` | 1 | ? |
| `[meliert]-[DP]` | 1 | ? | | `[meliert]-[DP]` | 1 | ? |
@@ -191,6 +182,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
| `+2018` | 1 | ? | | `+2018` | 1 | ? |
| `AAA` | 1 | ? | | `AAA` | 1 | ? |
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) | | `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
| `c[chm]chm]` | 1 | ? |
## Wurfchronik — Datenqualitäts-Hinweise ## Wurfchronik — Datenqualitäts-Hinweise

View File

@@ -396,6 +396,27 @@ if os.path.exists(danako_path):
else: else:
print("\nWarning: Danako stammbaum file not found, skipping integration checks.") print("\nWarning: Danako stammbaum file not found, skipping integration checks.")
# --- Stammbaum von Kazuya: photo generation-shift regression (PHOTO-LEFT-STYLE) ---
# This sheet has neither col-1 nor col-4 image anchors, so the old fixed-column
# heuristic misread it as right-style and shifted every photo one generation
# toward the proband: Kazuya wore his father's (Wilbur's) photo, Wilbur wore the
# grandfather's (Elay's). The fix picks the layout that places photos closest to
# their animal's name column → photos land on the correct generation.
kazuya_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Kazuya.xlsx"
if os.path.exists(kazuya_path):
print(f"\nFound Kazuya stammbaum, running photo generation-shift validation...")
kz = {a["name"]: a for a in e.extract_stammbaum(kazuya_path)}
if "Kazuya" in kz:
check("Kazuya (proband) has NO photo of his own", kz["Kazuya"]["photos"] == [])
if "Wilbur" in kz:
check("Wilbur (father) gets his own photo (image7), not the grandfather's",
kz["Wilbur"]["photos"] == ["photos/wilbur-19032017/image7.jpeg"])
if "Elay" in kz:
check("Elay (grandfather) gets his own photo (image2)",
kz["Elay"]["photos"] == ["photos/elay-16032016/image2.jpeg"])
else:
print("\nWarning: Kazuya stammbaum file not found, skipping photo-shift checks.")
if failed: if failed:
print(f"\n{failed} test(s) FAILED") print(f"\n{failed} test(s) FAILED")
sys.exit(1) sys.exit(1)

View File

@@ -0,0 +1,283 @@
"""Zero-dep tests for merge_and_resolve.py litter-dedup & parent-role logic.
Run: python test_merge_resolve.py (exit 0 = all pass)
Covers the sibling-pairing data fix:
- litter_compatible(): same-date / compatible-parent dedup, incl. the dateless
guard that stops unrelated nameless stubs from blind-merging.
- assign_parent_roles(): gender-correct role assignment, self-pairing removal,
no two same-role parents — the fix for the 16 self-pairings / 30 gender-role
errors that the old simple swap missed.
"""
import sys
import merge_and_resolve as m
def check(name, cond):
if not cond:
print(f"FAIL: {name}")
check.failed += 1
else:
print(f"ok: {name}")
check.failed = 0
def litter(date, fid=None, mid=None, fname=None, mname=None):
return {
"Date": date,
"FatherId": fid,
"MotherId": mid,
"_father_name": fname,
"_mother_name": mname,
}
# ── litter_compatible: both sides fully parented ──
check(
"same date + same parents → compatible",
m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "F", "M")),
)
check(
"same date + different parents → NOT compatible",
not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "X", "Y")),
)
check(
"different date → NOT compatible",
not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2019-01-01", "F", "M")),
)
# ── asymmetric (one resolved, one not), real date ──
check(
"asymmetric same real date, names agree → merge",
m.litter_compatible(
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
litter("2018-09-22", None, None, "Wonderman", "Unique"),
),
)
check(
"asymmetric same real date, conflicting names → NO merge",
not m.litter_compatible(
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
litter("2018-09-22", None, None, "Someone", "Else"),
),
)
check(
"asymmetric real date, parented side nameless stub → merge (no conflict)",
m.litter_compatible(
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
litter("2018-09-22", None, None, None, None),
),
)
# ── dateless guard: the bug that wrongly merged unrelated stubs ──
check(
"dateless asymmetric, NO name evidence → do NOT merge (was the bug)",
not m.litter_compatible(
litter(None, "F", "M", "Akina", "Arrow"),
litter(None, None, None, None, None),
),
)
check(
"dateless asymmetric WITH positive name match → merge",
m.litter_compatible(
litter(None, "F", "M", "Akina", "Arrow"),
litter(None, None, None, "Akina", None),
),
)
check(
"dateless asymmetric, contradicting names → do NOT merge",
not m.litter_compatible(
litter(None, "F", "M", "Akina", "Arrow"),
litter(None, None, None, "Mismatch", None),
),
)
# ── neither side parented → never blind-merge ──
check(
"neither parented, same date → NOT compatible",
not m.litter_compatible(litter("2018-09-22"), litter("2018-09-22")),
)
# ── assign_parent_roles ──
GENDER = {"bock": "male", "bock2": "male", "maus": "female", "maus2": "female", "u": "unknown", "u2": "unknown"}
gof = lambda gid: GENDER.get(gid)
check("correct roles stay put", m.assign_parent_roles("bock", "maus", gof) == ("bock", "maus"))
check("reversed roles get swapped", m.assign_parent_roles("maus", "bock", gof) == ("bock", "maus"))
check(
"self-pairing collapses to gender-correct single role (male→father)",
m.assign_parent_roles("bock", "bock", gof) == ("bock", None),
)
check(
"self-pairing collapses to gender-correct single role (female→mother)",
m.assign_parent_roles("maus", "maus", gof) == (None, "maus"),
)
check(
"female in father slot, empty mother → moved to mother",
m.assign_parent_roles("maus", None, gof) == (None, "maus"),
)
check(
"male in mother slot, empty father → moved to father",
m.assign_parent_roles(None, "bock", gof) == ("bock", None),
)
check(
"two males → keep one father, drop impossible second",
m.assign_parent_roles("bock", "bock2", gof) == ("bock", None),
)
check(
"two females → keep one mother, drop impossible second",
m.assign_parent_roles("maus", "maus2", gof) == (None, "maus"),
)
check(
"male + unknown → unknown fills mother",
m.assign_parent_roles("bock", "u", gof) == ("bock", "u"),
)
check(
"female + unknown → unknown fills father",
m.assign_parent_roles("u", "maus", gof) == ("u", "maus"),
)
check("both empty → both None", m.assign_parent_roles(None, None, gof) == (None, None))
check(
"single unknown parent kept as father",
m.assign_parent_roles("u", None, gof) == ("u", None),
)
# ── name helpers ──
check("names_no_conflict: one side empty", m.names_no_conflict(litter(None, fname="A"), litter(None)))
check(
"names_no_conflict: contradiction detected",
not m.names_no_conflict(litter(None, fname="A"), litter(None, fname="B")),
)
check("names_overlap: matching father name", m.names_overlap(litter(None, fname="A"), litter(None, fname="A")))
check("names_overlap: nothing in common", not m.names_overlap(litter(None, fname="A"), litter(None, mname="B")))
# ── parent_age_plausible: born before child, within ~6y lifespan ──
check("age: parent 1y before child → plausible", m.parent_age_plausible("16.04.2021", "27.03.2022"))
check("age: parent born AFTER child → implausible", not m.parent_age_plausible("2023-01-01", "2022-03-27"))
check("age: parent born SAME day → implausible", not m.parent_age_plausible("2022-03-27", "2022-03-27"))
check("age: 9 years older (Jayjay→Solice) → implausible", not m.parent_age_plausible("19.06.2013", "27.03.2022"))
check("age: exactly ~5y older → plausible", m.parent_age_plausible("01.06.2017", "01.05.2022"))
check("age: 7 years older → implausible", not m.parent_age_plausible("25.10.2018", "13.08.2025"))
check("age: unknown parent dob → plausible (can't disprove)", m.parent_age_plausible(None, "2022-03-27"))
check("age: unknown litter date → plausible", m.parent_age_plausible("2021-04-16", None))
# ── pick_parent_ref: prefer age-plausible ref, avoid duplicating the other role ──
def pref(name, role, dob=None):
return {"name": name, "roleGuess": role, "dob": dob}
# Solice case: first father ref (Jayjay, no DOB) loses to the dated, plausible Lui.
solice_refs = [
pref("Jayjay", "father"),
pref("Lui von den Kleinen Chaoten", "mother", "16.04.2021"),
pref("Lui von den Kleinen Chaoten", "father", "16.04.2021"),
pref("Molly of Black Forest", "mother", "13.09.2021"),
]
f = m.pick_parent_ref(solice_refs, "father", "27.03.2022")
check("pick: father = plausible-dated Lui, not first-listed Jayjay",
f and f["name"] == "Lui von den Kleinen Chaoten")
mo = m.pick_parent_ref(solice_refs, "mother", "27.03.2022", avoid_name=f["name"])
check("pick: mother = Molly (Lui avoided as it is the father)",
mo and mo["name"] == "Molly of Black Forest")
check("pick: a dated-but-impossible ref loses to a plausible one",
m.pick_parent_ref([pref("Old", "father", "2010-01-01"), pref("Dad", "father", "2021-01-01")],
"father", "2022-03-27")["name"] == "Dad")
check("pick: no ref for role → None",
m.pick_parent_ref([pref("X", "mother", "2021-01-01")], "father", "2022-03-27") is None)
check("pick: single ref is returned",
m.pick_parent_ref([pref("Solo", "father")], "father", "2022-03-27")["name"] == "Solo")
# Gender-aware: a dated FEMALE ref must not win the father slot over an undated
# male/unknown one (Molly regression: Hagrid (unknown, no DOB) vs Danielle
# (female, dated) → father must be Hagrid, not Danielle).
molly_refs = [
pref("Hagrid Rubeus of Black Forest", "father"),
pref("Arya Stark von den Kleinen Chaoten", "mother", "30.06.2020"),
pref("Danielle von den Kleinen Chaoten", "father", "04.03.2020"),
pref("Hagrid Rubeus of Black Forest", "mother", "18.07.2019"),
]
gender = {
m.normalize_name("Hagrid Rubeus of Black Forest"): None, # unknown
m.normalize_name("Danielle von den Kleinen Chaoten"): "female",
m.normalize_name("Arya Stark von den Kleinen Chaoten"): "female",
}
gof = lambda name: gender.get(m.normalize_name(name))
fr = m.pick_parent_ref(molly_refs, "father", "13.09.2021", gender_of=gof)
check("pick(gender): father = unknown-sex Hagrid, not dated female Danielle",
fr and fr["name"] == "Hagrid Rubeus of Black Forest")
mr = m.pick_parent_ref(molly_refs, "mother", "13.09.2021", avoid_name=fr["name"], gender_of=gof)
check("pick(gender): mother = Arya (female)", mr and mr["name"].startswith("Arya"))
# ── Provenance history: chronological, file-attributed German log ──
import json as _json
def _hist(prov_json):
return _json.loads(prov_json)["history"]
# build_entity_provenance carries history through verbatim.
_prov = _json.loads(
m.build_entity_provenance(["A.xlsx"], 1, notes=["x"], history=["line one"])
)
check("build_entity_provenance includes history key", _prov.get("history") == ["line one"])
check("build_entity_provenance defaults history to []",
_json.loads(m.build_entity_provenance(["A.xlsx"], 1)).get("history") == [])
# Single-record gerbil history: names the file and the per-field facts.
g_single = {
"Name": "Picus", "DateOfBirth": "2022-03-27", "Gender": "male",
"Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
"ImportSource": "Stammbaum von Picus Son.xlsx",
"_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
}
h = m._build_gerbil_history([g_single], g_single, {})
check("history: first line names the source file",
h[0] == "In „Stammbaum von Picus Son.xlsx“ gefunden.")
check("history: dob line names file + formatted date",
"Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h)
check("history: gender line is German + file-attributed",
"Geschlecht (männlich) aus „Stammbaum von Picus Son.xlsx“." in h)
check("history: genotype line file-attributed",
"Genotyp aus „Stammbaum von Picus Son.xlsx“." in h)
# Merged gerbil: a field sourced from a DIFFERENT file is attributed to THAT file.
g_best = {
"Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
"Genotype": None, "ColorVarietyId": None, "DateOfDeath": None,
"ImportSource": "Stammbaum von Picus Son.xlsx",
"_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
}
g_other = {
"Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
"Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
"ImportSource": "Wurfchronik-Detail.docx",
"_filename": "Wurfchronik-Detail.docx", "parentRefs": [],
}
# Genotype was filled from g_other → its line must name the docx file.
g_best["Genotype"] = "aa"
fs = {"DateOfBirth": g_best, "Gender": g_best, "Genotype": g_other}
h2 = m._build_gerbil_history([g_best, g_other], g_best, fs)
check("history(merge): genotype attributed to the file that supplied it",
"Genotyp aus „Wurfchronik-Detail.docx“." in h2)
check("history(merge): dob attributed to primary file",
"Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h2)
check("history(merge): merge line names the absorbed file",
"Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt." in h2)
check("history(merge): Wurfchronik line present",
"Angaben aus der Wurfchronik übernommen." in h2)
# Date formatting helper.
check("_de_date: ISO → DD.MM.YYYY", m._de_date("2022-03-27") == "27.03.2022")
check("_de_date: passes through non-ISO", m._de_date("unbekannt") == "unbekannt")
if check.failed:
print(f"\n{check.failed} test(s) FAILED")
sys.exit(1)
print("\nAll merge_and_resolve tests passed.")