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>
This commit is contained in:
2026-06-22 16:02:11 +02:00
parent 9c98c37d2a
commit 438c816820
27 changed files with 3474 additions and 34 deletions

View File

@@ -75,7 +75,7 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
{
Contacts = new[]
{
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false }
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[]
{
@@ -109,6 +109,19 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
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()
@@ -119,12 +132,18 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
var result = await new IngestResolvedService(db, config, null!).RunAsync();
Assert.Contains("Ingestion successful!", result);
// Gerbils/litters were wiped & re-created, but feedback is untouched.
var survivor = await db.Feedback.SingleAsync();
Assert.Equal(feedbackId, survivor.Id);
// 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
{

View File

@@ -36,7 +36,8 @@ namespace GerbilManager.Tests
Email = "test@example.com",
Homepage = "",
Phone = "",
Address = ""
Address = "",
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
}
},
Litters = new[]
@@ -54,7 +55,8 @@ namespace GerbilManager.Tests
Notes = "Test litter notes",
PairingCode = "PC01",
ExternalRef = "ext-litter-1",
LitterLetter = "A"
LitterLetter = "A",
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
}
},
Gerbils = new[]
@@ -184,6 +186,15 @@ namespace GerbilManager.Tests
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
Assert.Contains("mergedRecordCount", 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

@@ -44,9 +44,10 @@ namespace GerbilManagerWebAPI.Dtos
Guid? FatherId,
Guid? MotherId,
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);

View File

@@ -6,6 +6,7 @@ namespace GerbilManagerWebAPI.Dtos
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp);
@@ -17,6 +18,7 @@ namespace GerbilManagerWebAPI.Dtos
string Context,
Guid? GerbilId,
Guid? LitterId,
Guid? ContactId,
string? EntityName,
string? Url,
DateTimeOffset? ClientTimestamp,

View File

@@ -55,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
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

@@ -31,6 +31,7 @@ namespace GerbilManagerWebAPI.Endpoints
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,
@@ -56,7 +57,7 @@ namespace GerbilManagerWebAPI.Endpoints
}
private static FeedbackDto ToDto(Feedback f) =>
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.EntityName, f.Url,
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

@@ -98,7 +98,7 @@ namespace GerbilManagerWebAPI.Endpoints
private static LitterDto ToDto(Litter l) => new(
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>

View File

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

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")
.HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Contacts");
@@ -802,6 +805,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<DateTimeOffset?>("ClientTimestamp")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ContactId")
.HasColumnType("uuid");
b.Property<string>("Context")
.IsRequired()
.HasColumnType("text");
@@ -1046,6 +1052,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("PairingCode")
.HasColumnType("text");
b.Property<string>("Provenance")
.HasColumnType("text");
b.Property<int?>("TotalBorn")
.HasColumnType("integer");

View File

@@ -24,5 +24,11 @@ namespace GerbilManagerWebAPI.Models
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
/// </summary>
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

@@ -26,6 +26,9 @@ namespace GerbilManagerWebAPI.Models
/// <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; }

View File

@@ -38,5 +38,12 @@ namespace GerbilManagerWebAPI.Models
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
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

@@ -56,6 +56,24 @@ test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wur
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')

View File

@@ -195,7 +195,16 @@ export function seedDb(): MockDb {
const litters: Litter[] = [
{ 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 },
{
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'],
}),
},
{ 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-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
@@ -212,7 +221,16 @@ export function seedDb(): MockDb {
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
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'],
}),
},
{ 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-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },

View File

@@ -44,3 +44,42 @@ test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ pa
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()
// 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()
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

