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:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
1542
GerbilManagerWebAPI/Migrations/20260622135306_AddFeedbackContactId.Designer.cs
generated
Normal file
1542
GerbilManagerWebAPI/Migrations/20260622135306_AddFeedbackContactId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1548
GerbilManagerWebAPI/Migrations/20260622135338_AddContactLitterProvenance.Designer.cs
generated
Normal file
1548
GerbilManagerWebAPI/Migrations/20260622135338_AddContactLitterProvenance.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user