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[]
|
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[]
|
Litters = new[]
|
||||||
{
|
{
|
||||||
@@ -109,6 +109,19 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
|||||||
Url = "http://localhost/rennmaeuse/papa",
|
Url = "http://localhost/rennmaeuse/papa",
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
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();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var config = new ConfigurationBuilder()
|
var config = new ConfigurationBuilder()
|
||||||
@@ -119,12 +132,18 @@ public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
|||||||
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
Assert.Contains("Ingestion successful!", result);
|
Assert.Contains("Ingestion successful!", result);
|
||||||
|
|
||||||
// Gerbils/litters were wiped & re-created, but feedback is untouched.
|
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
|
||||||
var survivor = await db.Feedback.SingleAsync();
|
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
|
||||||
Assert.Equal(feedbackId, survivor.Id);
|
|
||||||
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||||
Assert.Equal(litterId, survivor.LitterId);
|
Assert.Equal(litterId, survivor.LitterId);
|
||||||
Assert.Equal("Papa", survivor.EntityName);
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ namespace GerbilManager.Tests
|
|||||||
Email = "test@example.com",
|
Email = "test@example.com",
|
||||||
Homepage = "",
|
Homepage = "",
|
||||||
Phone = "",
|
Phone = "",
|
||||||
Address = ""
|
Address = "",
|
||||||
|
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Litters = new[]
|
Litters = new[]
|
||||||
@@ -54,7 +55,8 @@ namespace GerbilManager.Tests
|
|||||||
Notes = "Test litter notes",
|
Notes = "Test litter notes",
|
||||||
PairingCode = "PC01",
|
PairingCode = "PC01",
|
||||||
ExternalRef = "ext-litter-1",
|
ExternalRef = "ext-litter-1",
|
||||||
LitterLetter = "A"
|
LitterLetter = "A",
|
||||||
|
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Gerbils = new[]
|
Gerbils = new[]
|
||||||
@@ -184,6 +186,15 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
||||||
Assert.Contains("mergedRecordCount", father.Provenance);
|
Assert.Contains("mergedRecordCount", father.Provenance);
|
||||||
Assert.Null(mother.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? FatherId,
|
||||||
Guid? MotherId,
|
Guid? MotherId,
|
||||||
DateOnly? ExpectedGoHomeDate,
|
DateOnly? ExpectedGoHomeDate,
|
||||||
string? Notes);
|
string? Notes,
|
||||||
|
string? Provenance);
|
||||||
|
|
||||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
|
||||||
|
|
||||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string Context,
|
string Context,
|
||||||
Guid? GerbilId,
|
Guid? GerbilId,
|
||||||
Guid? LitterId,
|
Guid? LitterId,
|
||||||
|
Guid? ContactId,
|
||||||
string? EntityName,
|
string? EntityName,
|
||||||
string? Url,
|
string? Url,
|
||||||
DateTimeOffset? ClientTimestamp);
|
DateTimeOffset? ClientTimestamp);
|
||||||
@@ -17,6 +18,7 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
string Context,
|
string Context,
|
||||||
Guid? GerbilId,
|
Guid? GerbilId,
|
||||||
Guid? LitterId,
|
Guid? LitterId,
|
||||||
|
Guid? ContactId,
|
||||||
string? EntityName,
|
string? EntityName,
|
||||||
string? Url,
|
string? Url,
|
||||||
DateTimeOffset? ClientTimestamp,
|
DateTimeOffset? ClientTimestamp,
|
||||||
|
|||||||
@@ -55,6 +55,6 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix);
|
private static ContactDto ToDto(Contact c) => new(c.Id, c.Name, c.Email, c.Phone, c.Address, c.Notes, c.IsBreeder, c.IsReceiver, c.NameSuffix, c.Provenance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
|
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
|
||||||
GerbilId = input.GerbilId,
|
GerbilId = input.GerbilId,
|
||||||
LitterId = input.LitterId,
|
LitterId = input.LitterId,
|
||||||
|
ContactId = input.ContactId,
|
||||||
EntityName = input.EntityName,
|
EntityName = input.EntityName,
|
||||||
Url = input.Url,
|
Url = input.Url,
|
||||||
ClientTimestamp = input.ClientTimestamp,
|
ClientTimestamp = input.ClientTimestamp,
|
||||||
@@ -56,7 +57,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static FeedbackDto ToDto(Feedback f) =>
|
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);
|
f.ClientTimestamp, f.UserAgent, f.CreatedAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
|
|
||||||
private static LitterDto ToDto(Litter l) => new(
|
private static LitterDto ToDto(Litter l) => new(
|
||||||
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
l.Id, l.Name, l.Date, l.TotalBorn, l.DeathsWithin8Weeks,
|
||||||
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes);
|
l.FatherId, l.MotherId, l.ExpectedGoHomeDate, l.Notes, l.Provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
/// <summary>400 body for a father×mother gender mismatch; frontend localises by Code.</summary>
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
existingContact.Notes = c.Notes;
|
existingContact.Notes = c.Notes;
|
||||||
existingContact.IsBreeder = c.IsBreeder;
|
existingContact.IsBreeder = c.IsBreeder;
|
||||||
existingContact.IsReceiver = c.IsReceiver;
|
existingContact.IsReceiver = c.IsReceiver;
|
||||||
|
existingContact.NameSuffix = c.NameSuffix;
|
||||||
|
existingContact.Provenance = c.Provenance;
|
||||||
contactsUpdated++;
|
contactsUpdated++;
|
||||||
}
|
}
|
||||||
else if (addedContactIds.Add(c.Id))
|
else if (addedContactIds.Add(c.Id))
|
||||||
@@ -130,7 +132,8 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
Notes = l.Notes,
|
Notes = l.Notes,
|
||||||
PairingCode = l.PairingCode,
|
PairingCode = l.PairingCode,
|
||||||
ExternalRef = l.ExternalRef,
|
ExternalRef = l.ExternalRef,
|
||||||
LitterLetter = l.LitterLetter
|
LitterLetter = l.LitterLetter,
|
||||||
|
Provenance = l.Provenance
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_db.Litters.AddRange(littersToInsert);
|
_db.Litters.AddRange(littersToInsert);
|
||||||
|
|||||||
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")
|
b.Property<string>("Phone")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Provenance")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("Contacts");
|
b.ToTable("Contacts");
|
||||||
@@ -802,6 +805,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<DateTimeOffset?>("ClientTimestamp")
|
b.Property<DateTimeOffset?>("ClientTimestamp")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ContactId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Context")
|
b.Property<string>("Context")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -1046,6 +1052,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<string>("PairingCode")
|
b.Property<string>("PairingCode")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Provenance")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<int?>("TotalBorn")
|
b.Property<int?>("TotalBorn")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|||||||
@@ -24,5 +24,11 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
|
/// „von den Wüstenwinden“. Für Tiere fremder Züchter pflegbar.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? NameSuffix { get; set; }
|
public string? NameSuffix { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Data-provenance / traceability for the import: a JSON object describing
|
||||||
|
/// WHICH source information produced this contact (sourceFiles, mergedRecordCount,
|
||||||
|
/// notes). Written by the Python merge_and_resolve step and surfaced read-only
|
||||||
|
/// ("Datenherkunft"). Null = manually-added contact / no import data.</summary>
|
||||||
|
public string? Provenance { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// <summary>Loose reference (no FK) to the litter the report is about, if any.</summary>
|
/// <summary>Loose reference (no FK) to the litter the report is about, if any.</summary>
|
||||||
public Guid? LitterId { get; set; }
|
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>
|
/// <summary>Captured name of the referenced animal/litter (survives an ingest wipe).</summary>
|
||||||
public string? EntityName { get; set; }
|
public string? EntityName { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -38,5 +38,12 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
||||||
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
||||||
public string? LitterLetter { get; set; }
|
public string? LitterLetter { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Data-provenance / traceability for the import: a JSON object describing
|
||||||
|
/// WHICH source information produced this litter (sourceFiles, mergedRecordCount,
|
||||||
|
/// fromWurfchronik, notes — e.g. "aus Wurfchronik", "aus Stammbaum-Diagramm rekonstruiert").
|
||||||
|
/// Written by the Python merge_and_resolve step and surfaced read-only ("Datenherkunft").
|
||||||
|
/// Null = manually-added litter / no import data.</summary>
|
||||||
|
public string? Provenance { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' })
|
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 }) => {
|
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
|||||||
@@ -195,7 +195,16 @@ export function seedDb(): MockDb {
|
|||||||
const litters: Litter[] = [
|
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-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-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-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
||||||
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
||||||
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
||||||
@@ -212,7 +221,16 @@ export function seedDb(): MockDb {
|
|||||||
|
|
||||||
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
||||||
const contacts: Contact[] = [
|
const contacts: Contact[] = [
|
||||||
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true },
|
{
|
||||||
|
id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true,
|
||||||
|
// NACHVERFOLGUNG: Datenherkunft des Kontakts (vom Import erzeugter JSON-String).
|
||||||
|
provenance: JSON.stringify({
|
||||||
|
sourceFiles: ['Wurfchronik Teil 1_page_0001.md', 'Stammbaum von Krümel.xlsx'],
|
||||||
|
mergedRecordCount: 2,
|
||||||
|
fromWurfchronik: true,
|
||||||
|
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
|
||||||
|
}),
|
||||||
|
},
|
||||||
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
|
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
|
||||||
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
|
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
|
||||||
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
|
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
|
||||||
|
|||||||
@@ -44,3 +44,42 @@ test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ pa
|
|||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(dialog).toContainText(p.empty)
|
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'
|
const RESOURCE = '/feedback'
|
||||||
|
|
||||||
/** Which view a report was filed from (matches the backend Context contract). */
|
/** 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. */
|
/** Payload for POST /feedback. Debug fields are captured automatically by the caller. */
|
||||||
export interface FeedbackInput {
|
export interface FeedbackInput {
|
||||||
@@ -12,6 +12,7 @@ export interface FeedbackInput {
|
|||||||
context: FeedbackContext
|
context: FeedbackContext
|
||||||
gerbilId?: string | null
|
gerbilId?: string | null
|
||||||
litterId?: string | null
|
litterId?: string | null
|
||||||
|
contactId?: string | null
|
||||||
entityName?: string | null
|
entityName?: string | null
|
||||||
url?: string | null
|
url?: string | null
|
||||||
clientTimestamp?: string | null
|
clientTimestamp?: string | null
|
||||||
@@ -23,6 +24,7 @@ export interface Feedback {
|
|||||||
context: string
|
context: string
|
||||||
gerbilId: string | null
|
gerbilId: string | null
|
||||||
litterId: string | null
|
litterId: string | null
|
||||||
|
contactId: string | null
|
||||||
entityName: string | null
|
entityName: string | null
|
||||||
url: string | null
|
url: string | null
|
||||||
clientTimestamp: string | null
|
clientTimestamp: string | null
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ export interface GerbilProvenance {
|
|||||||
notes: string[]
|
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. */
|
/** Payload for POST /gerbils. */
|
||||||
export interface CreateGerbil {
|
export interface CreateGerbil {
|
||||||
name: string
|
name: string
|
||||||
@@ -142,6 +149,11 @@ export interface Contact {
|
|||||||
isReceiver: boolean
|
isReceiver: boolean
|
||||||
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
|
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
|
||||||
nameSuffix: string | null
|
nameSuffix: string | null
|
||||||
|
/**
|
||||||
|
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
|
||||||
|
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
|
||||||
|
*/
|
||||||
|
provenance?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Litter {
|
export interface Litter {
|
||||||
@@ -162,6 +174,11 @@ export interface Litter {
|
|||||||
motherId: string | null
|
motherId: string | null
|
||||||
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
|
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
|
||||||
deathsWithin8Weeks?: number | null
|
deathsWithin8Weeks?: number | null
|
||||||
|
/**
|
||||||
|
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
|
||||||
|
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
|
||||||
|
*/
|
||||||
|
provenance?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /litters. */
|
/** Payload for POST /litters. */
|
||||||
|
|||||||
@@ -9,21 +9,23 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import type { GerbilProvenance } from '../api/types'
|
import type { EntityProvenance } from '../api/types'
|
||||||
import './provenanceDialog.css'
|
import './provenanceDialog.css'
|
||||||
|
|
||||||
interface ProvenanceDialogProps {
|
interface ProvenanceDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
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
|
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. */
|
/** 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
|
if (!raw || !raw.trim()) return null
|
||||||
try {
|
try {
|
||||||
const p = JSON.parse(raw) as Partial<GerbilProvenance>
|
const p = JSON.parse(raw) as Partial<EntityProvenance>
|
||||||
return {
|
return {
|
||||||
sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [],
|
sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [],
|
||||||
mergedRecordCount: typeof p.mergedRecordCount === 'number' ? p.mergedRecordCount : 0,
|
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).
|
// Close on Escape (only while mounted).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
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__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">
|
<section className="provenance__section">
|
||||||
<div className="provenance__section-title">
|
<div className="provenance__section-title">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ReportErrorContext {
|
|||||||
context: FeedbackContext
|
context: FeedbackContext
|
||||||
gerbilId?: string | null
|
gerbilId?: string | null
|
||||||
litterId?: string | null
|
litterId?: string | null
|
||||||
|
contactId?: string | null
|
||||||
entityName?: string | null
|
entityName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ function ReportErrorDialogBody({
|
|||||||
context: context.context,
|
context: context.context,
|
||||||
gerbilId: context.gerbilId ?? null,
|
gerbilId: context.gerbilId ?? null,
|
||||||
litterId: context.litterId ?? null,
|
litterId: context.litterId ?? null,
|
||||||
|
contactId: context.contactId ?? null,
|
||||||
entityName: context.entityName ?? null,
|
entityName: context.entityName ?? null,
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
clientTimestamp: new Date().toISOString(),
|
clientTimestamp: new Date().toISOString(),
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ import { listGerbils } from '../api/gerbils'
|
|||||||
import { listColorVarieties } from '../api/lookups'
|
import { listColorVarieties } from '../api/lookups'
|
||||||
import { condition } from '../api/gridify'
|
import { condition } from '../api/gridify'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
|
import ProvenanceDialog from '../components/ProvenanceDialog'
|
||||||
|
import ReportErrorDialog from '../components/ReportErrorDialog'
|
||||||
|
|
||||||
export default function KontaktDetailPage() {
|
export default function KontaktDetailPage() {
|
||||||
const t = de.pages.kontakte
|
const t = de.pages.kontakte
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
const [reportOpen, setReportOpen] = useState(false)
|
||||||
|
const [provenanceOpen, setProvenanceOpen] = useState(false)
|
||||||
|
|
||||||
const contact = useApi(() => getContact(id), [id])
|
const contact = useApi(() => getContact(id), [id])
|
||||||
// Verknüpfte Tiere: Kontakt ist Herkunft ODER Abnehmer (Gridify-OR via |).
|
// Verknüpfte Tiere: Kontakt ist Herkunft ODER Abnehmer (Gridify-OR via |).
|
||||||
@@ -79,6 +83,12 @@ export default function KontaktDetailPage() {
|
|||||||
<Link to={`/kontakte/${c.id}/bearbeiten`} className="btn btn--primary">
|
<Link to={`/kontakte/${c.id}/bearbeiten`} className="btn btn--primary">
|
||||||
{t.detail.edit}
|
{t.detail.edit}
|
||||||
</Link>
|
</Link>
|
||||||
|
<button type="button" className="btn" onClick={() => setProvenanceOpen(true)}>
|
||||||
|
{de.provenance.button}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
|
||||||
|
{de.feedback.button}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn--danger"
|
className="btn btn--danger"
|
||||||
@@ -161,6 +171,19 @@ export default function KontaktDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ReportErrorDialog
|
||||||
|
open={reportOpen}
|
||||||
|
onClose={() => setReportOpen(false)}
|
||||||
|
context={{ context: 'contact-detail', contactId: c.id, entityName: c.name }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProvenanceDialog
|
||||||
|
open={provenanceOpen}
|
||||||
|
onClose={() => setProvenanceOpen(false)}
|
||||||
|
provenance={c.provenance}
|
||||||
|
entityLabel={de.provenance.entityLabels.contact}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { isValidGenotype } from '../format/genotypeText'
|
|||||||
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
||||||
import BreedingResultView from '../components/BreedingResultView'
|
import BreedingResultView from '../components/BreedingResultView'
|
||||||
import GerbilIcon from '../components/GerbilIcon'
|
import GerbilIcon from '../components/GerbilIcon'
|
||||||
|
import ProvenanceDialog from '../components/ProvenanceDialog'
|
||||||
import ReportErrorDialog from '../components/ReportErrorDialog'
|
import ReportErrorDialog from '../components/ReportErrorDialog'
|
||||||
import { useGerbilName } from '../components/breederSuffix'
|
import { useGerbilName } from '../components/breederSuffix'
|
||||||
import './wuerfe.css'
|
import './wuerfe.css'
|
||||||
@@ -66,6 +67,7 @@ export default function WurfDetailPage() {
|
|||||||
const gerbilName = useGerbilName()
|
const gerbilName = useGerbilName()
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const [reportOpen, setReportOpen] = useState(false)
|
const [reportOpen, setReportOpen] = useState(false)
|
||||||
|
const [provenanceOpen, setProvenanceOpen] = useState(false)
|
||||||
|
|
||||||
const litter = useApi(() => getLitter(id), [id])
|
const litter = useApi(() => getLitter(id), [id])
|
||||||
const fatherId = litter.data?.fatherId ?? null
|
const fatherId = litter.data?.fatherId ?? null
|
||||||
@@ -153,6 +155,9 @@ export default function WurfDetailPage() {
|
|||||||
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
|
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
|
||||||
{t.detail.edit}
|
{t.detail.edit}
|
||||||
</Link>
|
</Link>
|
||||||
|
<button type="button" className="btn" onClick={() => setProvenanceOpen(true)}>
|
||||||
|
{de.provenance.button}
|
||||||
|
</button>
|
||||||
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
|
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
|
||||||
{de.feedback.button}
|
{de.feedback.button}
|
||||||
</button>
|
</button>
|
||||||
@@ -235,6 +240,13 @@ export default function WurfDetailPage() {
|
|||||||
onClose={() => setReportOpen(false)}
|
onClose={() => setReportOpen(false)}
|
||||||
context={{ context: 'litter-detail', litterId: l.id, entityName: l.name }}
|
context={{ context: 'litter-detail', litterId: l.id, entityName: l.name }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProvenanceDialog
|
||||||
|
open={provenanceOpen}
|
||||||
|
onClose={() => setProvenanceOpen(false)}
|
||||||
|
provenance={l.provenance}
|
||||||
|
entityLabel={de.provenance.entityLabels.litter}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -866,6 +866,7 @@ export const de = {
|
|||||||
stammbaum: 'Stammbaum',
|
stammbaum: 'Stammbaum',
|
||||||
'gerbil-detail': 'Rennmausakte',
|
'gerbil-detail': 'Rennmausakte',
|
||||||
'litter-detail': 'Wurf',
|
'litter-detail': 'Wurf',
|
||||||
|
'contact-detail': 'Kontakt',
|
||||||
},
|
},
|
||||||
submit: 'Senden',
|
submit: 'Senden',
|
||||||
submitting: 'Wird gesendet …',
|
submitting: 'Wird gesendet …',
|
||||||
@@ -884,6 +885,14 @@ export const de = {
|
|||||||
button: 'Datenherkunft',
|
button: 'Datenherkunft',
|
||||||
dialogTitle: 'Nachverfolgungsinformationen',
|
dialogTitle: 'Nachverfolgungsinformationen',
|
||||||
intro: 'Woher stammen die Daten dieses Eintrags? Diese Angaben werden beim Import automatisch erfasst.',
|
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. */
|
/** Überschriften / Feldbeschriftungen. */
|
||||||
sourceFilesTitle: 'Quelldateien',
|
sourceFilesTitle: 'Quelldateien',
|
||||||
sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`),
|
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."""
|
"""Generate a stable UUID string based on a key."""
|
||||||
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
|
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
|
||||||
|
|
||||||
|
def build_entity_provenance(source_files, merged_record_count, notes=None,
|
||||||
|
from_wurfchronik=None, extra=None):
|
||||||
|
"""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):
|
def to_valid_guid(val):
|
||||||
if not val:
|
if not val:
|
||||||
return None
|
return None
|
||||||
@@ -1101,6 +1125,7 @@ def main():
|
|||||||
|
|
||||||
norm_name = normalize_name(canon_name)
|
norm_name = normalize_name(canon_name)
|
||||||
|
|
||||||
|
rc_file = rc.get("_filename")
|
||||||
if norm_name not in contact_by_norm_name:
|
if norm_name not in contact_by_norm_name:
|
||||||
global_guid = generate_guid(f"contact-{norm_name}")
|
global_guid = generate_guid(f"contact-{norm_name}")
|
||||||
contact_by_norm_name[norm_name] = {
|
contact_by_norm_name[norm_name] = {
|
||||||
@@ -1109,7 +1134,10 @@ def main():
|
|||||||
"Email": rc.get("Email") or rc.get("email"),
|
"Email": rc.get("Email") or rc.get("email"),
|
||||||
"Phone": rc.get("Phone") or rc.get("phone"),
|
"Phone": rc.get("Phone") or rc.get("phone"),
|
||||||
"Address": rc.get("Address") or rc.get("address"),
|
"Address": rc.get("Address") or rc.get("address"),
|
||||||
"Notes": rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")
|
"Notes": rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note"),
|
||||||
|
# Provenance accumulators (consumed below, stripped from helper keys).
|
||||||
|
"_source_files": set([rc_file]) if rc_file else set(),
|
||||||
|
"_merged_count": 1,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
gc = contact_by_norm_name[norm_name]
|
gc = contact_by_norm_name[norm_name]
|
||||||
@@ -1121,6 +1149,9 @@ def main():
|
|||||||
gc["Address"] = rc.get("Address") or rc.get("address")
|
gc["Address"] = rc.get("Address") or rc.get("address")
|
||||||
if not gc["Notes"] and (rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")):
|
if not gc["Notes"] and (rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")):
|
||||||
gc["Notes"] = rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")
|
gc["Notes"] = rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")
|
||||||
|
if rc_file:
|
||||||
|
gc["_source_files"].add(rc_file)
|
||||||
|
gc["_merged_count"] += 1
|
||||||
|
|
||||||
if scoped_id:
|
if scoped_id:
|
||||||
contact_id_map[scoped_id] = contact_by_norm_name[norm_name]["Id"]
|
contact_id_map[scoped_id] = contact_by_norm_name[norm_name]["Id"]
|
||||||
@@ -1189,7 +1220,13 @@ def main():
|
|||||||
"LitterLetter": rl.get("LitterLetter") or rl.get("litterLetter"),
|
"LitterLetter": rl.get("LitterLetter") or rl.get("litterLetter"),
|
||||||
"_father_name": father_name,
|
"_father_name": father_name,
|
||||||
"_mother_name": mother_name,
|
"_mother_name": mother_name,
|
||||||
"_filename": filename
|
"_filename": filename,
|
||||||
|
# Provenance accumulators (canonical absorbs these during dedup below).
|
||||||
|
"_source_files": set([filename]) if filename else set(),
|
||||||
|
"_merged_count": 1,
|
||||||
|
# Virtual litters are reconstructed from a Stammbaum chart, not the
|
||||||
|
# Wurfchronik — flagged on the raw record's _filename == "Stammbaum".
|
||||||
|
"_virtual": filename == "Stammbaum",
|
||||||
}
|
}
|
||||||
resolved_litters.append(l_record)
|
resolved_litters.append(l_record)
|
||||||
litter_by_scoped_id[new_guid] = l_record
|
litter_by_scoped_id[new_guid] = l_record
|
||||||
@@ -1236,6 +1273,10 @@ def main():
|
|||||||
litter_dedup_canonical[l["Id"]] = canonical["Id"]
|
litter_dedup_canonical[l["Id"]] = canonical["Id"]
|
||||||
if l is not canonical:
|
if l is not canonical:
|
||||||
litter_id_map[l["Id"]] = canonical["Id"]
|
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)
|
deduped_litters.append(canonical)
|
||||||
if len(sub) > 1:
|
if len(sub) > 1:
|
||||||
@@ -1742,17 +1783,13 @@ def main():
|
|||||||
if n and n not in notes:
|
if n and n not in notes:
|
||||||
notes.append(n)
|
notes.append(n)
|
||||||
|
|
||||||
prov = {
|
return build_entity_provenance(
|
||||||
"sourceFiles": sorted(source_files),
|
source_files,
|
||||||
"mergedRecordCount": merged_count,
|
merged_count,
|
||||||
"fromWurfchronik": from_wurfchronik,
|
notes=notes,
|
||||||
"notes": notes,
|
from_wurfchronik=from_wurfchronik,
|
||||||
}
|
extra={"parentMethod": parent_method, "parentConfidence": parent_confidence},
|
||||||
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 = {}
|
||||||
@@ -2167,6 +2204,11 @@ def main():
|
|||||||
canonical = max(sub, key=lambda l: len(gerbil_by_litter.get(l["Id"], [])))
|
canonical = max(sub, key=lambda l: len(gerbil_by_litter.get(l["Id"], [])))
|
||||||
for l in sub:
|
for l in sub:
|
||||||
litter_remap2[l["Id"]] = canonical["Id"]
|
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)
|
deduped2.append(canonical)
|
||||||
if len(sub) > 1:
|
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]
|
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
|
resolved_litters = deduped2
|
||||||
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
|
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
|
# Set IsBreeder and IsReceiver flags on contacts
|
||||||
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
|
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
|
||||||
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}
|
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}
|
||||||
@@ -2198,6 +2263,20 @@ def main():
|
|||||||
c["IsBreeder"] = is_breeder
|
c["IsBreeder"] = is_breeder
|
||||||
c["IsReceiver"] = is_receiver
|
c["IsReceiver"] = is_receiver
|
||||||
|
|
||||||
|
# Datenherkunft: where this (deduplicated) contact came from, plus the
|
||||||
|
# role we inferred. Accumulator keys (_source_files/_merged_count) were
|
||||||
|
# filled during the contact dedup above; strip them after use.
|
||||||
|
c_source_files = c.pop("_source_files", set())
|
||||||
|
c_merged_count = c.pop("_merged_count", 1)
|
||||||
|
c_notes = []
|
||||||
|
if c_merged_count > 1:
|
||||||
|
c_notes.append(f"aus {c_merged_count} Datensätzen zusammengeführt")
|
||||||
|
if is_breeder:
|
||||||
|
c_notes.append("als Züchter erkannt")
|
||||||
|
if is_receiver:
|
||||||
|
c_notes.append("als Abnehmer erkannt")
|
||||||
|
c["Provenance"] = build_entity_provenance(c_source_files, c_merged_count, notes=c_notes)
|
||||||
|
|
||||||
# Set and map gerbilPhotos
|
# Set and map gerbilPhotos
|
||||||
resolved_photos = []
|
resolved_photos = []
|
||||||
for g in resolved_gerbils:
|
for g in resolved_gerbils:
|
||||||
|
|||||||
Reference in New Issue
Block a user