@@ -4,7 +4,7 @@ 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'
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 {
@@ -12,6 +12,7 @@ export interface FeedbackInput {
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
contactId?: string | null
entityName?: string | null
url?: string | null
clientTimestamp?: string | null
@@ -23,6 +24,7 @@ export interface Feedback {
context: string
gerbilId: string | null
litterId: string | null
contactId: string | null
entityName: string | null
url: string | null
clientTimestamp: string | null

View File

@@ -90,6 +90,13 @@ export interface GerbilProvenance {
notes: 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. */
export interface CreateGerbil {
name: string
@@ -142,6 +149,11 @@ export interface Contact {
isReceiver: boolean
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
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 {
@@ -162,6 +174,11 @@ export interface Litter {
motherId: string | null
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
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. */

View File

@@ -9,21 +9,23 @@
*/
import { useEffect } from 'react'
import { de } from '../strings/de'
import type { GerbilProvenance } from '../api/types'
import type { EntityProvenance } from '../api/types'
import './provenanceDialog.css'
interface ProvenanceDialogProps {
open: boolean
onClose: () => void
/** Raw provenance JSON string from Gerbil.provenance (null = no import data). */
/** 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): GerbilProvenance | null {
function parseProvenance(raw?: string | null): EntityProvenance | null {
if (!raw || !raw.trim()) return null
try {
const p = JSON.parse(raw) as Partial<GerbilProvenance>
const p = JSON.parse(raw) as Partial<EntityProvenance>
return {
sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [],
mergedRecordCount: typeof p.mergedRecordCount === 'number' ? p.mergedRecordCount : 0,
@@ -37,7 +39,7 @@ function parseProvenance(raw?: string | null): GerbilProvenance | null {
}
}
export default function ProvenanceDialog({ open, onClose, provenance }: ProvenanceDialogProps) {
export default function ProvenanceDialog({ open, onClose, provenance, entityLabel }: ProvenanceDialogProps) {
// Close on Escape (only while mounted).
useEffect(() => {
if (!open) return
@@ -84,7 +86,7 @@ export default function ProvenanceDialog({ open, onClose, provenance }: Provenan
<p className="provenance__empty">{t.empty}</p>
) : (
<>
<p className="provenance__intro">{t.intro}</p>
<p className="provenance__intro">{entityLabel ? t.introFor(entityLabel) : t.intro}</p>
<section className="provenance__section">
<div className="provenance__section-title">

View File

@@ -16,6 +16,7 @@ export interface ReportErrorContext {
context: FeedbackContext
gerbilId?: string | null
litterId?: string | null
contactId?: string | null
entityName?: string | null
}
@@ -78,6 +79,7 @@ function ReportErrorDialogBody({
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(),

View File

@@ -12,12 +12,16 @@ import { listGerbils } from '../api/gerbils'
import { listColorVarieties } from '../api/lookups'
import { condition } from '../api/gridify'
import { useApi, useMutation } from '../hooks/useApi'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
export default function KontaktDetailPage() {
const t = de.pages.kontakte
const { id = '' } = useParams()
const navigate = useNavigate()
const [deleteError, setDeleteError] = useState<string | null>(null)
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const contact = useApi(() => getContact(id), [id])
// 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">
{t.detail.edit}
</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
type="button"
className="btn btn--danger"
@@ -161,6 +171,19 @@ export default function KontaktDetailPage() {
))}
</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>
)
}

View File

@@ -13,6 +13,7 @@ import { isValidGenotype } from '../format/genotypeText'
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
import BreedingResultView from '../components/BreedingResultView'
import GerbilIcon from '../components/GerbilIcon'
import ProvenanceDialog from '../components/ProvenanceDialog'
import ReportErrorDialog from '../components/ReportErrorDialog'
import { useGerbilName } from '../components/breederSuffix'
import './wuerfe.css'
@@ -66,6 +67,7 @@ export default function WurfDetailPage() {
const gerbilName = useGerbilName()
const { id = '' } = useParams()
const [reportOpen, setReportOpen] = useState(false)
const [provenanceOpen, setProvenanceOpen] = useState(false)
const litter = useApi(() => getLitter(id), [id])
const fatherId = litter.data?.fatherId ?? null
@@ -153,6 +155,9 @@ export default function WurfDetailPage() {
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
{t.detail.edit}
</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>
@@ -235,6 +240,13 @@ export default function WurfDetailPage() {
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>
)
}

View File

@@ -866,6 +866,7 @@ export const de = {
stammbaum: 'Stammbaum',
'gerbil-detail': 'Rennmausakte',
'litter-detail': 'Wurf',
'contact-detail': 'Kontakt',
},
submit: 'Senden',
submitting: 'Wird gesendet …',
@@ -884,6 +885,14 @@ export const de = {
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',
},
/** Überschriften / Feldbeschriftungen. */
sourceFilesTitle: 'Quelldateien',
sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`),

View File

@@ -22,6 +22,30 @@ def generate_guid(key_str):
"""Generate a stable UUID string based on a key."""
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):
"""Generic data-provenance JSON builder shared by gerbils, contacts and
litters. Mirrors the GerbilProvenance frontend contract:
{ sourceFiles, mergedRecordCount, fromWurfchronik, notes, ... }
`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). 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 []),
}
if extra:
for k, v in extra.items():
if v is not None:
prov[k] = v
return json.dumps(prov, ensure_ascii=False)
def to_valid_guid(val):
if not val:
return None
@@ -1101,6 +1125,7 @@ def main():
norm_name = normalize_name(canon_name)
rc_file = rc.get("_filename")
if norm_name not in contact_by_norm_name:
global_guid = generate_guid(f"contact-{norm_name}")
contact_by_norm_name[norm_name] = {
@@ -1109,7 +1134,10 @@ def main():
"Email": rc.get("Email") or rc.get("email"),
"Phone": rc.get("Phone") or rc.get("phone"),
"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:
gc = contact_by_norm_name[norm_name]
@@ -1121,6 +1149,9 @@ def main():
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")):
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:
contact_id_map[scoped_id] = contact_by_norm_name[norm_name]["Id"]
@@ -1189,7 +1220,13 @@ def main():
"LitterLetter": rl.get("LitterLetter") or rl.get("litterLetter"),
"_father_name": father_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)
litter_by_scoped_id[new_guid] = l_record
@@ -1236,6 +1273,10 @@ def main():
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:
@@ -1742,17 +1783,13 @@ def main():
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)
return build_entity_provenance(
source_files,
merged_count,
notes=notes,
from_wurfchronik=from_wurfchronik,
extra={"parentMethod": parent_method, "parentConfidence": parent_confidence},
)
# Group gerbils by name to perform deduplication
gerbil_groups = {}
@@ -2167,6 +2204,11 @@ def main():
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]
@@ -2186,6 +2228,29 @@ def main():
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")
l["Provenance"] = build_entity_provenance(
l_source_files, l_merged_count, notes=l_notes,
from_wurfchronik=l_from_wurfchronik,
)
# Set IsBreeder and IsReceiver flags on contacts
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")}
@@ -2198,6 +2263,20 @@ def main():
c["IsBreeder"] = is_breeder
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")
c["Provenance"] = build_entity_provenance(c_source_files, c_merged_count, notes=c_notes)
# Set and map gerbilPhotos
resolved_photos = []
for g in resolved_gerbils: