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>
This commit is contained in:
2026-06-22 15:45:15 +02:00
parent b9e5b031c4
commit 9c98c37d2a
16 changed files with 2126 additions and 6 deletions

View File

@@ -84,7 +84,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\"]}"
}, },
new new
{ {
@@ -111,7 +112,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 +178,12 @@ 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);
Assert.Null(mother.Provenance);
} }
} }
} }

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,

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

@@ -160,6 +160,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,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");
}
}
}

View File

@@ -912,6 +912,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");

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

@@ -122,6 +122,15 @@ 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'],
}),
}, },
{ ...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'),

View File

@@ -0,0 +1,46 @@
/** 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()
// 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)
})

View File

@@ -62,6 +62,32 @@ 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[]
} }
/** Payload for POST /gerbils. */ /** Payload for POST /gerbils. */

View File

@@ -0,0 +1,157 @@
/**
* 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 { GerbilProvenance } from '../api/types'
import './provenanceDialog.css'
interface ProvenanceDialogProps {
open: boolean
onClose: () => void
/** Raw provenance JSON string from Gerbil.provenance (null = no import data). */
provenance?: string | null
}
/** Parse the JSON provenance string defensively; null on absence / parse error. */
function parseProvenance(raw?: string | null): GerbilProvenance | null {
if (!raw || !raw.trim()) return null
try {
const p = JSON.parse(raw) as Partial<GerbilProvenance>
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 : [],
}
} catch {
return null
}
}
export default function ProvenanceDialog({ open, onClose, provenance }: 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">{t.intro}</p>
<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,157 @@
/* 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;
}
.provenance__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.1rem;
}

View File

@@ -14,6 +14,7 @@ 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 ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix' import { useGerbilName } from '../components/breederSuffix'
import { useToast } from '../components/toast' import { useToast } from '../components/toast'
@@ -59,6 +60,7 @@ export default function GerbilDetailPage() {
const { id = '' } = useParams() const { id = '' } = useParams()
const [tab, setTab] = useState<DetailTab>('photos') const [tab, setTab] = useState<DetailTab>('photos')
const [reportOpen, setReportOpen] = useState(false) 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' }))
@@ -258,6 +260,9 @@ 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)}> <button type="button" className="ak-btn" onClick={() => setReportOpen(true)}>
{de.feedback.button} {de.feedback.button}
</button> </button>
@@ -482,6 +487,12 @@ export default function GerbilDetailPage() {
onClose={() => setReportOpen(false)} onClose={() => setReportOpen(false)}
context={{ context: 'gerbil-detail', gerbilId: g.id, entityName: g.name || de.pages.gerbils.nameless }} 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

@@ -878,6 +878,37 @@ export const de = {
idCopied: 'ID kopiert', idCopied: 'ID kopiert',
idCopyFailed: 'ID konnte nicht kopiert werden.', 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.',
/** Ü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

@@ -1520,6 +1520,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,
@@ -1678,6 +1680,80 @@ def main():
return True return True
def _record_source_files(g):
"""All distinct source files a single raw 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 build_provenance(records, best_g, extra_notes=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."""
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)
prov = {
"sourceFiles": sorted(source_files),
"mergedRecordCount": merged_count,
"fromWurfchronik": from_wurfchronik,
"notes": notes,
}
if parent_method:
prov["parentMethod"] = parent_method
if parent_confidence:
prov["parentConfidence"] = parent_confidence
return json.dumps(prov, ensure_ascii=False)
# 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:
@@ -1701,6 +1777,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
@@ -1721,6 +1798,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
@@ -1806,6 +1884,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:
@@ -1816,6 +1895,19 @@ 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:
FIELD_LABEL = {
"DateOfBirth": "Geburtsdatum",
"DateOfDeath": "Sterbedatum",
"Gender": "Geschlecht",
"Genotype": "Genotyp",
"ColorVarietyId": "Farbschlag",
}
conflict_notes.append(
f"Konflikt bei {FIELD_LABEL[field]} per Mehrheitsentscheidung gelöst"
)
best_g[field] = best_val best_g[field] = best_val
# Keep helper fields in sync if we changed DateOfBirth # Keep helper fields in sync if we changed DateOfBirth
if field == "DateOfBirth": if field == "DateOfBirth":
@@ -1826,6 +1918,7 @@ def main():
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)
# 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)}")
@@ -1885,6 +1978,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}