Compare commits
12 Commits
cb2ee03a14
...
2befe53df9
| Author | SHA1 | Date | |
|---|---|---|---|
| 2befe53df9 | |||
| cf672c0933 | |||
| a31fea17fa | |||
| 438c816820 | |||
| 9c98c37d2a | |||
| b9e5b031c4 | |||
| 1845d37476 | |||
| 2868aad369 | |||
| 03ad9e6e81 | |||
| 18efab6996 | |||
| 070a896072 | |||
| 3715e32303 |
181
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
181
GerbilManager.Tests/FeedbackEndpointTests.cs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.Import;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// FEEDBACK: the "Fehler melden" report sink.
|
||||||
|
/// - POST /feedback persists a row (with captured debug context) and GET /feedback returns it.
|
||||||
|
/// - Validation: an empty message is rejected with 400.
|
||||||
|
/// - CRITICAL: feedback rows survive the import re-ingest wipe (loose, FK-free GerbilId/LitterId).
|
||||||
|
/// </summary>
|
||||||
|
public class FeedbackEndpointTests : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory;
|
||||||
|
public FeedbackEndpointTests(ApiFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_feedback_persists_row_with_debug_context()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
|
||||||
|
var gerbilId = Guid.NewGuid();
|
||||||
|
var resp = await client.PostAsJsonAsync("/feedback", new
|
||||||
|
{
|
||||||
|
message = "Der Stammbaum zeigt den falschen Vater.",
|
||||||
|
context = "stammbaum",
|
||||||
|
gerbilId,
|
||||||
|
litterId = (Guid?)null,
|
||||||
|
entityName = "Krümel",
|
||||||
|
url = "http://localhost:5173/rennmaeuse/kruemel/stammbaum",
|
||||||
|
clientTimestamp = "2026-06-22T12:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Created, resp.StatusCode);
|
||||||
|
var created = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.False(string.IsNullOrEmpty(created.GetProperty("id").GetString()));
|
||||||
|
Assert.Equal("stammbaum", created.GetProperty("context").GetString());
|
||||||
|
Assert.Equal("Krümel", created.GetProperty("entityName").GetString());
|
||||||
|
Assert.Equal(gerbilId.ToString(), created.GetProperty("gerbilId").GetString());
|
||||||
|
|
||||||
|
// GET returns it (newest first)
|
||||||
|
var listed = JsonDocument.Parse(await client.GetStringAsync("/feedback")).RootElement;
|
||||||
|
Assert.Contains(listed.EnumerateArray(),
|
||||||
|
f => f.GetProperty("entityName").GetString() == "Krümel"
|
||||||
|
&& f.GetProperty("message").GetString() == "Der Stammbaum zeigt den falschen Vater.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Post_feedback_rejects_empty_message()
|
||||||
|
{
|
||||||
|
var client = _factory.CreateClient();
|
||||||
|
var resp = await client.PostAsJsonAsync("/feedback", new { message = " ", context = "gerbil-detail" });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Feedback_survives_ingest_wipe()
|
||||||
|
{
|
||||||
|
// Fresh in-memory DB seeded with a resolved import file (mirrors IngestResolvedServiceTests).
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "feedback-ingest-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var contactId = Guid.NewGuid();
|
||||||
|
var fatherId = Guid.NewGuid();
|
||||||
|
var motherId = Guid.NewGuid();
|
||||||
|
var litterId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var data = new
|
||||||
|
{
|
||||||
|
Contacts = new[]
|
||||||
|
{
|
||||||
|
new { Id = contactId, Name = "Test Breeder", Email = "t@e.de", Phone = "", Address = "", Notes = (string?)null, IsBreeder = true, IsReceiver = false, NameSuffix = (string?)null, Provenance = (string?)null }
|
||||||
|
},
|
||||||
|
Litters = new[]
|
||||||
|
{
|
||||||
|
new { Id = litterId, Name = "Wurf A", Date = "2026-01-01", TotalBorn = 5, DeathsWithin8Weeks = 0, FatherId = fatherId, MotherId = motherId, ExpectedGoHomeDate = (string?)null, Notes = "", PairingCode = "PC01", ExternalRef = "ext-litter-1", LitterLetter = "A" }
|
||||||
|
},
|
||||||
|
Gerbils = new[]
|
||||||
|
{
|
||||||
|
Animal(fatherId, "Papa", "male", contactId),
|
||||||
|
Animal(motherId, "Mama", "female", contactId),
|
||||||
|
},
|
||||||
|
GerbilPhotos = Array.Empty<object>(),
|
||||||
|
};
|
||||||
|
File.WriteAllText(Path.Combine(dir, "resolved_import.json"), JsonSerializer.Serialize(data));
|
||||||
|
|
||||||
|
var opts = new DbContextOptionsBuilder<ApplicationContext>()
|
||||||
|
.UseInMemoryDatabase("feedback-ingest-" + Guid.NewGuid().ToString("N"))
|
||||||
|
.Options;
|
||||||
|
using var db = new ApplicationContext(opts);
|
||||||
|
db.Database.EnsureCreated();
|
||||||
|
|
||||||
|
// A feedback report referencing the gerbil + litter that the wipe will delete.
|
||||||
|
var feedbackId = Guid.NewGuid();
|
||||||
|
db.Feedback.Add(new Feedback
|
||||||
|
{
|
||||||
|
Id = feedbackId,
|
||||||
|
Message = "Bitte prüfen.",
|
||||||
|
Context = "gerbil-detail",
|
||||||
|
GerbilId = fatherId,
|
||||||
|
LitterId = litterId,
|
||||||
|
EntityName = "Papa",
|
||||||
|
Url = "http://localhost/rennmaeuse/papa",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
// A contact-scoped feedback report — the ContactId is a loose (FK-free) id,
|
||||||
|
// so it must survive the contact-table wipe just like gerbil/litter ids.
|
||||||
|
var contactFeedbackId = Guid.NewGuid();
|
||||||
|
db.Feedback.Add(new Feedback
|
||||||
|
{
|
||||||
|
Id = contactFeedbackId,
|
||||||
|
Message = "Adresse stimmt nicht.",
|
||||||
|
Context = "contact-detail",
|
||||||
|
ContactId = contactId,
|
||||||
|
EntityName = "Test Breeder",
|
||||||
|
Url = "http://localhost/kontakte/test-breeder",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { { "Import:SourcePath", dir } })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Run the ingest wipe + reload.
|
||||||
|
var result = await new IngestResolvedService(db, config, null!).RunAsync();
|
||||||
|
Assert.Contains("Ingestion successful!", result);
|
||||||
|
|
||||||
|
// Gerbils/litters/contacts were wiped & re-created, but feedback is untouched.
|
||||||
|
var survivor = await db.Feedback.SingleAsync(f => f.Id == feedbackId);
|
||||||
|
Assert.Equal(fatherId, survivor.GerbilId); // loose id preserved even though the gerbil row was deleted/recreated
|
||||||
|
Assert.Equal(litterId, survivor.LitterId);
|
||||||
|
Assert.Equal("Papa", survivor.EntityName);
|
||||||
|
|
||||||
|
// The contact-scoped report also survives the contacts wipe (loose ContactId).
|
||||||
|
var contactSurvivor = await db.Feedback.SingleAsync(f => f.Id == contactFeedbackId);
|
||||||
|
Assert.Equal(contactId, contactSurvivor.ContactId);
|
||||||
|
Assert.Equal("contact-detail", contactSurvivor.Context);
|
||||||
|
Assert.Equal("Test Breeder", contactSurvivor.EntityName);
|
||||||
|
Assert.Equal(2, await db.Feedback.CountAsync());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, recursive: true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object Animal(Guid id, string name, string gender, Guid contactId) => new
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Gender = gender,
|
||||||
|
Status = "Breeding",
|
||||||
|
LitterId = (Guid?)null,
|
||||||
|
OriginContactId = contactId,
|
||||||
|
ReceiverContactId = (Guid?)null,
|
||||||
|
EnclosureId = (Guid?)null,
|
||||||
|
ColorVarietyId = new Guid("00000000-0000-0000-0000-000000000006"),
|
||||||
|
DateOfBirth = "2025-01-01",
|
||||||
|
DateOfDeath = (string?)null,
|
||||||
|
CauseOfDeath = (string?)null,
|
||||||
|
GoHomeDate = (string?)null,
|
||||||
|
Genotype = "aa CC DD EE GG PP spsp rere",
|
||||||
|
Notes = "",
|
||||||
|
ImportSource = "docx-export",
|
||||||
|
ExternalRef = "ext-" + name,
|
||||||
|
RawImportData = "{}",
|
||||||
|
OriginBreeder = "Test Zucht",
|
||||||
|
NameSearch = name.ToLowerInvariant(),
|
||||||
|
CharacterTraits = Array.Empty<string>(),
|
||||||
|
CharacterNote = (string?)null,
|
||||||
|
IsDeaf = false,
|
||||||
|
IsResident = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ namespace GerbilManager.Tests
|
|||||||
Email = "test@example.com",
|
Email = "test@example.com",
|
||||||
Homepage = "",
|
Homepage = "",
|
||||||
Phone = "",
|
Phone = "",
|
||||||
Address = ""
|
Address = "",
|
||||||
|
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum\"],\"mergedRecordCount\":1,\"notes\":[\"als Züchter erkannt\"]}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Litters = new[]
|
Litters = new[]
|
||||||
@@ -54,7 +55,8 @@ namespace GerbilManager.Tests
|
|||||||
Notes = "Test litter notes",
|
Notes = "Test litter notes",
|
||||||
PairingCode = "PC01",
|
PairingCode = "PC01",
|
||||||
ExternalRef = "ext-litter-1",
|
ExternalRef = "ext-litter-1",
|
||||||
LitterLetter = "A"
|
LitterLetter = "A",
|
||||||
|
Provenance = (string?)"{\"sourceFiles\":[\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":1,\"fromWurfchronik\":true,\"notes\":[\"aus Wurfchronik\"]}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Gerbils = new[]
|
Gerbils = new[]
|
||||||
@@ -84,7 +86,8 @@ namespace GerbilManager.Tests
|
|||||||
CharacterTraits = new string[] {},
|
CharacterTraits = new string[] {},
|
||||||
CharacterNote = (string?)null,
|
CharacterNote = (string?)null,
|
||||||
IsDeaf = false,
|
IsDeaf = false,
|
||||||
IsResident = true
|
IsResident = true,
|
||||||
|
Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
|
||||||
},
|
},
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
@@ -111,7 +114,8 @@ namespace GerbilManager.Tests
|
|||||||
CharacterTraits = new string[] {},
|
CharacterTraits = new string[] {},
|
||||||
CharacterNote = (string?)null,
|
CharacterNote = (string?)null,
|
||||||
IsDeaf = false,
|
IsDeaf = false,
|
||||||
IsResident = true
|
IsResident = true,
|
||||||
|
Provenance = (string?)null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
GerbilPhotos = new[]
|
GerbilPhotos = new[]
|
||||||
@@ -176,6 +180,25 @@ namespace GerbilManager.Tests
|
|||||||
Assert.Equal(mother.Id, litter.MotherId);
|
Assert.Equal(mother.Id, litter.MotherId);
|
||||||
Assert.Equal(father.Id, photo.GerbilId);
|
Assert.Equal(father.Id, photo.GerbilId);
|
||||||
Assert.Equal(father.OriginContactId, mother.OriginContactId);
|
Assert.Equal(father.OriginContactId, mother.OriginContactId);
|
||||||
|
|
||||||
|
// Provenance round-trips verbatim through ingest (and stays null when absent).
|
||||||
|
Assert.NotNull(father.Provenance);
|
||||||
|
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
|
||||||
|
Assert.Contains("mergedRecordCount", father.Provenance);
|
||||||
|
// The chronological, file-attributed history survives ingest verbatim
|
||||||
|
// (stored as opaque JSON on the text column — no schema for it).
|
||||||
|
Assert.Contains("history", father.Provenance);
|
||||||
|
Assert.Contains("Genotyp aus", father.Provenance);
|
||||||
|
Assert.Null(mother.Provenance);
|
||||||
|
|
||||||
|
// Contact + litter provenance also round-trips through the ingest.
|
||||||
|
var contact = await db.Contacts.SingleAsync();
|
||||||
|
Assert.NotNull(contact.Provenance);
|
||||||
|
Assert.Contains("als Züchter erkannt", contact.Provenance);
|
||||||
|
|
||||||
|
Assert.NotNull(litter.Provenance);
|
||||||
|
Assert.Contains("aus Wurfchronik", litter.Provenance);
|
||||||
|
Assert.Contains("fromWurfchronik", litter.Provenance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class ApplicationContext : DbContext
|
|||||||
public DbSet<Media> Media => Set<Media>();
|
public DbSet<Media> Media => Set<Media>();
|
||||||
public DbSet<Request> Requests => Set<Request>();
|
public DbSet<Request> Requests => Set<Request>();
|
||||||
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
public DbSet<MailSettings> MailSettings => Set<MailSettings>();
|
||||||
|
public DbSet<Feedback> Feedback => Set<Feedback>();
|
||||||
|
|
||||||
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
|
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
|
||||||
// so it can never drift from Name regardless of which code path mutates the entity.
|
// so it can never drift from Name regardless of which code path mutates the entity.
|
||||||
@@ -202,6 +203,15 @@ public class ApplicationContext : DbContext
|
|||||||
modelBuilder.Entity<MailSettings>()
|
modelBuilder.Entity<MailSettings>()
|
||||||
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
.HasData(new MailSettings { Id = GerbilManagerWebAPI.Models.MailSettings.SingletonId });
|
||||||
|
|
||||||
|
// FEEDBACK: deliberately relationship-free. GerbilId/LitterId are plain nullable
|
||||||
|
// Guid columns (no navigation properties → EF creates NO foreign key), so the
|
||||||
|
// import re-ingest wipe of Gerbils/Litters never cascades into — or breaks —
|
||||||
|
// feedback rows. They survive re-ingest, which is the whole point.
|
||||||
|
modelBuilder.Entity<Feedback>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(f => f.CreatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
// DB-4: German collation on remaining searched/sorted text columns (Npgsql-only).
|
||||||
if (isNpgsql)
|
if (isNpgsql)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
bool? IsDeaf,
|
bool? IsDeaf,
|
||||||
bool IsResident,
|
bool IsResident,
|
||||||
string? ProfilePhotoUrl,
|
string? ProfilePhotoUrl,
|
||||||
bool IsCastrated);
|
bool IsCastrated,
|
||||||
|
string? Provenance);
|
||||||
|
|
||||||
public record LitterDto(
|
public record LitterDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -43,9 +44,10 @@ namespace GerbilManagerWebAPI.Dtos
|
|||||||
Guid? FatherId,
|
Guid? FatherId,
|
||||||
Guid? MotherId,
|
Guid? MotherId,
|
||||||
DateOnly? ExpectedGoHomeDate,
|
DateOnly? ExpectedGoHomeDate,
|
||||||
string? Notes);
|
string? Notes,
|
||||||
|
string? Provenance);
|
||||||
|
|
||||||
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix);
|
public record ContactDto(Guid Id, string Name, string? Email, string? Phone, string? Address, string? Notes, bool IsBreeder, bool IsReceiver, string? NameSuffix, string? Provenance);
|
||||||
|
|
||||||
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
public record EnclosureDto(Guid Id, string Name, string? Notes);
|
||||||
|
|
||||||
|
|||||||
27
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
27
GerbilManagerWebAPI/Dtos/FeedbackDtos.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace GerbilManagerWebAPI.Dtos
|
||||||
|
{
|
||||||
|
/// <summary>FEEDBACK: payload for POST /feedback (the "Fehler melden" dialog).</summary>
|
||||||
|
public record FeedbackInput(
|
||||||
|
string Message,
|
||||||
|
string Context,
|
||||||
|
Guid? GerbilId,
|
||||||
|
Guid? LitterId,
|
||||||
|
Guid? ContactId,
|
||||||
|
string? EntityName,
|
||||||
|
string? Url,
|
||||||
|
DateTimeOffset? ClientTimestamp);
|
||||||
|
|
||||||
|
/// <summary>FEEDBACK: response DTO for a stored report.</summary>
|
||||||
|
public record FeedbackDto(
|
||||||
|
Guid Id,
|
||||||
|
string Message,
|
||||||
|
string Context,
|
||||||
|
Guid? GerbilId,
|
||||||
|
Guid? LitterId,
|
||||||
|
Guid? ContactId,
|
||||||
|
string? EntityName,
|
||||||
|
string? Url,
|
||||||
|
DateTimeOffset? ClientTimestamp,
|
||||||
|
string? UserAgent,
|
||||||
|
DateTimeOffset CreatedAt);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal file
63
GerbilManagerWebAPI/Endpoints/FeedbackEndpoints.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using GerbilManagerWebAPI.Dtos;
|
||||||
|
using GerbilManagerWebAPI.Models;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEEDBACK: the "Fehler melden" report sink.
|
||||||
|
/// POST /feedback -> persist a user bug report (with captured debug context), returns 201.
|
||||||
|
/// GET /feedback -> list reports, newest first (for later review).
|
||||||
|
/// Feedback is decoupled from gerbils/litters (loose nullable Guid columns, no FK), so
|
||||||
|
/// rows survive the import re-ingest wipe.
|
||||||
|
/// </summary>
|
||||||
|
public static class FeedbackEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapFeedbackEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/feedback").WithTags("Feedback");
|
||||||
|
|
||||||
|
group.MapPost("/", async Task<Results<Created<FeedbackDto>, BadRequest<string>>> (
|
||||||
|
FeedbackInput input, ApplicationContext db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input.Message))
|
||||||
|
return TypedResults.BadRequest("Message darf nicht leer sein.");
|
||||||
|
|
||||||
|
var entity = new Feedback
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Message = input.Message.Trim(),
|
||||||
|
Context = string.IsNullOrWhiteSpace(input.Context) ? "unknown" : input.Context.Trim(),
|
||||||
|
GerbilId = input.GerbilId,
|
||||||
|
LitterId = input.LitterId,
|
||||||
|
ContactId = input.ContactId,
|
||||||
|
EntityName = input.EntityName,
|
||||||
|
Url = input.Url,
|
||||||
|
ClientTimestamp = input.ClientTimestamp,
|
||||||
|
UserAgent = http.Request.Headers.UserAgent.ToString() is { Length: > 0 } ua ? ua : null,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.Feedback.Add(entity);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return TypedResults.Created($"/feedback/{entity.Id}", ToDto(entity));
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/", async (ApplicationContext db) =>
|
||||||
|
{
|
||||||
|
// Order in memory: SQLite (test host) cannot ORDER BY a DateTimeOffset column.
|
||||||
|
var rows = await db.Feedback.AsNoTracking().ToListAsync();
|
||||||
|
return TypedResults.Ok(rows
|
||||||
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
|
.Select(ToDto)
|
||||||
|
.ToList());
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FeedbackDto ToDto(Feedback f) =>
|
||||||
|
new(f.Id, f.Message, f.Context, f.GerbilId, f.LitterId, f.ContactId, f.EntityName, f.Url,
|
||||||
|
f.ClientTimestamp, f.UserAgent, f.CreatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -137,6 +137,7 @@ namespace GerbilManagerWebAPI.Endpoints
|
|||||||
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
|
||||||
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
|
||||||
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
g.GoHomeDate, g.Genotype, g.SpottingType, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
|
||||||
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated);
|
g.CharacterTraits, g.CharacterNote, g.IsDeaf, g.IsResident, profilePhotoUrl, g.IsCastrated,
|
||||||
|
g.Provenance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -160,6 +163,7 @@ namespace GerbilManagerWebAPI.Import
|
|||||||
ImportSource = g.ImportSource,
|
ImportSource = g.ImportSource,
|
||||||
ExternalRef = g.ExternalRef,
|
ExternalRef = g.ExternalRef,
|
||||||
RawImportData = g.RawImportData,
|
RawImportData = g.RawImportData,
|
||||||
|
Provenance = g.Provenance,
|
||||||
OriginBreeder = g.OriginBreeder,
|
OriginBreeder = g.OriginBreeder,
|
||||||
NameSearch = g.NameSearch,
|
NameSearch = g.NameSearch,
|
||||||
CharacterTraits = g.CharacterTraits,
|
CharacterTraits = g.CharacterTraits,
|
||||||
|
|||||||
1536
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs
generated
Normal file
1536
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs
Normal file
47
GerbilManagerWebAPI/Migrations/20260622131928_AddFeedback.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFeedback : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Feedback",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Message = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Context = table.Column<string>(type: "text", nullable: false),
|
||||||
|
GerbilId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
LitterId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
EntityName = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Url = table.Column<string>(type: "text", nullable: true),
|
||||||
|
ClientTimestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
UserAgent = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Feedback", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Feedback_CreatedAt",
|
||||||
|
table: "Feedback",
|
||||||
|
column: "CreatedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Feedback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1539
GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.Designer.cs
generated
Normal file
1539
GerbilManagerWebAPI/Migrations/20260622133758_AddGerbilProvenance.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGerbilProvenance : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Provenance",
|
||||||
|
table: "Gerbils",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Provenance",
|
||||||
|
table: "Gerbils");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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");
|
||||||
@@ -793,6 +796,51 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.ToTable("EnclosurePhotos");
|
b.ToTable("EnclosurePhotos");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.Feedback", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ClientTimestamp")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ContactId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Context")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("EntityName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("GerbilId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("LitterId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Url")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserAgent")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("Feedback");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -870,6 +918,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<Guid?>("OriginContactId")
|
b.Property<Guid?>("OriginContactId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Provenance")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("RawImportData")
|
b.Property<string>("RawImportData")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -1001,6 +1052,9 @@ namespace GerbilManagerWebAPI.Migrations
|
|||||||
b.Property<string>("PairingCode")
|
b.Property<string>("PairingCode")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Provenance")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<int?>("TotalBorn")
|
b.Property<int?>("TotalBorn")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
GerbilManagerWebAPI/Models/Feedback.cs
Normal file
47
GerbilManagerWebAPI/Models/Feedback.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEEDBACK: a user-submitted "Fehler melden" report. Decoupled from the rest of the
|
||||||
|
/// model on purpose — GerbilId/LitterId are plain nullable Guid columns (NOT enforced
|
||||||
|
/// foreign keys), so the import re-ingest wipe (IngestResolvedService) can delete
|
||||||
|
/// gerbils/litters without deleting or breaking feedback rows. The captured EntityName
|
||||||
|
/// keeps the report human-readable even after the referenced animal is gone.
|
||||||
|
/// </summary>
|
||||||
|
public class Feedback
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The user's free-text description of the problem.</summary>
|
||||||
|
public required string Message { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Which view the report came from: stammbaum | gerbil-detail | litter-detail.</summary>
|
||||||
|
public required string Context { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Loose reference (no FK) to the gerbil the report is about, if any.</summary>
|
||||||
|
public Guid? GerbilId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Loose reference (no FK) to the litter the report is about, if any.</summary>
|
||||||
|
public Guid? LitterId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Loose reference (no FK) to the contact the report is about, if any.</summary>
|
||||||
|
public Guid? ContactId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Captured name of the referenced animal/litter (survives an ingest wipe).</summary>
|
||||||
|
public string? EntityName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The client URL/route the report was filed from.</summary>
|
||||||
|
public string? Url { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Client-supplied timestamp (when the user submitted, in their browser).</summary>
|
||||||
|
public DateTimeOffset? ClientTimestamp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional browser user-agent for diagnostics.</summary>
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Server-side creation time.</summary>
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,13 @@ namespace GerbilManagerWebAPI.Models
|
|||||||
public string? ImportSource { get; set; }
|
public string? ImportSource { get; set; }
|
||||||
public string? ExternalRef { get; set; }
|
public string? ExternalRef { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Data-provenance / traceability for the import: a JSON object describing
|
||||||
|
/// WHICH source information produced this entry (sourceFiles, mergedRecordCount,
|
||||||
|
/// fromWurfchronik, parentMethod/parentConfidence, notes). Written by the Python
|
||||||
|
/// merge_and_resolve step and surfaced read-only in the Rennmausakte
|
||||||
|
/// ("Nachverfolgungsinformationen"). Null = manually-added animal / no import data.</summary>
|
||||||
|
public string? Provenance { get; set; }
|
||||||
|
|
||||||
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
|
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
|
||||||
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
|
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
|
||||||
public string? RawImportData { get; set; }
|
public string? RawImportData { get; set; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ app.MapExportEndpoints();
|
|||||||
app.MapCmsEndpoints();
|
app.MapCmsEndpoints();
|
||||||
app.MapRequestEndpoints();
|
app.MapRequestEndpoints();
|
||||||
app.MapNamesEndpoints();
|
app.MapNamesEndpoints();
|
||||||
|
app.MapFeedbackEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|||||||
132
gerbil-manager-web/e2e/feedback.spec.ts
Normal file
132
gerbil-manager-web/e2e/feedback.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/** FEEDBACK: "Fehler melden" Dialog + Stammbaum-Rechtsklick ("ID kopieren" / "Fehler melden"). */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const f = de.feedback
|
||||||
|
const st = de.pages.stammbaum
|
||||||
|
|
||||||
|
test('Tierakte: "Fehler melden"-Button öffnet den Dialog, Senden zeigt Erfolg', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
|
||||||
|
// Button in den Aktionen öffnet den Dialog.
|
||||||
|
await page.getByRole('button', { name: f.button }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// Debug-Kontext ist sichtbar (automatisch erfasst).
|
||||||
|
await expect(dialog).toContainText(f.debugTitle)
|
||||||
|
await expect(dialog).toContainText(f.contexts['gerbil-detail'])
|
||||||
|
|
||||||
|
// Beschreibung eintragen + senden.
|
||||||
|
await dialog.getByLabel(f.label).fill('Der Farbschlag stimmt nicht.')
|
||||||
|
await dialog.getByRole('button', { name: f.submit }).click()
|
||||||
|
|
||||||
|
// Erfolgs-Toast + Dialog schließt.
|
||||||
|
await expect(page.getByText(f.success)).toBeVisible()
|
||||||
|
await expect(dialog).not.toBeVisible()
|
||||||
|
|
||||||
|
// Der Bericht wurde mit Debug-Kontext gespeichert.
|
||||||
|
expect(mockDb).not.toBeNull()
|
||||||
|
const reports = mockDb!.feedback
|
||||||
|
expect(reports.length).toBe(1)
|
||||||
|
expect(reports[0]).toMatchObject({
|
||||||
|
message: 'Der Farbschlag stimmt nicht.',
|
||||||
|
context: 'gerbil-detail',
|
||||||
|
gerbilId: 'kruemel',
|
||||||
|
})
|
||||||
|
expect(reports[0].url).toContain('/rennmaeuse/kruemel')
|
||||||
|
expect(typeof reports[0].clientTimestamp).toBe('string')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Wurf-Ansicht: "Fehler melden"-Button öffnet den Dialog und sendet mit Wurf-Kontext', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: f.button }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog).toContainText(f.contexts['litter-detail'])
|
||||||
|
|
||||||
|
await dialog.getByLabel(f.label).fill('Die Wurfstärke ist falsch.')
|
||||||
|
await dialog.getByRole('button', { name: f.submit }).click()
|
||||||
|
await expect(page.getByText(f.success)).toBeVisible()
|
||||||
|
|
||||||
|
const reports = mockDb!.feedback
|
||||||
|
expect(reports.length).toBe(1)
|
||||||
|
expect(reports[0]).toMatchObject({ context: 'litter-detail', litterId: 'w-kruemel' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Kontakt-Detail: "Fehler melden"-Button sendet mit Kontakt-Kontext', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/kontakte/con-meier')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: f.button }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog).toContainText(f.contexts['contact-detail'])
|
||||||
|
|
||||||
|
await dialog.getByLabel(f.label).fill('Die Adresse stimmt nicht.')
|
||||||
|
await dialog.getByRole('button', { name: f.submit }).click()
|
||||||
|
await expect(page.getByText(f.success)).toBeVisible()
|
||||||
|
|
||||||
|
const reports = mockDb!.feedback
|
||||||
|
expect(reports.length).toBe(1)
|
||||||
|
expect(reports[0]).toMatchObject({ context: 'contact-detail', contactId: 'con-meier' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Stammbaum: Rechtsklick auf eine Karte zeigt "ID kopieren" + "Fehler melden"', async ({ page, mockDb }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
// Rechtsklick auf die Wurzelkarte (Krümel).
|
||||||
|
await page.locator('.pedigree-card--root').click({ button: 'right' })
|
||||||
|
|
||||||
|
const menu = page.locator('.stammbaum-context-menu')
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await expect(menu.getByRole('menuitem', { name: st.contextMenu.copyId })).toBeVisible()
|
||||||
|
await expect(menu.getByRole('menuitem', { name: st.contextMenu.reportError })).toBeVisible()
|
||||||
|
|
||||||
|
// "Fehler melden" öffnet den Dialog mit Stammbaum-Kontext.
|
||||||
|
await menu.getByRole('menuitem', { name: st.contextMenu.reportError }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: f.dialogTitle })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog).toContainText(f.contexts.stammbaum)
|
||||||
|
|
||||||
|
await dialog.getByLabel(f.label).fill('Stammbaum-Bug.')
|
||||||
|
await dialog.getByRole('button', { name: f.submit }).click()
|
||||||
|
await expect(page.getByText(f.success)).toBeVisible()
|
||||||
|
|
||||||
|
const reports = mockDb!.feedback
|
||||||
|
expect(reports.length).toBe(1)
|
||||||
|
expect(reports[0]).toMatchObject({ context: 'stammbaum', gerbilId: 'kruemel' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Stammbaum: langer Druck (Long-Press) öffnet dasselbe Kontextmenü', async ({ page }, testInfo) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Der Long-Press-Code-Pfad (onTouchStart → Timer → Menü) ist projekt-unabhängig
|
||||||
|
// identisch; im Touch-emulierten phone-Projekt ist das Auslösen eines
|
||||||
|
// synthetischen TouchEvents unzuverlässig, daher hier auf das desktop-Projekt
|
||||||
|
// beschränkt (validiert dieselbe Logik).
|
||||||
|
testInfo.skip(testInfo.project.name === 'phone', 'synthetischer Long-Press im phone-Projekt instabil')
|
||||||
|
await page.goto('/rennmaeuse/kruemel/stammbaum')
|
||||||
|
const card = page.locator('.pedigree-card--root')
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
|
||||||
|
// Long-Press simulieren: echtes touchstart (mit Touch-Objekt) auslösen; der
|
||||||
|
// Timer im PedigreeCard öffnet nach ~500 ms das Menü.
|
||||||
|
const dispatched = await card.evaluate((el) => {
|
||||||
|
if (typeof Touch === 'undefined' || typeof TouchEvent === 'undefined') return false
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
const t = new Touch({ identifier: 1, target: el, clientX: r.left + 10, clientY: r.top + 10 })
|
||||||
|
el.dispatchEvent(
|
||||||
|
new TouchEvent('touchstart', { bubbles: true, cancelable: true, touches: [t], targetTouches: [t], changedTouches: [t] }),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
test.skip(!dispatched, 'TouchEvent in diesem Projekt nicht verfügbar')
|
||||||
|
await expect(page.locator('.stammbaum-context-menu')).toBeVisible({ timeout: 2000 })
|
||||||
|
await expect(
|
||||||
|
page.locator('.stammbaum-context-menu').getByRole('menuitem', { name: st.contextMenu.copyId }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
@@ -397,6 +397,25 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
return json(route, 200, result)
|
return json(route, 200, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FEEDBACK: "Fehler melden" — POST persistiert, GET listet (neueste zuerst).
|
||||||
|
if (path === '/feedback') {
|
||||||
|
if (method === 'POST') {
|
||||||
|
const body = request.postDataJSON() as Row
|
||||||
|
const created = {
|
||||||
|
id: newId('feedback'),
|
||||||
|
...body,
|
||||||
|
userAgent: request.headers()['user-agent'] ?? null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
db.feedback.push(created)
|
||||||
|
return json(route, 201, created)
|
||||||
|
}
|
||||||
|
if (method === 'GET') {
|
||||||
|
return json(route, 200, [...db.feedback].reverse())
|
||||||
|
}
|
||||||
|
return json(route, 405)
|
||||||
|
}
|
||||||
|
|
||||||
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
// Generische Kollektionen: /<resource> und /<resource>/<id>
|
||||||
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
|
||||||
const col = m ? collections[m[1]] : undefined
|
const col = m ? collections[m[1]] : undefined
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export interface MockDb {
|
|||||||
saleAdConfigured: boolean
|
saleAdConfigured: boolean
|
||||||
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
// FEAT-NAMEGEN: Namensvorschläge — false = 503 NamesKeyMissing simulieren
|
||||||
namesConfigured: boolean
|
namesConfigured: boolean
|
||||||
|
// FEEDBACK: "Fehler melden" — gesammelte Berichte (POST /feedback)
|
||||||
|
feedback: Record<string, unknown>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function gerbil(
|
function gerbil(
|
||||||
@@ -120,6 +122,23 @@ export function seedDb(): MockDb {
|
|||||||
enclosureId: 'enc-gross',
|
enclosureId: 'enc-gross',
|
||||||
originContactId: 'con-meier',
|
originContactId: 'con-meier',
|
||||||
originBreeder: 'Clan-Kleine-Chaoten',
|
originBreeder: 'Clan-Kleine-Chaoten',
|
||||||
|
// NACHVERFOLGUNG: Datenherkunft (vom Import erzeugter JSON-String).
|
||||||
|
provenance: JSON.stringify({
|
||||||
|
sourceFiles: ['Stammbaum von Krümel.xlsx', 'Wurfchronik-Detail.docx'],
|
||||||
|
mergedRecordCount: 2,
|
||||||
|
fromWurfchronik: true,
|
||||||
|
parentMethod: 'chart-position',
|
||||||
|
parentConfidence: 'medium',
|
||||||
|
notes: ['aus 2 Datensätzen zusammengeführt'],
|
||||||
|
history: [
|
||||||
|
'In „Stammbaum von Krümel.xlsx“ gefunden.',
|
||||||
|
'Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.',
|
||||||
|
'Genotyp aus „Stammbaum von Krümel.xlsx“.',
|
||||||
|
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
|
||||||
|
'Eltern über Position im Stammbaum erkannt (Quelle: „Stammbaum von Krümel.xlsx“).',
|
||||||
|
'Angaben aus der Wurfchronik übernommen.',
|
||||||
|
],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
|
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
|
||||||
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
|
gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'),
|
||||||
@@ -171,10 +190,34 @@ export function seedDb(): MockDb {
|
|||||||
},
|
},
|
||||||
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
|
// UI-POLISH-1: Import-Stub ohne Namen — testet den '(ohne Namen)'-Platzhalter in Liste + Detail.
|
||||||
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
|
gerbil('nameless-stub', '', 'male', '2023-01-01', null, null),
|
||||||
|
// SIBLING-PAIRING: Vollgeschwister-Verpaarung (in der Zucht häufig). Beide
|
||||||
|
// Eltern von 'inzucht-kind' stammen aus DEMSELBEN Wurf 'w-zwillinge' → das
|
||||||
|
// Diagramm führt ihren Vorfahren-Ast zu einem Verweis-Knoten zusammen.
|
||||||
|
gerbil('zwilling-bock', 'Zwilling Bock', 'male', '2020-05-01', 'w-zwillinge', 'cv-agouti'),
|
||||||
|
gerbil('zwilling-maus', 'Zwilling Maus', 'female', '2020-05-01', 'w-zwillinge', 'cv-schwarz'),
|
||||||
|
{ ...gerbil('opa-w', 'Opa W', 'male', '2018-01-01', null, 'cv-agouti'), isResident: false },
|
||||||
|
{ ...gerbil('oma-u', 'Oma U', 'female', '2018-02-01', null, 'cv-gold'), isResident: false },
|
||||||
|
gerbil('inzucht-kind', 'Inzucht Kind', 'female', '2021-06-01', 'w-inzucht', 'cv-agouti'),
|
||||||
]
|
]
|
||||||
|
|
||||||
const litters: Litter[] = [
|
const litters: Litter[] = [
|
||||||
{ id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1 },
|
{ id: 'w-zwillinge', name: 'Wurf Z', date: '2020-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'opa-w', motherId: 'oma-u' },
|
||||||
|
{ id: 'w-inzucht', name: 'Wurf I', date: '2021-06-01', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'zwilling-bock', motherId: 'zwilling-maus' },
|
||||||
|
{
|
||||||
|
id: 'w-kruemel', name: 'Wurf K', date: '2025-03-12', totalBorn: 5, expectedGoHomeDate: '2025-04-16', notes: null, fatherId: 'fridolin', motherId: 'luna', deathsWithin8Weeks: 1,
|
||||||
|
// NACHVERFOLGUNG: Datenherkunft des Wurfs (vom Import erzeugter JSON-String).
|
||||||
|
provenance: JSON.stringify({
|
||||||
|
sourceFiles: ['Wurfchronik Teil 1_page_0009.md', 'Wurfchronik Teil 1_page_0028.md'],
|
||||||
|
mergedRecordCount: 2,
|
||||||
|
fromWurfchronik: true,
|
||||||
|
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
|
||||||
|
history: [
|
||||||
|
'Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.',
|
||||||
|
'Auch in „Wurfchronik Teil 1_page_0028.md“ gefunden → Datensätze zusammengeführt.',
|
||||||
|
'Geschwister-Würfe zusammengeführt.',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
|
||||||
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
{ id: 'w-luna', name: 'Wurf L', date: '2023-08-15', totalBorn: 6, expectedGoHomeDate: null, notes: null, fatherId: 'karlsson', motherId: 'smilla' },
|
||||||
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
{ id: 'w-balu', name: 'Wurf B', date: '2021-04-20', totalBorn: 3, expectedGoHomeDate: null, notes: null, fatherId: 'anton', motherId: 'greta' },
|
||||||
@@ -191,7 +234,21 @@ export function seedDb(): MockDb {
|
|||||||
|
|
||||||
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
// FEAT-13: contactInfo (Freitext) wurde durch strukturierte Felder ersetzt.
|
||||||
const contacts: Contact[] = [
|
const contacts: Contact[] = [
|
||||||
{ id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true },
|
{
|
||||||
|
id: 'con-meier', name: 'Zoohandlung Meier', email: 'meier@example.de', phone: null, address: 'Hauptstraße 1, 12345 Musterstadt', notes: null, isBreeder: true, isReceiver: true,
|
||||||
|
// NACHVERFOLGUNG: Datenherkunft des Kontakts (vom Import erzeugter JSON-String).
|
||||||
|
provenance: JSON.stringify({
|
||||||
|
sourceFiles: ['Wurfchronik Teil 1_page_0001.md', 'Stammbaum von Krümel.xlsx'],
|
||||||
|
mergedRecordCount: 2,
|
||||||
|
fromWurfchronik: true,
|
||||||
|
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
|
||||||
|
history: [
|
||||||
|
'In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.',
|
||||||
|
'Auch in „Wurfchronik Teil 1_page_0001.md“ gefunden → Datensätze zusammengeführt.',
|
||||||
|
'Sowohl als Züchter als auch als Abnehmer geführt.',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
|
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
|
||||||
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
|
{ id: 'con-frei', name: 'Züchterin Frei', email: null, phone: null, address: null, notes: 'unverknüpft', isBreeder: true, isReceiver: false },
|
||||||
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
|
{ id: 'con-neither', name: 'Weder Noch', email: null, phone: null, address: null, notes: 'weder züchter noch abnehmer', isBreeder: false, isReceiver: false },
|
||||||
@@ -314,5 +371,6 @@ export function seedDb(): MockDb {
|
|||||||
contracts: [],
|
contracts: [],
|
||||||
saleAdConfigured: true,
|
saleAdConfigured: true,
|
||||||
namesConfigured: true,
|
namesConfigured: true,
|
||||||
|
feedback: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
gerbil-manager-web/e2e/provenance.spec.ts
Normal file
101
gerbil-manager-web/e2e/provenance.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/** NACHVERFOLGUNG: "Datenherkunft"-Button in der Tierakte öffnet den
|
||||||
|
* Nachverfolgungs-Dialog mit Quelldateien, Datensatzanzahl und Hinweisen. */
|
||||||
|
import { de, expect, skipUnlessMock, test } from './fixtures'
|
||||||
|
|
||||||
|
const p = de.provenance
|
||||||
|
|
||||||
|
test('Tierakte: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
|
||||||
|
// Button in den Aktionen öffnet den Dialog.
|
||||||
|
await page.getByRole('button', { name: p.button }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// VERLAUF: chronologischer, dateibezogener Verlauf als Primärinhalt.
|
||||||
|
await expect(dialog).toContainText(p.historyTitle)
|
||||||
|
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ gefunden.')
|
||||||
|
await expect(dialog).toContainText('Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.')
|
||||||
|
await expect(dialog).toContainText(
|
||||||
|
'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Quelldateien werden angezeigt.
|
||||||
|
await expect(dialog).toContainText(p.sourceFilesTitle)
|
||||||
|
await expect(dialog).toContainText('Stammbaum von Krümel.xlsx')
|
||||||
|
await expect(dialog).toContainText('Wurfchronik-Detail.docx')
|
||||||
|
|
||||||
|
// Zusammenführungs-Info + Wurfchronik-Hinweis.
|
||||||
|
await expect(dialog).toContainText(p.mergedCount(2))
|
||||||
|
await expect(dialog).toContainText(p.fromWurfchronik)
|
||||||
|
|
||||||
|
// Eltern-Herkunft (übersetzte Methode/Konfidenz).
|
||||||
|
await expect(dialog).toContainText(p.methods['chart-position'])
|
||||||
|
await expect(dialog).toContainText(p.confidences.medium)
|
||||||
|
|
||||||
|
// Hinweis aus dem Import.
|
||||||
|
await expect(dialog).toContainText('aus 2 Datensätzen zusammengeführt')
|
||||||
|
|
||||||
|
// Schließen (Footer-Button — Header-Button trägt dasselbe aria-label).
|
||||||
|
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
|
||||||
|
await expect(dialog).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Tierakte: Tier ohne Importdaten zeigt "Keine Herkunftsdaten"', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// Luna hat keine provenance im Mock → Leer-Zustand.
|
||||||
|
await page.goto('/rennmaeuse/luna')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: p.button }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog).toContainText(p.empty)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Kontakt: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/kontakte/con-meier')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: p.button }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// VERLAUF: dateibezogene Schritte des Kontakts.
|
||||||
|
await expect(dialog).toContainText(p.historyTitle)
|
||||||
|
await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.')
|
||||||
|
|
||||||
|
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
|
||||||
|
await expect(dialog).toContainText(p.sourceFilesTitle)
|
||||||
|
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
|
||||||
|
await expect(dialog).toContainText(p.mergedCount(2))
|
||||||
|
await expect(dialog).toContainText(p.fromWurfchronik)
|
||||||
|
await expect(dialog).toContainText('als Züchter erkannt')
|
||||||
|
await expect(dialog).toContainText('als Abnehmer erkannt')
|
||||||
|
|
||||||
|
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
|
||||||
|
await expect(dialog).not.toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Wurf: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
await page.goto('/wuerfe/w-kruemel')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: p.button }).click()
|
||||||
|
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
// VERLAUF: dateibezogene Schritte des Wurfs.
|
||||||
|
await expect(dialog).toContainText(p.historyTitle)
|
||||||
|
await expect(dialog).toContainText('Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.')
|
||||||
|
|
||||||
|
await expect(dialog).toContainText(p.sourceFilesTitle)
|
||||||
|
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
|
||||||
|
await expect(dialog).toContainText(p.mergedCount(2))
|
||||||
|
await expect(dialog).toContainText(p.fromWurfchronik)
|
||||||
|
await expect(dialog).toContainText('aus Wurfchronik')
|
||||||
|
await expect(dialog).toContainText('Geschwister-Würfe zusammengeführt')
|
||||||
|
|
||||||
|
await dialog.locator('.provenance__actions').getByRole('button', { name: p.close }).click()
|
||||||
|
await expect(dialog).not.toBeVisible()
|
||||||
|
})
|
||||||
@@ -111,6 +111,33 @@ test('+-Knopf ist sichtbar und lädt weitere Vorfahren nach (STAMMBAUM-EXPAND)
|
|||||||
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
await expect(page.getByRole('link', { name: 'Max' })).toBeVisible({ timeout: 8000 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Geschwister-Verpaarung führt den Vorfahren-Ast zu einem Verweis-Knoten zusammen (SIBLING-PAIRING)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// 'Inzucht Kind': Eltern Zwilling Bock (♂) + Zwilling Maus (♀) stammen aus
|
||||||
|
// demselben Wurf 'w-zwillinge' (Opa W × Oma U) → Vollgeschwister.
|
||||||
|
await page.goto('/rennmaeuse/inzucht-kind/stammbaum')
|
||||||
|
await expect(page.locator('.pedigree-card').first()).toBeVisible()
|
||||||
|
|
||||||
|
const fit = page.getByRole('button', { name: t.zoomFit })
|
||||||
|
if (await fit.isVisible()) await fit.click()
|
||||||
|
await page.waitForTimeout(600)
|
||||||
|
|
||||||
|
// Vaterlinie zeigt die echten Großeltern (einmal). Der Vater-Knoten trägt den
|
||||||
|
// Namen als Link (die Verweis-Karte nur als Span — daher Link-Rolle eindeutig).
|
||||||
|
await expect(page.getByRole('link', { name: 'Zwilling Bock' })).toBeVisible()
|
||||||
|
await expect(page.locator('.pedigree-card').filter({ hasText: 'Opa W' })).toBeVisible()
|
||||||
|
await expect(page.locator('.pedigree-card').filter({ hasText: 'Oma U' })).toBeVisible()
|
||||||
|
|
||||||
|
// Mutter-Ast ist zu EINEM Verweis-Knoten zusammengeführt (Verweis auf den Vater).
|
||||||
|
const ref = page.locator('.pedigree-card--sibling')
|
||||||
|
await expect(ref).toBeVisible()
|
||||||
|
await expect(ref).toContainText(t.siblingPairing.refLabel('Zwilling Bock'))
|
||||||
|
await expect(ref).toContainText(t.siblingPairing.refHint)
|
||||||
|
|
||||||
|
// Mini-Legende ergänzt den Geschwister-Hinweis.
|
||||||
|
await expect(page.locator('.stammbaum-hints')).toContainText(t.hintSiblings)
|
||||||
|
})
|
||||||
|
|
||||||
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
|
test('Würfe-Panel zeigt Würfe des Wurzeltiers + Link öffnet Wurf (STAMMBAUM-LITTERS)', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
|
// Fridolin ist Vater von Wurf K (5 Junge) — Panel muss erscheinen.
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ test('Detailseite: Wurf-Feld ist ein Link zur Wurf-Detailseite (WURF-LINK)', asy
|
|||||||
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Wurf K' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Detailseite zeigt Geschwisterverpaarungs-Hinweis nur wenn die Eltern Vollgeschwister sind (SIBLING-PAIRING)', async ({ page }) => {
|
||||||
|
skipUnlessMock()
|
||||||
|
// 'Inzucht Kind': Eltern Zwilling Bock + Zwilling Maus aus demselben Wurf
|
||||||
|
// 'w-zwillinge' → Vollgeschwister → Hinweis sichtbar.
|
||||||
|
await page.goto('/rennmaeuse/inzucht-kind')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Inzucht Kind' })).toBeVisible()
|
||||||
|
await expect(page.locator('.ak-fact--sibling')).toContainText(t.detail.siblingPairing)
|
||||||
|
|
||||||
|
// 'Krümel': Eltern Fridolin × Luna aus verschiedenen Würfen → kein Hinweis.
|
||||||
|
await page.goto('/rennmaeuse/kruemel')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Krümel' })).toBeVisible()
|
||||||
|
await expect(page.locator('.ak-fact--sibling')).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
|
test('Detailseite: Herkunft zeigt originBreeder als Text wenn kein Kontakt (WURF-LINK-Addendum)', async ({ page }) => {
|
||||||
skipUnlessMock()
|
skipUnlessMock()
|
||||||
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link
|
// Fridolin hat originContactId=null + originBreeder='Zoohandlung Meier' → plain text, kein Link
|
||||||
|
|||||||
37
gerbil-manager-web/src/api/feedback.ts
Normal file
37
gerbil-manager-web/src/api/feedback.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/** FEEDBACK: API client for the "Fehler melden" report sink (POST /feedback). */
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
const RESOURCE = '/feedback'
|
||||||
|
|
||||||
|
/** Which view a report was filed from (matches the backend Context contract). */
|
||||||
|
export type FeedbackContext = 'stammbaum' | 'gerbil-detail' | 'litter-detail' | 'contact-detail'
|
||||||
|
|
||||||
|
/** Payload for POST /feedback. Debug fields are captured automatically by the caller. */
|
||||||
|
export interface FeedbackInput {
|
||||||
|
message: string
|
||||||
|
context: FeedbackContext
|
||||||
|
gerbilId?: string | null
|
||||||
|
litterId?: string | null
|
||||||
|
contactId?: string | null
|
||||||
|
entityName?: string | null
|
||||||
|
url?: string | null
|
||||||
|
clientTimestamp?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Feedback {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
context: string
|
||||||
|
gerbilId: string | null
|
||||||
|
litterId: string | null
|
||||||
|
contactId: string | null
|
||||||
|
entityName: string | null
|
||||||
|
url: string | null
|
||||||
|
clientTimestamp: string | null
|
||||||
|
userAgent: string | null
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitFeedback(body: FeedbackInput): Promise<Feedback> {
|
||||||
|
return api.post<Feedback>(RESOURCE, body)
|
||||||
|
}
|
||||||
@@ -62,8 +62,48 @@ export interface Gerbil {
|
|||||||
profilePhotoUrl?: string | null
|
profilePhotoUrl?: string | null
|
||||||
spottingType?: string | null
|
spottingType?: string | null
|
||||||
isCastrated?: boolean
|
isCastrated?: boolean
|
||||||
|
/**
|
||||||
|
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
|
||||||
|
* Python-Merge erzeugt; siehe {@link GerbilProvenance} für die geparste Form).
|
||||||
|
* null = manuell angelegtes Tier / keine Importdaten.
|
||||||
|
*/
|
||||||
|
provenance?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NACHVERFOLGUNG: geparste Datenherkunft eines importierten Tiers (JSON aus
|
||||||
|
* {@link Gerbil.provenance}). Beschreibt, WELCHE Quellinformationen den
|
||||||
|
* Datenbankeintrag erzeugt haben.
|
||||||
|
*/
|
||||||
|
export interface GerbilProvenance {
|
||||||
|
/** Alle Quelldateien (Stammbäume / Wurfchronik), die beigetragen haben. */
|
||||||
|
sourceFiles: string[]
|
||||||
|
/** Anzahl der Rohdatensätze, die zu diesem Tier zusammengeführt wurden. */
|
||||||
|
mergedRecordCount: number
|
||||||
|
/** Hat ein Wurfchronik-Treffer beigetragen? */
|
||||||
|
fromWurfchronik: boolean
|
||||||
|
/** Wie die Eltern abgeleitet wurden (z. B. "chart-position", "decision"). */
|
||||||
|
parentMethod?: string
|
||||||
|
/** Konfidenz der Eltern-Ableitung (z. B. "low" | "medium" | "high"). */
|
||||||
|
parentConfidence?: string
|
||||||
|
/** Menschlich lesbare Herkunftshinweise (deutsch). */
|
||||||
|
notes: string[]
|
||||||
|
/**
|
||||||
|
* Chronologischer, dateibezogener Verlauf (deutsch): liest sich wie ein
|
||||||
|
* Protokoll, WELCHE Quelldatei WELCHE Angabe beigetragen hat (z. B.
|
||||||
|
* „Geburtsdatum (27.03.2022) aus ‚Stammbaum von X.xlsx'."). Primärinhalt des
|
||||||
|
* Datenherkunft-Dialogs.
|
||||||
|
*/
|
||||||
|
history: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NACHVERFOLGUNG: geparste Datenherkunft eines beliebigen importierten Eintrags
|
||||||
|
* (Tier, Kontakt oder Wurf). {@link GerbilProvenance} ist die Tier-Spezialisierung
|
||||||
|
* mit zusätzlichen Eltern-Feldern; Kontakte/Würfe nutzen dieselbe Grundform.
|
||||||
|
*/
|
||||||
|
export type EntityProvenance = GerbilProvenance
|
||||||
|
|
||||||
/** Payload for POST /gerbils. */
|
/** Payload for POST /gerbils. */
|
||||||
export interface CreateGerbil {
|
export interface CreateGerbil {
|
||||||
name: string
|
name: string
|
||||||
@@ -116,6 +156,11 @@ export interface Contact {
|
|||||||
isReceiver: boolean
|
isReceiver: boolean
|
||||||
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
|
/** Namens-Anhängsel dieser Zucht (für Tiere fremder Züchter). */
|
||||||
nameSuffix: string | null
|
nameSuffix: string | null
|
||||||
|
/**
|
||||||
|
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
|
||||||
|
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
|
||||||
|
*/
|
||||||
|
provenance?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Litter {
|
export interface Litter {
|
||||||
@@ -136,6 +181,11 @@ export interface Litter {
|
|||||||
motherId: string | null
|
motherId: string | null
|
||||||
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
|
/** LITTER-MORTALITY: pups that died within the first 8 weeks. */
|
||||||
deathsWithin8Weeks?: number | null
|
deathsWithin8Weeks?: number | null
|
||||||
|
/**
|
||||||
|
* NACHVERFOLGUNG: Datenherkunft des Import-Eintrags als JSON-String (vom
|
||||||
|
* Python-Merge erzeugt; siehe {@link EntityProvenance}). null = manuell angelegt.
|
||||||
|
*/
|
||||||
|
provenance?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /litters. */
|
/** Payload for POST /litters. */
|
||||||
|
|||||||
173
gerbil-manager-web/src/components/ProvenanceDialog.tsx
Normal file
173
gerbil-manager-web/src/components/ProvenanceDialog.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* NACHVERFOLGUNG: "Nachverfolgungsinformationen" — read-only modal showing the
|
||||||
|
* data provenance of an imported gerbil (which source files contributed, how many
|
||||||
|
* raw records were merged, how the parents were derived, and human-readable notes).
|
||||||
|
*
|
||||||
|
* The provenance arrives as a JSON string on the Gerbil DTO (produced by the Python
|
||||||
|
* merge step). We parse it here defensively; anything unparseable / absent is shown
|
||||||
|
* as "Keine Herkunftsdaten vorhanden." Mirrors the ReportErrorDialog modal pattern.
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import type { EntityProvenance } from '../api/types'
|
||||||
|
import './provenanceDialog.css'
|
||||||
|
|
||||||
|
interface ProvenanceDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
/** Raw provenance JSON string from the entity (Gerbil/Contact/Litter; null = no import data). */
|
||||||
|
provenance?: string | null
|
||||||
|
/** Optional entity label (e.g. "dieses Tiers", "dieses Kontakts") for the intro line. */
|
||||||
|
entityLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the JSON provenance string defensively; null on absence / parse error. */
|
||||||
|
function parseProvenance(raw?: string | null): EntityProvenance | null {
|
||||||
|
if (!raw || !raw.trim()) return null
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(raw) as Partial<EntityProvenance>
|
||||||
|
return {
|
||||||
|
sourceFiles: Array.isArray(p.sourceFiles) ? p.sourceFiles : [],
|
||||||
|
mergedRecordCount: typeof p.mergedRecordCount === 'number' ? p.mergedRecordCount : 0,
|
||||||
|
fromWurfchronik: Boolean(p.fromWurfchronik),
|
||||||
|
parentMethod: p.parentMethod,
|
||||||
|
parentConfidence: p.parentConfidence,
|
||||||
|
notes: Array.isArray(p.notes) ? p.notes : [],
|
||||||
|
history: Array.isArray(p.history) ? p.history : [],
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProvenanceDialog({ open, onClose, provenance, entityLabel }: ProvenanceDialogProps) {
|
||||||
|
// Close on Escape (only while mounted).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const t = de.provenance
|
||||||
|
const p = parseProvenance(provenance)
|
||||||
|
const method = p?.parentMethod ? (t.methods[p.parentMethod] ?? p.parentMethod) : null
|
||||||
|
const confidence = p?.parentConfidence
|
||||||
|
? (t.confidences[p.parentConfidence] ?? p.parentConfidence)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="provenance__backdrop" onClick={onClose} role="presentation">
|
||||||
|
<div
|
||||||
|
className="provenance__dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="provenance-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="provenance__header">
|
||||||
|
<h2 id="provenance-title" className="provenance__title">
|
||||||
|
{t.dialogTitle}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="provenance__close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t.close}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!p ? (
|
||||||
|
<p className="provenance__empty">{t.empty}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="provenance__intro">{entityLabel ? t.introFor(entityLabel) : t.intro}</p>
|
||||||
|
|
||||||
|
{p.history.length > 0 && (
|
||||||
|
<section className="provenance__section">
|
||||||
|
<div className="provenance__section-title">{t.historyTitle}</div>
|
||||||
|
<ol className="provenance__history">
|
||||||
|
{p.history.map((step, i) => (
|
||||||
|
<li key={`${i}-${step}`} className="provenance__history-step">
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="provenance__section">
|
||||||
|
<div className="provenance__section-title">
|
||||||
|
{t.mergedTitle}
|
||||||
|
</div>
|
||||||
|
<p className="provenance__merged">{t.mergedCount(p.mergedRecordCount)}</p>
|
||||||
|
{p.fromWurfchronik && (
|
||||||
|
<p className="provenance__wurfchronik">📖 {t.fromWurfchronik}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="provenance__section">
|
||||||
|
<div className="provenance__section-title">
|
||||||
|
{t.sourceFilesTitle} — {t.sourceFilesCount(p.sourceFiles.length)}
|
||||||
|
</div>
|
||||||
|
{p.sourceFiles.length === 0 ? (
|
||||||
|
<p className="provenance__empty">{t.empty}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="provenance__files">
|
||||||
|
{p.sourceFiles.map((f) => (
|
||||||
|
<li key={f} className="provenance__file">
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(method || confidence) && (
|
||||||
|
<section className="provenance__section">
|
||||||
|
<div className="provenance__section-title">{t.parentsTitle}</div>
|
||||||
|
<dl className="provenance__kvlist">
|
||||||
|
{method && (
|
||||||
|
<div className="provenance__kv">
|
||||||
|
<dt>{t.parentMethodLabel}</dt>
|
||||||
|
<dd>{method}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{confidence && (
|
||||||
|
<div className="provenance__kv">
|
||||||
|
<dt>{t.parentConfidenceLabel}</dt>
|
||||||
|
<dd>{confidence}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.notes.length > 0 && (
|
||||||
|
<section className="provenance__section">
|
||||||
|
<div className="provenance__section-title">{t.notesTitle}</div>
|
||||||
|
<ul className="provenance__notes">
|
||||||
|
{p.notes.map((n) => (
|
||||||
|
<li key={n}>{n}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="provenance__actions">
|
||||||
|
<button type="button" className="btn btn--primary" onClick={onClose}>
|
||||||
|
{t.close}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
gerbil-manager-web/src/components/ReportErrorDialog.tsx
Normal file
173
gerbil-manager-web/src/components/ReportErrorDialog.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* FEEDBACK: "Fehler melden" — a small accessible modal with a textarea + submit.
|
||||||
|
*
|
||||||
|
* The caller passes the debug context (which view, the gerbil/litter id + name); the
|
||||||
|
* dialog captures the current URL and a client timestamp itself and POSTs everything
|
||||||
|
* to /feedback. Success/error is surfaced via the existing toast system.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { de } from '../strings/de'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
import { submitFeedback, type FeedbackContext } from '../api/feedback'
|
||||||
|
import { useToast } from './toast'
|
||||||
|
import './reportErrorDialog.css'
|
||||||
|
|
||||||
|
export interface ReportErrorContext {
|
||||||
|
context: FeedbackContext
|
||||||
|
gerbilId?: string | null
|
||||||
|
litterId?: string | null
|
||||||
|
contactId?: string | null
|
||||||
|
entityName?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportErrorDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
context: ReportErrorContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper: mounts the dialog body only while open, so its form state starts
|
||||||
|
* fresh on every open (no reset-in-effect needed).
|
||||||
|
*/
|
||||||
|
export default function ReportErrorDialog({ open, onClose, context }: ReportErrorDialogProps) {
|
||||||
|
if (!open) return null
|
||||||
|
return <ReportErrorDialogBody onClose={onClose} context={context} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportErrorDialogBody({
|
||||||
|
onClose,
|
||||||
|
context,
|
||||||
|
}: {
|
||||||
|
onClose: () => void
|
||||||
|
context: ReportErrorContext
|
||||||
|
}) {
|
||||||
|
const t = de.feedback
|
||||||
|
const toast = useToast()
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
// Focus the textarea once on mount.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setTimeout(() => textareaRef.current?.focus(), 0)
|
||||||
|
return () => window.clearTimeout(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Close on Escape.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const contextLabel = t.contexts[context.context]
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const trimmed = message.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
toast.error(t.emptyMessage)
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await submitFeedback({
|
||||||
|
message: trimmed,
|
||||||
|
context: context.context,
|
||||||
|
gerbilId: context.gerbilId ?? null,
|
||||||
|
litterId: context.litterId ?? null,
|
||||||
|
contactId: context.contactId ?? null,
|
||||||
|
entityName: context.entityName ?? null,
|
||||||
|
url: window.location.href,
|
||||||
|
clientTimestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
toast.success(t.success)
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof ApiError ? err.message : t.error)
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="report-error__backdrop"
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="report-error__dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="report-error-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="report-error__header">
|
||||||
|
<h2 id="report-error-title" className="report-error__title">
|
||||||
|
{t.dialogTitle}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="report-error__close"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t.close}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="report-error__intro">{t.intro}</p>
|
||||||
|
|
||||||
|
<label className="report-error__label" htmlFor="report-error-message">
|
||||||
|
{t.label}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="report-error-message"
|
||||||
|
ref={textareaRef}
|
||||||
|
className="report-error__textarea"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder={t.placeholder}
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="report-error__debug">
|
||||||
|
<div className="report-error__debug-title">{t.debugTitle}</div>
|
||||||
|
<dl className="report-error__debug-list">
|
||||||
|
<div className="report-error__debug-row">
|
||||||
|
<dt>{t.debugFields.context}</dt>
|
||||||
|
<dd>{contextLabel}</dd>
|
||||||
|
</div>
|
||||||
|
{context.entityName && (
|
||||||
|
<div className="report-error__debug-row">
|
||||||
|
<dt>{t.debugFields.entity}</dt>
|
||||||
|
<dd>{context.entityName}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="report-error__debug-row">
|
||||||
|
<dt>{t.debugFields.url}</dt>
|
||||||
|
<dd className="report-error__debug-url">{window.location.href}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-error__actions">
|
||||||
|
<button type="button" className="btn" onClick={onClose} disabled={submitting}>
|
||||||
|
{t.cancel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? t.submitting : t.submit}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
171
gerbil-manager-web/src/components/provenanceDialog.css
Normal file
171
gerbil-manager-web/src/components/provenanceDialog.css
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/* NACHVERFOLGUNG: "Nachverfolgungsinformationen" modal dialog. Mirrors the
|
||||||
|
reportErrorDialog modal pattern. */
|
||||||
|
.provenance__backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(43, 33, 25, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 32rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 12px 40px rgba(43, 33, 25, 0.32);
|
||||||
|
padding: 1.25rem;
|
||||||
|
animation: provenanceIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes provenanceIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted, #666);
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__close:hover {
|
||||||
|
background: var(--color-border, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__intro {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__empty {
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__section {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-2, rgba(0, 0, 0, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__section-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__merged {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__wurfchronik {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__files {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__file {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__kvlist {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__kv {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__kv dt {
|
||||||
|
flex: 0 0 6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__kv dd {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__notes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chronologischer Verlauf: liest sich wie ein Protokoll der Datenherkunft. */
|
||||||
|
.provenance__history {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__history-step {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provenance__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
149
gerbil-manager-web/src/components/reportErrorDialog.css
Normal file
149
gerbil-manager-web/src/components/reportErrorDialog.css
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/* FEEDBACK: "Fehler melden" modal dialog. */
|
||||||
|
.report-error__backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(43, 33, 25, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 32rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 12px 40px rgba(43, 33, 25, 0.32);
|
||||||
|
padding: 1.25rem;
|
||||||
|
animation: reportErrorIn 0.18s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes reportErrorIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted, #666);
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__close:hover {
|
||||||
|
background: var(--color-border, #eee);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__intro {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.6rem 0.7rem;
|
||||||
|
border: 1px solid var(--color-border, #ccc);
|
||||||
|
border-radius: 8px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 5.5rem;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__textarea:focus {
|
||||||
|
outline: 2px solid var(--color-accent, #2563eb);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__debug {
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface-2, rgba(0, 0, 0, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__debug-title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__debug-list {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__debug-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__debug-row dt {
|
||||||
|
flex: 0 0 5.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted, #777);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__debug-row dd {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__debug-url {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-error__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import GerbilHealthTab from '../components/GerbilHealthTab'
|
|||||||
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
import GerbilPhotosTab from '../components/GerbilPhotosTab'
|
||||||
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
import GerbilProfilePhoto from '../components/GerbilProfilePhoto'
|
||||||
import GerbilWeightTab from '../components/GerbilWeightTab'
|
import GerbilWeightTab from '../components/GerbilWeightTab'
|
||||||
|
import ProvenanceDialog from '../components/ProvenanceDialog'
|
||||||
|
import ReportErrorDialog from '../components/ReportErrorDialog'
|
||||||
import { useGerbilName } from '../components/breederSuffix'
|
import { useGerbilName } from '../components/breederSuffix'
|
||||||
import { useToast } from '../components/toast'
|
import { useToast } from '../components/toast'
|
||||||
import './rennmausakte.css'
|
import './rennmausakte.css'
|
||||||
@@ -57,6 +59,8 @@ export default function GerbilDetailPage() {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const [tab, setTab] = useState<DetailTab>('photos')
|
const [tab, setTab] = useState<DetailTab>('photos')
|
||||||
|
const [reportOpen, setReportOpen] = useState(false)
|
||||||
|
const [provenanceOpen, setProvenanceOpen] = useState(false)
|
||||||
|
|
||||||
const gerbil = useApi(() => getGerbil(id), [id])
|
const gerbil = useApi(() => getGerbil(id), [id])
|
||||||
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
|
const forSale = useMutation(() => updateGerbil(id, { status: 'ForSale' }))
|
||||||
@@ -137,6 +141,17 @@ export default function GerbilDetailPage() {
|
|||||||
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
const lookup = (map: Map<string, string>, key: string | null) => (key ? (map.get(key) ?? '—') : '—')
|
||||||
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
const storedColorName = g.colorVarietyId ? (colorName.get(g.colorVarietyId) ?? null) : null
|
||||||
|
|
||||||
|
// Geschwisterverpaarung: Vater und Mutter dieses Tiers sind Vollgeschwister —
|
||||||
|
// erkennbar an gemeinsamer litterId ODER gleichem Geburtsdatum (starkes Indiz:
|
||||||
|
// ein identisches Datum entsteht praktisch nur durch einen gemeinsamen Wurf,
|
||||||
|
// und die Eltern-Würfe sind in den Daten nicht immer verknüpft).
|
||||||
|
const isSiblingPairing =
|
||||||
|
!!father.data &&
|
||||||
|
!!mother.data &&
|
||||||
|
father.data.id !== mother.data.id &&
|
||||||
|
((!!father.data.litterId && father.data.litterId === mother.data.litterId) ||
|
||||||
|
(!!father.data.dateOfBirth && father.data.dateOfBirth === mother.data.dateOfBirth))
|
||||||
|
|
||||||
const parentLink = (
|
const parentLink = (
|
||||||
pid: string | null,
|
pid: string | null,
|
||||||
p: Parameters<typeof gerbilName>[0] | null,
|
p: Parameters<typeof gerbilName>[0] | null,
|
||||||
@@ -204,6 +219,12 @@ export default function GerbilDetailPage() {
|
|||||||
{storedColorName}
|
{storedColorName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isSiblingPairing && (
|
||||||
|
<span className="ak-fact ak-fact--sibling" title={t.detail.siblingPairingTitle}>
|
||||||
|
<span aria-hidden="true">⚭</span>
|
||||||
|
{t.detail.siblingPairing}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ak-actions">
|
<div className="ak-actions">
|
||||||
@@ -239,6 +260,12 @@ export default function GerbilDetailPage() {
|
|||||||
{de.pages.vertraege.wizard.title}
|
{de.pages.vertraege.wizard.title}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<button type="button" className="ak-btn" onClick={() => setProvenanceOpen(true)}>
|
||||||
|
{de.provenance.button}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ak-btn" onClick={() => setReportOpen(true)}>
|
||||||
|
{de.feedback.button}
|
||||||
|
</button>
|
||||||
<Link to="/rennmaeuse" className="ak-btn">
|
<Link to="/rennmaeuse" className="ak-btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -454,6 +481,18 @@ export default function GerbilDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ReportErrorDialog
|
||||||
|
open={reportOpen}
|
||||||
|
onClose={() => setReportOpen(false)}
|
||||||
|
context={{ context: 'gerbil-detail', gerbilId: g.id, entityName: g.name || de.pages.gerbils.nameless }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProvenanceDialog
|
||||||
|
open={provenanceOpen}
|
||||||
|
onClose={() => setProvenanceOpen(false)}
|
||||||
|
provenance={g.provenance}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
|
* - Druck/PDF: separate CSS-Grid-Ahnentafel (.stammbaum-print), per
|
||||||
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
|
* @media print sichtbar; Browser „Als PDF speichern“ ist der Export.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import Tree from 'react-d3-tree'
|
import Tree from 'react-d3-tree'
|
||||||
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
import type { CustomNodeElementProps, Point, RawNodeDatum } from 'react-d3-tree'
|
||||||
@@ -29,6 +29,8 @@ import type { Gender, Gerbil, Litter } from '../api/types'
|
|||||||
import { useApi } from '../hooks/useApi'
|
import { useApi } from '../hooks/useApi'
|
||||||
import { formatDate, genderLabel } from '../format/labels'
|
import { formatDate, genderLabel } from '../format/labels'
|
||||||
import GerbilIcon from '../components/GerbilIcon'
|
import GerbilIcon from '../components/GerbilIcon'
|
||||||
|
import ReportErrorDialog, { type ReportErrorContext } from '../components/ReportErrorDialog'
|
||||||
|
import { useToast } from '../components/toast'
|
||||||
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
import { UNKNOWN_FARBSCHLAG, fromDisplayString, genotypeToFarbschlag, displayGenotypeSafe } from '../genetics'
|
||||||
import {
|
import {
|
||||||
DEFAULT_GENERATIONS,
|
DEFAULT_GENERATIONS,
|
||||||
@@ -90,6 +92,73 @@ export default function StammbaumPage() {
|
|||||||
const t = de.pages.stammbaum
|
const t = de.pages.stammbaum
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
/* ── Rechtsklick-Kontextmenü auf einer Ahnenkarte ── */
|
||||||
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
gerbil: Gerbil
|
||||||
|
} | null>(null)
|
||||||
|
const [reportContext, setReportContext] = useState<ReportErrorContext | null>(null)
|
||||||
|
|
||||||
|
const openContextMenuAt = useCallback((x: number, y: number, gerbil: Gerbil) => {
|
||||||
|
setContextMenu({ x, y, gerbil })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Rechtsklick (Desktop). Auf Touch-Geräten gibt es keinen Rechtsklick — dort
|
||||||
|
// öffnet ein langer Druck (Long-Press) dasselbe Menü, siehe PedigreeCard.
|
||||||
|
const openContextMenu = useCallback(
|
||||||
|
(e: ReactMouseEvent, gerbil: Gerbil) => {
|
||||||
|
e.preventDefault()
|
||||||
|
openContextMenuAt(e.clientX, e.clientY, gerbil)
|
||||||
|
},
|
||||||
|
[openContextMenuAt],
|
||||||
|
)
|
||||||
|
|
||||||
|
const closeContextMenu = useCallback(() => setContextMenu(null), [])
|
||||||
|
|
||||||
|
const copyId = useCallback(
|
||||||
|
async (gerbilId: string) => {
|
||||||
|
closeContextMenu()
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(gerbilId)
|
||||||
|
toast.success(de.feedback.idCopied)
|
||||||
|
} catch {
|
||||||
|
toast.error(de.feedback.idCopyFailed)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[closeContextMenu, toast],
|
||||||
|
)
|
||||||
|
|
||||||
|
const openReport = useCallback(
|
||||||
|
(gerbil: Gerbil) => {
|
||||||
|
closeContextMenu()
|
||||||
|
setReportContext({
|
||||||
|
context: 'stammbaum',
|
||||||
|
gerbilId: gerbil.id,
|
||||||
|
entityName: gerbil.name || de.pages.gerbils.nameless,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[closeContextMenu],
|
||||||
|
)
|
||||||
|
|
||||||
|
/* Menü schließt bei Klick/Scroll/Escape außerhalb. */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
const onAway = () => closeContextMenu()
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeContextMenu()
|
||||||
|
}
|
||||||
|
window.addEventListener('click', onAway)
|
||||||
|
window.addEventListener('scroll', onAway, true)
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('click', onAway)
|
||||||
|
window.removeEventListener('scroll', onAway, true)
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [contextMenu, closeContextMenu])
|
||||||
|
|
||||||
/* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */
|
/* ── Daten: Ahnenbaum (Cache überlebt das Umwurzeln) ── */
|
||||||
const [source] = useState(createApiPedigreeSource)
|
const [source] = useState(createApiPedigreeSource)
|
||||||
@@ -168,6 +237,13 @@ export default function StammbaumPage() {
|
|||||||
/* ── react-d3-tree-Daten ── */
|
/* ── react-d3-tree-Daten ── */
|
||||||
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
|
const nodesByPath = useMemo(() => (root ? collectNodes(root) : null), [root])
|
||||||
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
|
const datum = useMemo(() => (root ? toRawNodeDatum(root, t.unknown) : null), [root, t])
|
||||||
|
const hasSiblingPairing = useMemo(
|
||||||
|
() =>
|
||||||
|
nodesByPath
|
||||||
|
? [...nodesByPath.values()].some((n) => n.kind === 'animal' && n.siblingPairing != null)
|
||||||
|
: false,
|
||||||
|
[nodesByPath],
|
||||||
|
)
|
||||||
|
|
||||||
/* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */
|
/* ── Zeichenfläche vermessen (Erst-Zentrierung + Einpassen) ── */
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||||
@@ -183,16 +259,27 @@ export default function StammbaumPage() {
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [hasRoot])
|
}, [hasRoot])
|
||||||
|
|
||||||
/* ── Ansicht (Zoom/Position): Wurzelkarte links, vertikal zentriert —
|
/* ── Ansicht (Zoom/Position) ──
|
||||||
so sind die Eltern auch auf dem Smartphone sofort sichtbar. ── */
|
Desktop: den gesamten geladenen Baum einpassen, aber nie über 1
|
||||||
|
vergrößern (Karten bleiben lesbar). So sind alle geladenen Generationen
|
||||||
|
sofort sichtbar und die Fläche wird genutzt, statt rechte Generationen
|
||||||
|
abzuschneiden. Mobil: Wurzel links, vertikal zentriert (Pan), damit die
|
||||||
|
Karten auf dem kleinen Schirm nicht winzig werden. ── */
|
||||||
const defaultView = useMemo<View | null>(() => {
|
const defaultView = useMemo<View | null>(() => {
|
||||||
if (!size) return null
|
if (!size) return null
|
||||||
const zoom = size.w < 520 ? 0.8 : 1
|
const isPhone = size.w < 520
|
||||||
|
let zoom = isPhone ? 0.8 : 1
|
||||||
|
if (!isPhone && datum) {
|
||||||
|
const extent = datumExtent(datum)
|
||||||
|
const width = extent.depth * NODE_X + CARD_W + 32
|
||||||
|
const height = extent.leaves * NODE_Y + 32
|
||||||
|
zoom = clamp(Math.min(1, size.w / width, size.h / height), ZOOM_MIN, ZOOM_MAX)
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
zoom,
|
zoom,
|
||||||
translate: { x: (CARD_W / 2) * zoom + 24, y: size.h / 2 },
|
translate: { x: (CARD_W / 2) * zoom + 24, y: size.h / 2 },
|
||||||
}
|
}
|
||||||
}, [size])
|
}, [size, datum])
|
||||||
|
|
||||||
const [viewOverride, setViewOverride] = useState<(View & { forId: string }) | null>(null)
|
const [viewOverride, setViewOverride] = useState<(View & { forId: string }) | null>(null)
|
||||||
const view = viewOverride && viewOverride.forId === id ? viewOverride : defaultView
|
const view = viewOverride && viewOverride.forId === id ? viewOverride : defaultView
|
||||||
@@ -246,6 +333,28 @@ export default function StammbaumPage() {
|
|||||||
const renderNode = useCallback(
|
const renderNode = useCallback(
|
||||||
({ nodeDatum }: CustomNodeElementProps) => {
|
({ nodeDatum }: CustomNodeElementProps) => {
|
||||||
const path = String(nodeDatum.attributes?.path ?? '')
|
const path = String(nodeDatum.attributes?.path ?? '')
|
||||||
|
// Verweis-Knoten der Geschwister-Verpaarung: rein aus den Attributen
|
||||||
|
// gerendert (kein Eintrag in nodesByPath).
|
||||||
|
if (nodeDatum.attributes?.kind === 'sibling-ref') {
|
||||||
|
const refName = String(nodeDatum.attributes.refName ?? '') || de.pages.gerbils.nameless
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<foreignObject width={CARD_W} height={CARD_H} x={-CARD_W / 2} y={-CARD_H / 2}>
|
||||||
|
<div className="pedigree-card pedigree-card--sibling">
|
||||||
|
<span className="pedigree-card--sibling__icon" aria-hidden="true">
|
||||||
|
⟲
|
||||||
|
</span>
|
||||||
|
<div className="pedigree-card--sibling__body">
|
||||||
|
<span className="pedigree-card--sibling__label">
|
||||||
|
{t.siblingPairing.refLabel(refName)}
|
||||||
|
</span>
|
||||||
|
<span className="pedigree-card--sibling__hint">{t.siblingPairing.refHint}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
const node = nodesByPath?.get(path)
|
const node = nodesByPath?.get(path)
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
@@ -259,13 +368,15 @@ export default function StammbaumPage() {
|
|||||||
farbschlag={farbschlagOf(node.gerbil)}
|
farbschlag={farbschlagOf(node.gerbil)}
|
||||||
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
|
onOpen={() => navigate(`/rennmaeuse/${node.gerbil.id}/stammbaum`)}
|
||||||
onExpand={() => handleExpand(path)}
|
onExpand={() => handleExpand(path)}
|
||||||
|
onContextMenu={(e) => openContextMenu(e, node.gerbil)}
|
||||||
|
onLongPress={(x, y) => openContextMenuAt(x, y, node.gerbil)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[nodesByPath, farbschlagOf, navigate, handleExpand, t],
|
[nodesByPath, farbschlagOf, navigate, handleExpand, openContextMenu, openContextMenuAt, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
/* ── Zustände: Laden / Fehler ── */
|
/* ── Zustände: Laden / Fehler ── */
|
||||||
@@ -353,11 +464,49 @@ export default function StammbaumPage() {
|
|||||||
<li>{t.tapHint}</li>
|
<li>{t.tapHint}</li>
|
||||||
<li>{t.hintName}</li>
|
<li>{t.hintName}</li>
|
||||||
<li>{t.hintExpand}</li>
|
<li>{t.hintExpand}</li>
|
||||||
|
<li>{t.hintContext}</li>
|
||||||
|
{hasSiblingPairing && <li>{t.hintSiblings}</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
{/* Druckansicht: am Bildschirm unsichtbar, ersetzt beim Drucken alles. */}
|
||||||
<PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} />
|
<PrintPedigree root={root} farbschlagOf={farbschlagOf} inbreedingText={inbreedingText} />
|
||||||
|
|
||||||
|
{contextMenu && (
|
||||||
|
<ul
|
||||||
|
className="stammbaum-context-menu"
|
||||||
|
role="menu"
|
||||||
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="stammbaum-context-menu__item"
|
||||||
|
onClick={() => copyId(contextMenu.gerbil.id)}
|
||||||
|
>
|
||||||
|
{t.contextMenu.copyId}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="stammbaum-context-menu__item"
|
||||||
|
onClick={() => openReport(contextMenu.gerbil)}
|
||||||
|
>
|
||||||
|
{t.contextMenu.reportError}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReportErrorDialog
|
||||||
|
open={reportContext !== null}
|
||||||
|
onClose={() => setReportContext(null)}
|
||||||
|
context={reportContext ?? { context: 'stammbaum' }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -370,12 +519,16 @@ function PedigreeCard({
|
|||||||
farbschlag,
|
farbschlag,
|
||||||
onOpen,
|
onOpen,
|
||||||
onExpand,
|
onExpand,
|
||||||
|
onContextMenu,
|
||||||
|
onLongPress,
|
||||||
}: {
|
}: {
|
||||||
node: AnimalNode
|
node: AnimalNode
|
||||||
isRoot: boolean
|
isRoot: boolean
|
||||||
farbschlag: string | null
|
farbschlag: string | null
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
onExpand: () => void
|
onExpand: () => void
|
||||||
|
onContextMenu: (e: ReactMouseEvent) => void
|
||||||
|
onLongPress: (x: number, y: number) => void
|
||||||
}) {
|
}) {
|
||||||
const t = de.pages.stammbaum
|
const t = de.pages.stammbaum
|
||||||
const g = node.gerbil
|
const g = node.gerbil
|
||||||
@@ -388,10 +541,57 @@ function PedigreeCard({
|
|||||||
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
: `${API_BASE_URL}${g.profilePhotoUrl}`
|
||||||
: null
|
: null
|
||||||
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
|
const photoUrl = rawUrl !== failedUrl ? rawUrl : null
|
||||||
|
|
||||||
|
/* Long-Press (Touch) = Rechtsklick-Äquivalent: ~500 ms halten ohne zu
|
||||||
|
verschieben öffnet das Kontextmenü. Der danach folgende Klick (Umwurzeln)
|
||||||
|
wird unterdrückt. */
|
||||||
|
const pressTimer = useRef<number | null>(null)
|
||||||
|
const pressStart = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
const longPressed = useRef(false)
|
||||||
|
const cancelPress = () => {
|
||||||
|
if (pressTimer.current != null) {
|
||||||
|
clearTimeout(pressTimer.current)
|
||||||
|
pressTimer.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onTouchStart = (e: ReactTouchEvent) => {
|
||||||
|
const tch = e.touches[0]
|
||||||
|
if (!tch) return
|
||||||
|
pressStart.current = { x: tch.clientX, y: tch.clientY }
|
||||||
|
longPressed.current = false
|
||||||
|
cancelPress()
|
||||||
|
pressTimer.current = window.setTimeout(() => {
|
||||||
|
pressTimer.current = null
|
||||||
|
longPressed.current = true
|
||||||
|
onLongPress(pressStart.current!.x, pressStart.current!.y)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
const onTouchMove = (e: ReactTouchEvent) => {
|
||||||
|
const tch = e.touches[0]
|
||||||
|
if (!tch || !pressStart.current) return
|
||||||
|
if (Math.abs(tch.clientX - pressStart.current.x) > 10 || Math.abs(tch.clientY - pressStart.current.y) > 10) {
|
||||||
|
cancelPress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onTouchEnd = (e: ReactTouchEvent) => {
|
||||||
|
cancelPress()
|
||||||
|
if (longPressed.current) {
|
||||||
|
// Synthetischen Klick verhindern (sonst würde umgewurzelt / Menü sofort
|
||||||
|
// wieder geschlossen).
|
||||||
|
e.preventDefault()
|
||||||
|
longPressed.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
|
className={isRoot ? 'pedigree-card pedigree-card--root' : 'pedigree-card'}
|
||||||
onClick={isRoot ? undefined : onOpen}
|
onClick={isRoot ? undefined : onOpen}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={onTouchEnd}
|
||||||
|
onTouchCancel={cancelPress}
|
||||||
role={isRoot ? undefined : 'button'}
|
role={isRoot ? undefined : 'button'}
|
||||||
title={isRoot ? undefined : t.tapHint}
|
title={isRoot ? undefined : t.tapHint}
|
||||||
>
|
>
|
||||||
@@ -429,7 +629,7 @@ function PedigreeCard({
|
|||||||
)}
|
)}
|
||||||
{dob && <span className="pedigree-card__year">* {dob}</span>}
|
{dob && <span className="pedigree-card__year">* {dob}</span>}
|
||||||
</div>
|
</div>
|
||||||
{node.expandable && (
|
{node.expandable && !node.siblingPairing && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pedigree-card__expand"
|
className="pedigree-card__expand"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { isValidGenotype } from '../format/genotypeText'
|
|||||||
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
import { breed, fromDisplayString, genotypeToFarbschlag, UNKNOWN_FARBSCHLAG, type BreedingResult } from '../genetics'
|
||||||
import BreedingResultView from '../components/BreedingResultView'
|
import BreedingResultView from '../components/BreedingResultView'
|
||||||
import GerbilIcon from '../components/GerbilIcon'
|
import GerbilIcon from '../components/GerbilIcon'
|
||||||
|
import ProvenanceDialog from '../components/ProvenanceDialog'
|
||||||
|
import ReportErrorDialog from '../components/ReportErrorDialog'
|
||||||
import { useGerbilName } from '../components/breederSuffix'
|
import { useGerbilName } from '../components/breederSuffix'
|
||||||
import './wuerfe.css'
|
import './wuerfe.css'
|
||||||
|
|
||||||
@@ -64,6 +66,8 @@ export default function WurfDetailPage() {
|
|||||||
const t = de.pages.litters
|
const t = de.pages.litters
|
||||||
const gerbilName = useGerbilName()
|
const gerbilName = useGerbilName()
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
|
const [reportOpen, setReportOpen] = useState(false)
|
||||||
|
const [provenanceOpen, setProvenanceOpen] = useState(false)
|
||||||
|
|
||||||
const litter = useApi(() => getLitter(id), [id])
|
const litter = useApi(() => getLitter(id), [id])
|
||||||
const fatherId = litter.data?.fatherId ?? null
|
const fatherId = litter.data?.fatherId ?? null
|
||||||
@@ -151,6 +155,12 @@ export default function WurfDetailPage() {
|
|||||||
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
|
<Link to={`/wuerfe/${l.id}/bearbeiten`} className="btn btn--primary">
|
||||||
{t.detail.edit}
|
{t.detail.edit}
|
||||||
</Link>
|
</Link>
|
||||||
|
<button type="button" className="btn" onClick={() => setProvenanceOpen(true)}>
|
||||||
|
{de.provenance.button}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={() => setReportOpen(true)}>
|
||||||
|
{de.feedback.button}
|
||||||
|
</button>
|
||||||
<Link to="/wuerfe" className="btn">
|
<Link to="/wuerfe" className="btn">
|
||||||
{t.detail.back}
|
{t.detail.back}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -224,6 +234,19 @@ export default function WurfDetailPage() {
|
|||||||
) : (
|
) : (
|
||||||
<p className="muted">{t.detail.needParentsGenotype}</p>
|
<p className="muted">{t.detail.needParentsGenotype}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ReportErrorDialog
|
||||||
|
open={reportOpen}
|
||||||
|
onClose={() => setReportOpen(false)}
|
||||||
|
context={{ context: 'litter-detail', litterId: l.id, entityName: l.name }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProvenanceDialog
|
||||||
|
open={provenanceOpen}
|
||||||
|
onClose={() => setProvenanceOpen(false)}
|
||||||
|
provenance={l.provenance}
|
||||||
|
entityLabel={de.provenance.entityLabels.litter}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,12 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
/* Geschwisterverpaarung: hervorgehoben, da zuchtrelevant (Inzucht). */
|
||||||
|
.ak-fact--sibling {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-surface));
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Actions ---------- */
|
/* ---------- Actions ---------- */
|
||||||
.ak-actions {
|
.ak-actions {
|
||||||
|
|||||||
@@ -171,7 +171,7 @@
|
|||||||
.stammbaum-canvas {
|
.stammbaum-canvas {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: clamp(18rem, 62dvh, 46rem);
|
height: clamp(18rem, 72dvh, 58rem);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.6rem;
|
border-radius: 0.6rem;
|
||||||
background:
|
background:
|
||||||
@@ -231,6 +231,44 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Verweis-Knoten der Geschwister-Verpaarung: Vater & Mutter aus demselben
|
||||||
|
Wurf — der Vorfahren-Ast verweist auf die Vaterlinie statt ihn zu doppeln. */
|
||||||
|
.pedigree-card--sibling {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-accent-soft);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card--sibling__icon {
|
||||||
|
flex: none;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card--sibling__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card--sibling__label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pedigree-card--sibling__hint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.pedigree-card__photo {
|
.pedigree-card__photo {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 42px;
|
width: 42px;
|
||||||
@@ -450,3 +488,38 @@
|
|||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: #555;
|
color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* FEEDBACK: Rechtsklick-Kontextmenü auf einer Ahnenkarte. */
|
||||||
|
.stammbaum-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 150;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
min-width: 11rem;
|
||||||
|
background: var(--color-bg, #fff);
|
||||||
|
border: 1px solid var(--color-border, #ddd);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 28px rgba(43, 33, 25, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-context-menu__item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stammbaum-context-menu__item:hover,
|
||||||
|
.stammbaum-context-menu__item:focus-visible {
|
||||||
|
background: var(--color-accent, #2563eb);
|
||||||
|
color: #fff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,6 +183,74 @@ describe('buildPedigree', () => {
|
|||||||
expect(aAgain.expandable).toBe(false)
|
expect(aAgain.expandable).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('markiert Geschwister-Verpaarung: Vater & Mutter aus demselben Wurf', async () => {
|
||||||
|
// Vater und Mutter sind Vollgeschwister (gleiche litterId 'w-grand').
|
||||||
|
const source = makeSource(
|
||||||
|
[
|
||||||
|
makeGerbil('kind', 'Kind', 'w-kind'),
|
||||||
|
makeGerbil('vater', 'Vater', 'w-grand'),
|
||||||
|
makeGerbil('mutter', 'Mutter', 'w-grand'),
|
||||||
|
makeGerbil('opa', 'Opa', null),
|
||||||
|
makeGerbil('oma', 'Oma', null),
|
||||||
|
],
|
||||||
|
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
|
||||||
|
)
|
||||||
|
const root = await buildPedigree(source, 'kind')
|
||||||
|
const vater = animal(root!.father)
|
||||||
|
const mutter = animal(root!.mother)
|
||||||
|
|
||||||
|
// Mutter trägt den Verweis auf die Vaterlinie; Vater selbst nicht.
|
||||||
|
expect(mutter.siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
|
||||||
|
expect(vater.siblingPairing).toBeUndefined()
|
||||||
|
|
||||||
|
// Echte Vorfahren bleiben im Modell (Druck-Ahnentafel nutzt sie weiter).
|
||||||
|
expect(animal(mutter.father).gerbil.name).toBe('Opa')
|
||||||
|
expect(animal(mutter.mother).gerbil.name).toBe('Oma')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('erkennt Geschwister-Verpaarung am gleichen Geburtsdatum (litterId fehlt/verschieden)', async () => {
|
||||||
|
// Vater und Mutter haben verschiedene litterId, aber dasselbe Geburtsdatum
|
||||||
|
// (starkes Indiz: gemeinsamer Wurf, der in den Daten nicht verknüpft ist).
|
||||||
|
const source = makeSource(
|
||||||
|
[
|
||||||
|
makeGerbil('kind', 'Kind', 'w-kind'),
|
||||||
|
{ ...makeGerbil('vater', 'Vater', 'w-v'), dateOfBirth: '2018-09-22' },
|
||||||
|
{ ...makeGerbil('mutter', 'Mutter', 'w-m'), dateOfBirth: '2018-09-22' },
|
||||||
|
],
|
||||||
|
[makeLitter('w-kind', 'vater', 'mutter')],
|
||||||
|
)
|
||||||
|
const root = await buildPedigree(source, 'kind')
|
||||||
|
expect(animal(root!.mother).siblingPairing).toEqual({ siblingName: 'Vater', siblingPath: 'v' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gleiches Geburtsdatum nur bei BEIDEN Eltern (unterschiedliches DOB → keine Verpaarung)', async () => {
|
||||||
|
const source = makeSource(
|
||||||
|
[
|
||||||
|
makeGerbil('kind', 'Kind', 'w-kind'),
|
||||||
|
{ ...makeGerbil('vater', 'Vater', 'w-v'), dateOfBirth: '2018-09-22' },
|
||||||
|
{ ...makeGerbil('mutter', 'Mutter', 'w-m'), dateOfBirth: '2019-05-15' },
|
||||||
|
],
|
||||||
|
[makeLitter('w-kind', 'vater', 'mutter')],
|
||||||
|
)
|
||||||
|
const root = await buildPedigree(source, 'kind')
|
||||||
|
expect(animal(root!.mother).siblingPairing).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verlangt für Geschwister-Verpaarung dieselbe litterId (Halbgeschwister zählen nicht)', async () => {
|
||||||
|
// Gemeinsamer Vater (Opa), aber verschiedene Würfe → keine Verpaarung-Marke.
|
||||||
|
const source = makeSource(
|
||||||
|
[
|
||||||
|
makeGerbil('kind', 'Kind', 'w1'),
|
||||||
|
makeGerbil('vater', 'Vater', 'w2'),
|
||||||
|
makeGerbil('mutter', 'Mutter', 'w3'),
|
||||||
|
makeGerbil('opa', 'Opa', null),
|
||||||
|
],
|
||||||
|
[makeLitter('w1', 'vater', 'mutter'), makeLitter('w2', 'opa', null), makeLitter('w3', 'opa', null)],
|
||||||
|
)
|
||||||
|
const root = await buildPedigree(source, 'kind')
|
||||||
|
expect(animal(root!.mother).siblingPairing).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => {
|
it('dasselbe Tier darf an mehreren Positionen stehen (Inzucht ist kein Zyklus)', async () => {
|
||||||
// Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus.
|
// Vater und Mutter haben denselben Vater (Opa) — Ahnenschwund, kein Zyklus.
|
||||||
const source = makeSource(
|
const source = makeSource(
|
||||||
@@ -260,6 +328,36 @@ describe('toRawNodeDatum', () => {
|
|||||||
expect(datum.children![0].children).toBeUndefined()
|
expect(datum.children![0].children).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('führt den Vorfahren-Ast einer Geschwister-Verpaarung zu einem Verweis zusammen', async () => {
|
||||||
|
const source = makeSource(
|
||||||
|
[
|
||||||
|
makeGerbil('kind', 'Kind', 'w-kind'),
|
||||||
|
makeGerbil('vater', 'Vater', 'w-grand'),
|
||||||
|
makeGerbil('mutter', 'Mutter', 'w-grand'),
|
||||||
|
makeGerbil('opa', 'Opa', null),
|
||||||
|
makeGerbil('oma', 'Oma', null),
|
||||||
|
],
|
||||||
|
[makeLitter('w-kind', 'vater', 'mutter'), makeLitter('w-grand', 'opa', 'oma')],
|
||||||
|
)
|
||||||
|
const root = await buildPedigree(source, 'kind')
|
||||||
|
const datum = toRawNodeDatum(root!, 'unbekannt')
|
||||||
|
|
||||||
|
// Vater behält seinen vollen Vorfahren-Ast …
|
||||||
|
const vater = datum.children![0]
|
||||||
|
expect(vater.name).toBe('Vater')
|
||||||
|
expect(vater.children!.map((c) => c.name)).toEqual(['Opa', 'Oma'])
|
||||||
|
|
||||||
|
// … Mutter wird zu EINEM Verweis-Knoten zusammengeführt.
|
||||||
|
const mutter = datum.children![1]
|
||||||
|
expect(mutter.name).toBe('Mutter')
|
||||||
|
expect(mutter.children).toHaveLength(1)
|
||||||
|
expect(mutter.children![0].attributes).toMatchObject({
|
||||||
|
kind: 'sibling-ref',
|
||||||
|
refName: 'Vater',
|
||||||
|
refPath: 'v',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('rendert Platzhalter mit dem übergebenen Label', async () => {
|
it('rendert Platzhalter mit dem übergebenen Label', async () => {
|
||||||
const root = await buildPedigree(familySource(), 'mutter')
|
const root = await buildPedigree(familySource(), 'mutter')
|
||||||
const datum = toRawNodeDatum(root!, 'unbekannt')
|
const datum = toRawNodeDatum(root!, 'unbekannt')
|
||||||
|
|||||||
@@ -70,6 +70,29 @@ async function buildAnimal(
|
|||||||
parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors),
|
parentNode(source, litter?.fatherId, path + 'v', generationsLeft - 1, nextAncestors),
|
||||||
parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors),
|
parentNode(source, litter?.motherId, path + 'm', generationsLeft - 1, nextAncestors),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Geschwister-Verpaarung: Vater und Mutter sind Vollgeschwister, wenn sie aus
|
||||||
|
// DEMSELBEN Wurf stammen (gleiche litterId) ODER am selben Tag geboren sind
|
||||||
|
// (gleiches Geburtsdatum ist ein starkes Indiz — Würfe sind praktisch der
|
||||||
|
// einzige Grund für ein identisches Datum, und die Daten verknüpfen die
|
||||||
|
// Eltern-Würfe nicht immer). Wir markieren dann die Mutter, damit das Diagramm
|
||||||
|
// ihren Ast zu einem Verweis auf die Vaterlinie zusammenführt. Die echten
|
||||||
|
// Vorfahren bleiben im Modell erhalten (Druck-Ahnentafel nutzt sie weiter).
|
||||||
|
if (
|
||||||
|
father.kind === 'animal' &&
|
||||||
|
mother.kind === 'animal' &&
|
||||||
|
father.gerbil.id !== mother.gerbil.id &&
|
||||||
|
((father.gerbil.litterId && father.gerbil.litterId === mother.gerbil.litterId) ||
|
||||||
|
(father.gerbil.dateOfBirth != null &&
|
||||||
|
father.gerbil.dateOfBirth === mother.gerbil.dateOfBirth))
|
||||||
|
) {
|
||||||
|
const markedMother: AnimalNode = {
|
||||||
|
...mother,
|
||||||
|
siblingPairing: { siblingName: father.gerbil.name, siblingPath: father.path },
|
||||||
|
}
|
||||||
|
return { kind: 'animal', path, gerbil, father, mother: markedMother, expandable: false }
|
||||||
|
}
|
||||||
|
|
||||||
return { kind: 'animal', path, gerbil, father, mother, expandable: false }
|
return { kind: 'animal', path, gerbil, father, mother, expandable: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +166,24 @@ export function toRawNodeDatum(node: PedigreeNode, unknownLabel: string): RawNod
|
|||||||
name: node.gerbil.name,
|
name: node.gerbil.name,
|
||||||
attributes: { path: node.path, kind: 'animal', expandable: node.expandable },
|
attributes: { path: node.path, kind: 'animal', expandable: node.expandable },
|
||||||
}
|
}
|
||||||
|
// Geschwister-Verpaarung: Vorfahren-Ast zu EINEM Verweis-Knoten
|
||||||
|
// zusammenführen (gemeinsame Vorfahren stehen bereits an der Vaterlinie).
|
||||||
|
if (node.siblingPairing) {
|
||||||
|
datum.children = [
|
||||||
|
{
|
||||||
|
name: node.siblingPairing.siblingName,
|
||||||
|
attributes: {
|
||||||
|
// Synthetischer Pfad (nicht in collectNodes) — die Karte rendert
|
||||||
|
// ausschließlich aus diesen Attributen.
|
||||||
|
path: node.path + '§',
|
||||||
|
kind: 'sibling-ref',
|
||||||
|
refName: node.siblingPairing.siblingName,
|
||||||
|
refPath: node.siblingPairing.siblingPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return datum
|
||||||
|
}
|
||||||
if (node.father && node.mother) {
|
if (node.father && node.mother) {
|
||||||
datum.children = [
|
datum.children = [
|
||||||
toRawNodeDatum(node.father, unknownLabel),
|
toRawNodeDatum(node.father, unknownLabel),
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ export interface AnimalNode {
|
|||||||
readonly mother?: PedigreeNode
|
readonly mother?: PedigreeNode
|
||||||
/** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */
|
/** True: Tier hat einen Wurf, dessen Eltern noch nachgeladen werden können. */
|
||||||
readonly expandable: boolean
|
readonly expandable: boolean
|
||||||
|
/**
|
||||||
|
* Geschwister-Verpaarung: gesetzt, wenn dieses Tier und sein Geschwister
|
||||||
|
* (an `siblingPath`) aus DEMSELBEN Wurf stammen und beide Eltern des
|
||||||
|
* Wurzeltiers sind (Vollgeschwister-Verpaarung — in der Zucht häufig).
|
||||||
|
* Im interaktiven Diagramm wird der Vorfahren-Ast dieses Knotens dann zu
|
||||||
|
* einem Verweis kollabiert (gemeinsame Vorfahren siehe `siblingPath`); die
|
||||||
|
* echten Vorfahren bleiben im Modell (Druck-Ahnentafel zeigt sie weiterhin).
|
||||||
|
*/
|
||||||
|
readonly siblingPairing?: { readonly siblingName: string; readonly siblingPath: PedigreePath }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ export const de = {
|
|||||||
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
|
parentLittersEmpty: 'Keine Würfe als Elternteil erfasst.',
|
||||||
parentLittersRoleVater: 'Vater',
|
parentLittersRoleVater: 'Vater',
|
||||||
parentLittersRoleMutter: 'Mutter',
|
parentLittersRoleMutter: 'Mutter',
|
||||||
|
// Hinweis: Eltern dieses Tiers sind Vollgeschwister (gleicher Wurf).
|
||||||
|
siblingPairing: 'Geschwisterverpaarung',
|
||||||
|
siblingPairingTitle:
|
||||||
|
'Die Eltern dieses Tiers stammen aus demselben Wurf (Vollgeschwister).',
|
||||||
},
|
},
|
||||||
// Formular (anlegen/bearbeiten)
|
// Formular (anlegen/bearbeiten)
|
||||||
form: {
|
form: {
|
||||||
@@ -411,6 +415,13 @@ export const de = {
|
|||||||
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
/** Mini-Legende unter dem Baum (STAMMBAUM-EXPAND). */
|
||||||
hintName: 'Namenslink: Tierakte öffnen',
|
hintName: 'Namenslink: Tierakte öffnen',
|
||||||
hintExpand: '+: weitere Vorfahren nachladen',
|
hintExpand: '+: weitere Vorfahren nachladen',
|
||||||
|
hintContext: 'Rechtsklick (oder Karte lange gedrückt halten): ID kopieren / Fehler melden',
|
||||||
|
hintSiblings: '⟲: Eltern sind Geschwister — gemeinsame Vorfahren siehe Vaterlinie',
|
||||||
|
/** Verweis-Knoten bei Geschwister-Verpaarung (Vater & Mutter aus demselben Wurf). */
|
||||||
|
siblingPairing: {
|
||||||
|
refLabel: (name: string) => `Geschwister von ${name}`,
|
||||||
|
refHint: 'Eltern siehe oben',
|
||||||
|
},
|
||||||
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
|
/** Würfe-Panel links (STAMMBAUM-LITTERS). */
|
||||||
littersTitle: 'Würfe',
|
littersTitle: 'Würfe',
|
||||||
littersJunge: 'Junge',
|
littersJunge: 'Junge',
|
||||||
@@ -428,6 +439,11 @@ export const de = {
|
|||||||
createdOn: 'Erstellt am',
|
createdOn: 'Erstellt am',
|
||||||
born: 'geb.',
|
born: 'geb.',
|
||||||
},
|
},
|
||||||
|
/** FEEDBACK: Rechtsklick-Kontextmenü auf einer Stammbaum-Karte. */
|
||||||
|
contextMenu: {
|
||||||
|
copyId: 'ID kopieren',
|
||||||
|
reportError: 'Fehler melden',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// ── FEAT-7 (Kelly): Statistik & Berichte ──
|
// ── FEAT-7 (Kelly): Statistik & Berichte ──
|
||||||
statistik: {
|
statistik: {
|
||||||
@@ -832,6 +848,79 @@ export const de = {
|
|||||||
unknown: 'Ein unbekannter Fehler ist aufgetreten.',
|
unknown: 'Ein unbekannter Fehler ist aufgetreten.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── FEEDBACK: "Fehler melden" Dialog + "ID kopieren" Aktion ──
|
||||||
|
feedback: {
|
||||||
|
/** Button-Beschriftung (Tierakte, Wurf-Ansicht). */
|
||||||
|
button: 'Fehler melden',
|
||||||
|
dialogTitle: 'Fehler melden',
|
||||||
|
intro: 'Beschreibe kurz, was nicht stimmt. Technische Angaben werden automatisch mitgesendet.',
|
||||||
|
label: 'Beschreibung',
|
||||||
|
placeholder: 'Was funktioniert nicht oder ist falsch dargestellt?',
|
||||||
|
/** Überschrift über den automatisch erfassten Debug-Angaben. */
|
||||||
|
debugTitle: 'Automatisch erfasst',
|
||||||
|
debugFields: {
|
||||||
|
context: 'Ansicht',
|
||||||
|
entity: 'Datensatz',
|
||||||
|
url: 'Adresse',
|
||||||
|
},
|
||||||
|
contexts: {
|
||||||
|
stammbaum: 'Stammbaum',
|
||||||
|
'gerbil-detail': 'Rennmausakte',
|
||||||
|
'litter-detail': 'Wurf',
|
||||||
|
'contact-detail': 'Kontakt',
|
||||||
|
},
|
||||||
|
submit: 'Senden',
|
||||||
|
submitting: 'Wird gesendet …',
|
||||||
|
cancel: 'Abbrechen',
|
||||||
|
close: 'Schließen',
|
||||||
|
success: 'Danke! Dein Fehlerbericht wurde gesendet.',
|
||||||
|
error: 'Fehlerbericht konnte nicht gesendet werden.',
|
||||||
|
emptyMessage: 'Bitte beschreibe den Fehler.',
|
||||||
|
/** Toast nach erfolgreichem "ID kopieren". */
|
||||||
|
idCopied: 'ID kopiert',
|
||||||
|
idCopyFailed: 'ID konnte nicht kopiert werden.',
|
||||||
|
},
|
||||||
|
/** NACHVERFOLGUNG: Datenherkunft eines importierten Tiers (Rennmausakte). */
|
||||||
|
provenance: {
|
||||||
|
/** Button-Beschriftung in den Akten-Aktionen. */
|
||||||
|
button: 'Datenherkunft',
|
||||||
|
dialogTitle: 'Nachverfolgungsinformationen',
|
||||||
|
intro: 'Woher stammen die Daten dieses Eintrags? Diese Angaben werden beim Import automatisch erfasst.',
|
||||||
|
/** Wie {@link intro}, aber mit Entitätsbezeichnung (z. B. „dieses Kontakts"). */
|
||||||
|
introFor: (label: string) =>
|
||||||
|
`Woher stammen die Daten ${label}? Diese Angaben werden beim Import automatisch erfasst.`,
|
||||||
|
/** Entitätsbezeichnungen für introFor (Genitiv). */
|
||||||
|
entityLabels: {
|
||||||
|
contact: 'dieses Kontakts',
|
||||||
|
litter: 'dieses Wurfs',
|
||||||
|
},
|
||||||
|
/** Überschrift des chronologischen, dateibezogenen Verlaufs (Primärinhalt). */
|
||||||
|
historyTitle: 'Verlauf',
|
||||||
|
/** Überschriften / Feldbeschriftungen. */
|
||||||
|
sourceFilesTitle: 'Quelldateien',
|
||||||
|
sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`),
|
||||||
|
mergedTitle: 'Datensätze',
|
||||||
|
mergedCount: (n: number) =>
|
||||||
|
n === 1 ? 'aus 1 Datensatz' : `zusammengeführt aus ${n} Datensätzen`,
|
||||||
|
fromWurfchronik: 'Enthält Angaben aus der Wurfchronik',
|
||||||
|
parentsTitle: 'Eltern-Herkunft',
|
||||||
|
parentMethodLabel: 'Methode',
|
||||||
|
parentConfidenceLabel: 'Konfidenz',
|
||||||
|
/** Übersetzungen für die technischen Methoden-/Konfidenz-Werte. */
|
||||||
|
methods: {
|
||||||
|
'chart-position': 'Position im Stammbaum',
|
||||||
|
decision: 'Manuelle Entscheidung',
|
||||||
|
} as Record<string, string>,
|
||||||
|
confidences: {
|
||||||
|
low: 'niedrig',
|
||||||
|
medium: 'mittel',
|
||||||
|
high: 'hoch',
|
||||||
|
} as Record<string, string>,
|
||||||
|
notesTitle: 'Hinweise',
|
||||||
|
/** Wenn ein Tier keine Importdaten hat (manuell angelegt). */
|
||||||
|
empty: 'Keine Herkunftsdaten vorhanden.',
|
||||||
|
close: 'Schließen',
|
||||||
|
},
|
||||||
common: {
|
common: {
|
||||||
loading: 'Lädt …',
|
loading: 'Lädt …',
|
||||||
retry: 'Erneut versuchen',
|
retry: 'Erneut versuchen',
|
||||||
|
|||||||
@@ -341,14 +341,22 @@ def _attach_photos(z, sheets, animals, fname):
|
|||||||
if not anchors:
|
if not anchors:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Detect left_style: whether photo is to the left or to the right of the name cell
|
by_gen = {}
|
||||||
a_count = sum(1 for a in anchors if a[1] == 1)
|
for a in animals:
|
||||||
d_count = sum(1 for a in anchors if a[1] == 4)
|
by_gen.setdefault(a["_gen"], []).append(a)
|
||||||
has_proband_in_d = any(a[1] == 4 and 50 <= a[2] <= 70 for a in anchors)
|
media_dir = os.path.join(OUT, "photos")
|
||||||
left_style = a_count > 0 or (d_count > 0 and not has_proband_in_d)
|
col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3
|
||||||
|
|
||||||
def get_anchor_gen(colnum, offset=0):
|
# A photo sits one column either side of (or on) its animal's name cell;
|
||||||
effective_col = colnum - offset
|
# the generation is read from the photo's column. Whether photos are LEFT or
|
||||||
|
# RIGHT of the name varies, and the old fixed-column heuristic (col 1 / col 4)
|
||||||
|
# misclassified sheets that have neither, shifting every photo one generation
|
||||||
|
# toward the proband (e.g. „Stammbaum von Kazuya“ / „Picus Son“: Kazuya wore
|
||||||
|
# his father's photo, the father his grandfather's). Instead, try BOTH
|
||||||
|
# interpretations and keep the one that places photos closest to their
|
||||||
|
# assigned animal's name column (minimal horizontal misalignment).
|
||||||
|
def get_anchor_gen(colnum, left_style):
|
||||||
|
effective_col = colnum - col_offset
|
||||||
if left_style:
|
if left_style:
|
||||||
if effective_col <= 3: return 0
|
if effective_col <= 3: return 0
|
||||||
if effective_col <= 6: return 1
|
if effective_col <= 6: return 1
|
||||||
@@ -364,18 +372,26 @@ def _attach_photos(z, sheets, animals, fname):
|
|||||||
if effective_col <= 16: return 4
|
if effective_col <= 16: return 4
|
||||||
return 5
|
return 5
|
||||||
|
|
||||||
by_gen = {}
|
def targets_for(left_style):
|
||||||
for a in animals:
|
out = []
|
||||||
by_gen.setdefault(a["_gen"], []).append(a)
|
for (sp, col, row, media) in anchors:
|
||||||
media_dir = os.path.join(OUT, "photos")
|
cands = by_gen.get(get_anchor_gen(col, left_style), []) or animals
|
||||||
col_offset = 0 if any(a["_col"] == 2 for a in animals) else 3
|
out.append(min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None)
|
||||||
for i, (sp, col, row, media) in enumerate(anchors):
|
return out
|
||||||
g = get_anchor_gen(col, col_offset)
|
|
||||||
cands = by_gen.get(g, [])
|
def mean_col_offset(targets):
|
||||||
if not cands:
|
vals = [abs(col - t["_col"]) for (sp, col, row, m), t in zip(anchors, targets) if t]
|
||||||
# fall back to nearest animal by row across all gens
|
return sum(vals) / len(vals) if vals else 0.0
|
||||||
cands = animals
|
|
||||||
target = min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None
|
left_targets = targets_for(True)
|
||||||
|
right_targets = targets_for(False)
|
||||||
|
targets = (
|
||||||
|
left_targets
|
||||||
|
if mean_col_offset(left_targets) <= mean_col_offset(right_targets)
|
||||||
|
else right_targets
|
||||||
|
)
|
||||||
|
|
||||||
|
for (sp, col, row, media), target in zip(anchors, targets):
|
||||||
if not target:
|
if not target:
|
||||||
continue
|
continue
|
||||||
ext = os.path.splitext(media)[1] or ".img"
|
ext = os.path.splitext(media)[1] or ".img"
|
||||||
|
|||||||
@@ -22,6 +22,86 @@ def generate_guid(key_str):
|
|||||||
"""Generate a stable UUID string based on a key."""
|
"""Generate a stable UUID string based on a key."""
|
||||||
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
|
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
|
||||||
|
|
||||||
|
def build_entity_provenance(source_files, merged_record_count, notes=None,
|
||||||
|
from_wurfchronik=None, extra=None, history=None):
|
||||||
|
"""Generic data-provenance JSON builder shared by gerbils, contacts and
|
||||||
|
litters. Mirrors the GerbilProvenance frontend contract:
|
||||||
|
{ sourceFiles, mergedRecordCount, fromWurfchronik, notes, history, ... }
|
||||||
|
`source_files` is any iterable of filenames; `from_wurfchronik` is auto-
|
||||||
|
derived from the filenames when left as None. `extra` may carry entity-
|
||||||
|
specific keys (e.g. parentMethod/parentConfidence for gerbils). `history`
|
||||||
|
is an ordered list of human-readable German lines that read like a
|
||||||
|
chronological log of where each fact came from (the primary content shown
|
||||||
|
in the Datenherkunft dialog). Returns a JSON string (stored on the nullable
|
||||||
|
Provenance text column)."""
|
||||||
|
files = sorted({f for f in source_files if f})
|
||||||
|
if from_wurfchronik is None:
|
||||||
|
from_wurfchronik = any("wurfchronik" in f.lower() for f in files)
|
||||||
|
prov = {
|
||||||
|
"sourceFiles": files,
|
||||||
|
"mergedRecordCount": merged_record_count,
|
||||||
|
"fromWurfchronik": bool(from_wurfchronik),
|
||||||
|
"notes": list(notes or []),
|
||||||
|
"history": list(history or []),
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
for k, v in extra.items():
|
||||||
|
if v is not None:
|
||||||
|
prov[k] = v
|
||||||
|
return json.dumps(prov, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_file(fname):
|
||||||
|
"""German typographic quotes around a source filename for history lines."""
|
||||||
|
return f"„{fname}“"
|
||||||
|
|
||||||
|
|
||||||
|
# Human-readable German labels for the significant fields we attribute to files.
|
||||||
|
PROV_FIELD_LABELS = {
|
||||||
|
"DateOfBirth": "Geburtsdatum",
|
||||||
|
"DateOfDeath": "Sterbedatum",
|
||||||
|
"Gender": "Geschlecht",
|
||||||
|
"Genotype": "Genotyp",
|
||||||
|
"ColorVarietyId": "Farbschlag",
|
||||||
|
"Name": "Name",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_file_of(record):
|
||||||
|
"""The single most representative source file of one raw record.
|
||||||
|
|
||||||
|
Prefers an explicit Stammbaum/Wurfchronik filename from ImportSource, else
|
||||||
|
the record's _filename. Used to attribute a field value to a concrete file
|
||||||
|
in the history log."""
|
||||||
|
fn = record.get("_filename")
|
||||||
|
if fn:
|
||||||
|
return fn
|
||||||
|
imp = record.get("ImportSource")
|
||||||
|
if imp:
|
||||||
|
first = str(imp).split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_source_files(g):
|
||||||
|
"""All distinct source files a single raw gerbil record drew from.
|
||||||
|
|
||||||
|
ImportSource is either a comma-joined Stammbaum file list, a single
|
||||||
|
Wurfchronik filename, or the per-litter filename; _filename is the primary
|
||||||
|
file. We union both so nothing is lost."""
|
||||||
|
files = set()
|
||||||
|
imp = g.get("ImportSource")
|
||||||
|
if imp:
|
||||||
|
for part in str(imp).split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if part:
|
||||||
|
files.add(part)
|
||||||
|
fn = g.get("_filename")
|
||||||
|
if fn:
|
||||||
|
files.add(fn)
|
||||||
|
return files
|
||||||
|
|
||||||
def to_valid_guid(val):
|
def to_valid_guid(val):
|
||||||
if not val:
|
if not val:
|
||||||
return None
|
return None
|
||||||
@@ -509,6 +589,238 @@ def parse_death_info(notes, status, existing_dod, existing_cod):
|
|||||||
|
|
||||||
return resolved_status, dod, cod
|
return resolved_status, dod, cod
|
||||||
|
|
||||||
|
|
||||||
|
# ── Litter dedup & parent-role helpers (pure, unit-tested in test_merge_resolve.py) ──
|
||||||
|
|
||||||
|
def _norm_pname(s):
|
||||||
|
return normalize_name(s) if s else ""
|
||||||
|
|
||||||
|
|
||||||
|
def names_no_conflict(l1, l2):
|
||||||
|
"""Parent names don't contradict (equal per role, or one side empty)."""
|
||||||
|
f1, f2 = _norm_pname(l1.get("_father_name")), _norm_pname(l2.get("_father_name"))
|
||||||
|
m1, m2 = _norm_pname(l1.get("_mother_name")), _norm_pname(l2.get("_mother_name"))
|
||||||
|
f_ok = (not f1) or (not f2) or (f1 == f2)
|
||||||
|
m_ok = (not m1) or (not m2) or (m1 == m2)
|
||||||
|
return f_ok and m_ok
|
||||||
|
|
||||||
|
|
||||||
|
def names_overlap(l1, l2):
|
||||||
|
"""At least one role has a non-empty matching name (positive evidence)."""
|
||||||
|
f1, f2 = _norm_pname(l1.get("_father_name")), _norm_pname(l2.get("_father_name"))
|
||||||
|
m1, m2 = _norm_pname(l1.get("_mother_name")), _norm_pname(l2.get("_mother_name"))
|
||||||
|
return bool((f1 and f1 == f2) or (m1 and m1 == m2))
|
||||||
|
|
||||||
|
|
||||||
|
def litter_compatible(l1, l2):
|
||||||
|
"""Two litter records describe the same litter: same date and compatible parents.
|
||||||
|
|
||||||
|
- Both sides have both parents → must match exactly.
|
||||||
|
- Asymmetric (one side resolved, the other not) → merge only if names don't
|
||||||
|
contradict; for dateless litters require a POSITIVE name match (a shared
|
||||||
|
null date is no evidence), so unrelated nameless stubs stay separate.
|
||||||
|
- Neither side has parents → never blind-merge.
|
||||||
|
"""
|
||||||
|
if l1["Date"] != l2["Date"]:
|
||||||
|
return False
|
||||||
|
f1, m1 = l1.get("FatherId"), l1.get("MotherId")
|
||||||
|
f2, m2 = l2.get("FatherId"), l2.get("MotherId")
|
||||||
|
if f1 and f2 and m1 and m2:
|
||||||
|
return f1 == f2 and m1 == m2
|
||||||
|
asymmetric = (bool(f1 or m1) and not (f2 or m2)) or (bool(f2 or m2) and not (f1 or m1))
|
||||||
|
if asymmetric:
|
||||||
|
if not names_no_conflict(l1, l2):
|
||||||
|
return False
|
||||||
|
if l1["Date"] is None:
|
||||||
|
return names_overlap(l1, l2)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def assign_parent_roles(father_id, mother_id, gender_of):
|
||||||
|
"""Assign two resolved parent IDs to father/mother roles by gender.
|
||||||
|
|
||||||
|
Drops self-pairing duplicates (same animal in both roles) and never returns
|
||||||
|
two same-role parents. `gender_of` maps an id to 'male'|'female'|'unknown'|None.
|
||||||
|
Returns (father_id, mother_id).
|
||||||
|
"""
|
||||||
|
ids = []
|
||||||
|
for gid in (father_id, mother_id):
|
||||||
|
if gid and gid not in ids:
|
||||||
|
ids.append(gid)
|
||||||
|
males = [g for g in ids if gender_of(g) == "male"]
|
||||||
|
females = [g for g in ids if gender_of(g) == "female"]
|
||||||
|
unknowns = [g for g in ids if gender_of(g) == "unknown"]
|
||||||
|
father = males[0] if males else (unknowns.pop(0) if unknowns else None)
|
||||||
|
mother = females[0] if females else (unknowns.pop(0) if unknowns else None)
|
||||||
|
return father, mother
|
||||||
|
|
||||||
|
|
||||||
|
# A gerbil lives at most ~6 years, so a parent can be at most ~6 years older than
|
||||||
|
# its offspring (and must be born before it). Links outside this window are
|
||||||
|
# impossible — e.g. a 2013 animal resolved onto a 2022 litter (Jayjay → Solice).
|
||||||
|
MAX_PARENT_AGE_DAYS = 6 * 366
|
||||||
|
|
||||||
|
|
||||||
|
def parent_age_plausible(parent_dob, litter_date):
|
||||||
|
"""Could a parent born `parent_dob` have offspring born on `litter_date`?
|
||||||
|
|
||||||
|
Requires birth strictly before the litter and within the gerbil lifespan.
|
||||||
|
Unknown/unparseable dates return True (cannot disprove). Accepts any date
|
||||||
|
format parse_date understands.
|
||||||
|
"""
|
||||||
|
pd = date_to_days(parse_date(parent_dob)) if parent_dob else None
|
||||||
|
ld = date_to_days(parse_date(litter_date)) if litter_date else None
|
||||||
|
if pd is None or ld is None:
|
||||||
|
return True
|
||||||
|
return 0 < (ld - pd) <= MAX_PARENT_AGE_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
def pick_parent_ref(parent_refs, role, child_dob, avoid_name=None, gender_of=None):
|
||||||
|
"""Choose the best parent ref for a role from possibly-conflicting chart refs.
|
||||||
|
|
||||||
|
A Stammbaum lists an animal at several positions, so its parentRefs can carry
|
||||||
|
contradictory guesses (the first one is not necessarily right). Rank candidates
|
||||||
|
(lower = better):
|
||||||
|
0 right/unknown gender for the role, age-plausible dated ref
|
||||||
|
1 right/unknown gender, no DOB (usable, but a plausible dated ref wins)
|
||||||
|
2 right/unknown gender, dated but age-impossible
|
||||||
|
3 resolved gender is clearly WRONG for the role (e.g. a female father)
|
||||||
|
4 would duplicate the animal chosen for the other role
|
||||||
|
|
||||||
|
Gender is decisive over DOB: a dated female ref must not win the father slot
|
||||||
|
over an undated male/unknown one. `gender_of(name)` returns 'male'/'female'
|
||||||
|
or None (unknown/ambiguous → not penalised). Returns the chosen ref or None.
|
||||||
|
"""
|
||||||
|
role_refs = [p for p in parent_refs if p.get("roleGuess") == role]
|
||||||
|
if not role_refs:
|
||||||
|
return None
|
||||||
|
avoid = normalize_name(avoid_name) if avoid_name else None
|
||||||
|
expected = "male" if role == "father" else "female"
|
||||||
|
|
||||||
|
def rank(p):
|
||||||
|
if avoid is not None and normalize_name(p.get("name")) == avoid:
|
||||||
|
return 4 # would duplicate the other parent role
|
||||||
|
g = gender_of(p.get("name")) if gender_of else None
|
||||||
|
if g in ("male", "female") and g != expected:
|
||||||
|
return 3 # wrong sex for this role
|
||||||
|
dob = p.get("dob")
|
||||||
|
if not dob:
|
||||||
|
return 1
|
||||||
|
return 0 if parent_age_plausible(dob, child_dob) else 2
|
||||||
|
|
||||||
|
order = sorted(range(len(role_refs)), key=lambda i: (rank(role_refs[i]), i))
|
||||||
|
return role_refs[order[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_gerbil_history(records, best_g, field_source, parent_method=None,
|
||||||
|
any_decision=False, any_conflict=False, conflict_notes=None):
|
||||||
|
"""Build an ordered, file-attributed German history for a resolved gerbil.
|
||||||
|
|
||||||
|
Reads like a chronological log:
|
||||||
|
• „In ‚X.xlsx' gefunden."
|
||||||
|
• „Geburtsdatum (27.03.2022) aus ‚X.xlsx'."
|
||||||
|
• „Auch in ‚Y.xlsx' gefunden → Datensätze zusammengeführt."
|
||||||
|
• „Genotyp aus ‚Z.xlsx'."
|
||||||
|
• „Eltern über Position im Stammbaum erkannt (Quelle: ‚X.xlsx')."
|
||||||
|
• „Aus Wurfchronik übernommen."
|
||||||
|
|
||||||
|
`field_source` maps a field name to the raw record that supplied its final
|
||||||
|
value; when present we name that record's file, otherwise we fall back to
|
||||||
|
the primary record. The records are visited in a stable order (primary
|
||||||
|
first, then the rest sorted by file) so the log is deterministic."""
|
||||||
|
conflict_notes = conflict_notes or []
|
||||||
|
history = []
|
||||||
|
|
||||||
|
# Order records: best_g first, then others by primary file name (stable).
|
||||||
|
others = [r for r in records if r is not best_g]
|
||||||
|
others.sort(key=lambda r: (_primary_file_of(r) or ""))
|
||||||
|
ordered = [best_g] + others
|
||||||
|
|
||||||
|
best_file = _primary_file_of(best_g)
|
||||||
|
if best_file:
|
||||||
|
history.append(f"In {_quote_file(best_file)} gefunden.")
|
||||||
|
else:
|
||||||
|
history.append("Im Import gefunden.")
|
||||||
|
|
||||||
|
# Field-by-field attribution: name the file that supplied each fact.
|
||||||
|
def attr_line(field, formatter):
|
||||||
|
rec = field_source.get(field) or best_g
|
||||||
|
val = best_g.get(field)
|
||||||
|
if not val or val == "unknown":
|
||||||
|
return
|
||||||
|
fname = _primary_file_of(rec)
|
||||||
|
label = PROV_FIELD_LABELS.get(field, field)
|
||||||
|
text = formatter(label, val)
|
||||||
|
if fname:
|
||||||
|
history.append(f"{text} aus {_quote_file(fname)}.")
|
||||||
|
else:
|
||||||
|
history.append(f"{text} (Quelle unbekannt).")
|
||||||
|
|
||||||
|
attr_line("DateOfBirth", lambda label, v: f"{label} ({_de_date(v)})")
|
||||||
|
attr_line("Gender", lambda label, v: f"{label} ({_de_gender(v)})")
|
||||||
|
attr_line("Genotype", lambda label, v: f"{label}")
|
||||||
|
attr_line("ColorVarietyId", lambda label, v: f"{label}")
|
||||||
|
attr_line("DateOfDeath", lambda label, v: f"{label} ({_de_date(v)})")
|
||||||
|
|
||||||
|
# Merge step: every additional record that contributed.
|
||||||
|
for r in others:
|
||||||
|
fname = _primary_file_of(r)
|
||||||
|
if fname:
|
||||||
|
history.append(
|
||||||
|
f"Auch in {_quote_file(fname)} gefunden → Datensätze zusammengeführt."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
history.append("In weiterem Datensatz gefunden → Datensätze zusammengeführt.")
|
||||||
|
|
||||||
|
# Parent derivation.
|
||||||
|
if parent_method:
|
||||||
|
method_label = {
|
||||||
|
"chart-position": "Position im Stammbaum",
|
||||||
|
"geburtsdatum+eltern": "Geburtsdatum und Elternnamen",
|
||||||
|
"nur-geburtsdatum": "Geburtsdatum",
|
||||||
|
"decision": "manuelle Entscheidung",
|
||||||
|
}.get(parent_method, parent_method)
|
||||||
|
# The parent evidence comes from a Stammbaum chart — attribute to the
|
||||||
|
# primary record's file when it is a Stammbaum.
|
||||||
|
parent_file = best_file if best_file and "stammbaum" in best_file.lower() else None
|
||||||
|
if parent_file:
|
||||||
|
history.append(
|
||||||
|
f"Eltern über {method_label} erkannt (Quelle: {_quote_file(parent_file)})."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
history.append(f"Eltern über {method_label} erkannt.")
|
||||||
|
|
||||||
|
# Manual decisions / conflicts.
|
||||||
|
if any_decision:
|
||||||
|
history.append("Zuordnung per manueller Entscheidung getroffen.")
|
||||||
|
if any_conflict:
|
||||||
|
history.append("Konflikt per Entscheidung gelöst.")
|
||||||
|
for cn in conflict_notes:
|
||||||
|
if cn and cn not in history:
|
||||||
|
history.append(cn + ".")
|
||||||
|
|
||||||
|
# Wurfchronik provenance line.
|
||||||
|
if any("wurfchronik" in f.lower() for r in records for f in _record_source_files(r)):
|
||||||
|
history.append("Angaben aus der Wurfchronik übernommen.")
|
||||||
|
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
def _de_date(iso):
|
||||||
|
"""YYYY-MM-DD → DD.MM.YYYY for display; pass through anything else."""
|
||||||
|
if not iso:
|
||||||
|
return iso
|
||||||
|
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", str(iso))
|
||||||
|
if m:
|
||||||
|
return f"{m.group(3)}.{m.group(2)}.{m.group(1)}"
|
||||||
|
return iso
|
||||||
|
|
||||||
|
|
||||||
|
def _de_gender(g):
|
||||||
|
return {"male": "männlich", "female": "weiblich"}.get(g, g)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("Loading color variety seeds...")
|
print("Loading color variety seeds...")
|
||||||
variety_map = {}
|
variety_map = {}
|
||||||
@@ -846,12 +1158,35 @@ def main():
|
|||||||
key = (normalize_name(f_name), normalize_name(m_name), ldate)
|
key = (normalize_name(f_name), normalize_name(m_name), ldate)
|
||||||
md_litters_idx[key] = rl
|
md_litters_idx[key] = rl
|
||||||
|
|
||||||
|
# Gender index for parent-ref selection: normalized name → 'male' | 'female'
|
||||||
|
# | 'ambiguous'. Drives the gender-aware ranking in pick_parent_ref so a dated
|
||||||
|
# but wrong-sex ref (e.g. female „Danielle“) cannot win the father slot over
|
||||||
|
# an undated male/unknown one (e.g. „Hagrid Rubeus“).
|
||||||
|
gender_idx = {}
|
||||||
|
for a in stammbaum_only_animals:
|
||||||
|
g = (a.get("gender") or "").lower().strip()
|
||||||
|
g = g if g in ("male", "female") else None
|
||||||
|
for key in {normalize_name(a.get("name")), normalize_name(get_call_name(a.get("name") or ""))}:
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
if key not in gender_idx:
|
||||||
|
gender_idx[key] = g
|
||||||
|
elif gender_idx[key] != g:
|
||||||
|
gender_idx[key] = "ambiguous"
|
||||||
|
|
||||||
|
def gender_of_name(name):
|
||||||
|
v = gender_idx.get(normalize_name(name))
|
||||||
|
return v if v in ("male", "female") else None
|
||||||
|
|
||||||
# Create virtual litters for stammbaum animals
|
# Create virtual litters for stammbaum animals
|
||||||
created_virtual_litters = {}
|
created_virtual_litters = {}
|
||||||
for a in stammbaum_only_animals:
|
for a in stammbaum_only_animals:
|
||||||
parent_refs = a.get("parentRefs", [])
|
parent_refs = a.get("parentRefs", [])
|
||||||
father_ref = next((p for p in parent_refs if p.get("roleGuess") == "father"), None)
|
child_dob_raw = a.get("dob")
|
||||||
mother_ref = next((p for p in parent_refs if p.get("roleGuess") == "mother"), None)
|
father_ref = pick_parent_ref(parent_refs, "father", child_dob_raw, gender_of=gender_of_name)
|
||||||
|
mother_ref = pick_parent_ref(parent_refs, "mother", child_dob_raw,
|
||||||
|
avoid_name=father_ref.get("name") if father_ref else None,
|
||||||
|
gender_of=gender_of_name)
|
||||||
|
|
||||||
a["_mapped_litter_scoped_id"] = None
|
a["_mapped_litter_scoped_id"] = None
|
||||||
if father_ref and mother_ref:
|
if father_ref and mother_ref:
|
||||||
@@ -954,6 +1289,7 @@ def main():
|
|||||||
|
|
||||||
norm_name = normalize_name(canon_name)
|
norm_name = normalize_name(canon_name)
|
||||||
|
|
||||||
|
rc_file = rc.get("_filename")
|
||||||
if norm_name not in contact_by_norm_name:
|
if norm_name not in contact_by_norm_name:
|
||||||
global_guid = generate_guid(f"contact-{norm_name}")
|
global_guid = generate_guid(f"contact-{norm_name}")
|
||||||
contact_by_norm_name[norm_name] = {
|
contact_by_norm_name[norm_name] = {
|
||||||
@@ -962,7 +1298,10 @@ def main():
|
|||||||
"Email": rc.get("Email") or rc.get("email"),
|
"Email": rc.get("Email") or rc.get("email"),
|
||||||
"Phone": rc.get("Phone") or rc.get("phone"),
|
"Phone": rc.get("Phone") or rc.get("phone"),
|
||||||
"Address": rc.get("Address") or rc.get("address"),
|
"Address": rc.get("Address") or rc.get("address"),
|
||||||
"Notes": rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")
|
"Notes": rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note"),
|
||||||
|
# Provenance accumulators (consumed below, stripped from helper keys).
|
||||||
|
"_source_files": set([rc_file]) if rc_file else set(),
|
||||||
|
"_merged_count": 1,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
gc = contact_by_norm_name[norm_name]
|
gc = contact_by_norm_name[norm_name]
|
||||||
@@ -974,6 +1313,9 @@ def main():
|
|||||||
gc["Address"] = rc.get("Address") or rc.get("address")
|
gc["Address"] = rc.get("Address") or rc.get("address")
|
||||||
if not gc["Notes"] and (rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")):
|
if not gc["Notes"] and (rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")):
|
||||||
gc["Notes"] = rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")
|
gc["Notes"] = rc.get("Notes") or rc.get("notes") or rc.get("Note") or rc.get("note")
|
||||||
|
if rc_file:
|
||||||
|
gc["_source_files"].add(rc_file)
|
||||||
|
gc["_merged_count"] += 1
|
||||||
|
|
||||||
if scoped_id:
|
if scoped_id:
|
||||||
contact_id_map[scoped_id] = contact_by_norm_name[norm_name]["Id"]
|
contact_id_map[scoped_id] = contact_by_norm_name[norm_name]["Id"]
|
||||||
@@ -1042,13 +1384,77 @@ def main():
|
|||||||
"LitterLetter": rl.get("LitterLetter") or rl.get("litterLetter"),
|
"LitterLetter": rl.get("LitterLetter") or rl.get("litterLetter"),
|
||||||
"_father_name": father_name,
|
"_father_name": father_name,
|
||||||
"_mother_name": mother_name,
|
"_mother_name": mother_name,
|
||||||
"_filename": filename
|
"_filename": filename,
|
||||||
|
# Provenance accumulators (canonical absorbs these during dedup below).
|
||||||
|
"_source_files": set([filename]) if filename else set(),
|
||||||
|
"_merged_count": 1,
|
||||||
|
# Virtual litters are reconstructed from a Stammbaum chart, not the
|
||||||
|
# Wurfchronik — flagged on the raw record's _filename == "Stammbaum".
|
||||||
|
"_virtual": filename == "Stammbaum",
|
||||||
}
|
}
|
||||||
resolved_litters.append(l_record)
|
resolved_litters.append(l_record)
|
||||||
litter_by_scoped_id[new_guid] = l_record
|
litter_by_scoped_id[new_guid] = l_record
|
||||||
|
|
||||||
print(f"Processed {len(resolved_litters)} litters.")
|
print(f"Processed {len(resolved_litters)} litters.")
|
||||||
|
|
||||||
|
# 3b. Deduplicate litters: same date + compatible parents → merge
|
||||||
|
# This handles the "sibling pairing" case: Stammbaum shows the same parental
|
||||||
|
# litter twice (once under the father branch, once under the mother branch),
|
||||||
|
# generating two separate litter records with the same date but only one of
|
||||||
|
# them has FatherId/MotherId resolved.
|
||||||
|
litter_canonical_map = {} # old_id -> canonical_id (for dedup within this step)
|
||||||
|
|
||||||
|
# Group by date for efficiency
|
||||||
|
by_date = {}
|
||||||
|
for l in resolved_litters:
|
||||||
|
by_date.setdefault(l["Date"], []).append(l)
|
||||||
|
|
||||||
|
litter_dedup_canonical = {} # old_litter_id -> canonical_litter_id
|
||||||
|
deduped_litters = []
|
||||||
|
|
||||||
|
for date_val, group in by_date.items():
|
||||||
|
# Partition into compatible subsets
|
||||||
|
sub_groups = []
|
||||||
|
for l in group:
|
||||||
|
placed = False
|
||||||
|
for sub in sub_groups:
|
||||||
|
if all(litter_compatible(l, member) for member in sub):
|
||||||
|
sub.append(l)
|
||||||
|
placed = True
|
||||||
|
break
|
||||||
|
if not placed:
|
||||||
|
sub_groups.append([l])
|
||||||
|
|
||||||
|
for sub in sub_groups:
|
||||||
|
if len(sub) == 1:
|
||||||
|
deduped_litters.append(sub[0])
|
||||||
|
litter_dedup_canonical[sub[0]["Id"]] = sub[0]["Id"]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pick the canonical record: prefer the one with parents set
|
||||||
|
canonical = next((l for l in sub if l.get("FatherId") or l.get("MotherId")), sub[0])
|
||||||
|
for l in sub:
|
||||||
|
litter_dedup_canonical[l["Id"]] = canonical["Id"]
|
||||||
|
if l is not canonical:
|
||||||
|
litter_id_map[l["Id"]] = canonical["Id"]
|
||||||
|
canonical["_source_files"] |= l.get("_source_files", set())
|
||||||
|
canonical["_merged_count"] += l.get("_merged_count", 1)
|
||||||
|
if not l.get("_virtual"):
|
||||||
|
canonical["_virtual"] = False
|
||||||
|
|
||||||
|
deduped_litters.append(canonical)
|
||||||
|
if len(sub) > 1:
|
||||||
|
merged_names = [l["Id"] for l in sub if l is not canonical]
|
||||||
|
print(f"Litter-Dedup: merged {len(sub)} same-date litters on {date_val} → {canonical['Name']} (absorbed: {', '.join(merged_names)})")
|
||||||
|
|
||||||
|
n_merged = len(resolved_litters) - len(deduped_litters)
|
||||||
|
if n_merged:
|
||||||
|
print(f"Litter-Dedup: {n_merged} redundant litter record(s) removed.")
|
||||||
|
resolved_litters = deduped_litters
|
||||||
|
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
|
||||||
|
|
||||||
|
# 4. Normalize and group Gerbils
|
||||||
|
|
||||||
# Helper to lookup litter dates for birth date estimation
|
# Helper to lookup litter dates for birth date estimation
|
||||||
def get_litter_date(l_id):
|
def get_litter_date(l_id):
|
||||||
if l_id in litter_by_scoped_id:
|
if l_id in litter_by_scoped_id:
|
||||||
@@ -1057,7 +1463,6 @@ def main():
|
|||||||
return d
|
return d
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 4. Normalize and group Gerbils
|
|
||||||
all_processed_gerbils = []
|
all_processed_gerbils = []
|
||||||
for rg in raw_gerbils:
|
for rg in raw_gerbils:
|
||||||
filename = rg.get("_filename")
|
filename = rg.get("_filename")
|
||||||
@@ -1320,6 +1725,8 @@ def main():
|
|||||||
"IsResident": a_id in stammbaum_resident_ids,
|
"IsResident": a_id in stammbaum_resident_ids,
|
||||||
"parentRefs": a.get("parentRefs", []),
|
"parentRefs": a.get("parentRefs", []),
|
||||||
"_photos": a.get("photos", []),
|
"_photos": a.get("photos", []),
|
||||||
|
"_conflict": bool(a.get("conflict")),
|
||||||
|
"_resolved_by_decision": bool(a.get("resolvedByDecision")),
|
||||||
"_old_scoped_litter_id": scoped_litter_id,
|
"_old_scoped_litter_id": scoped_litter_id,
|
||||||
"_eff_dob": dob_val or "2010-01-01",
|
"_eff_dob": dob_val or "2010-01-01",
|
||||||
"_birth_date": dob_val,
|
"_birth_date": dob_val,
|
||||||
@@ -1478,6 +1885,69 @@ def main():
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def build_provenance(records, best_g, extra_notes=None, field_source=None):
|
||||||
|
"""Aggregate data-provenance across every raw record merged into one
|
||||||
|
resolved gerbil. Returns a JSON string (stored on the Gerbil entity as a
|
||||||
|
nullable text column) so the Rennmausakte can show where the entry came
|
||||||
|
from. `records` is the list of raw records that became this gerbil;
|
||||||
|
`best_g` is the chosen primary record. `field_source` maps a significant
|
||||||
|
field name (DateOfBirth/Genotype/…) to the raw record that supplied its
|
||||||
|
final value, so the history can name the exact file for each fact."""
|
||||||
|
source_files = set()
|
||||||
|
from_wurfchronik = False
|
||||||
|
any_conflict = False
|
||||||
|
any_decision = False
|
||||||
|
for r in records:
|
||||||
|
source_files |= _record_source_files(r)
|
||||||
|
for f in _record_source_files(r):
|
||||||
|
if "wurfchronik" in f.lower():
|
||||||
|
from_wurfchronik = True
|
||||||
|
if r.get("_conflict"):
|
||||||
|
any_conflict = True
|
||||||
|
if r.get("_resolved_by_decision"):
|
||||||
|
any_decision = True
|
||||||
|
|
||||||
|
notes = []
|
||||||
|
merged_count = len(records)
|
||||||
|
if merged_count > 1:
|
||||||
|
notes.append(f"aus {merged_count} Datensätzen zusammengeführt")
|
||||||
|
if any_decision:
|
||||||
|
notes.append("per manueller Entscheidung zugeordnet")
|
||||||
|
if any_conflict:
|
||||||
|
notes.append("Konflikt per Entscheidung gelöst")
|
||||||
|
|
||||||
|
# Parent derivation: surface the strongest parentRef method/confidence
|
||||||
|
# the primary record carries (chart-position etc.).
|
||||||
|
parent_method = None
|
||||||
|
parent_confidence = None
|
||||||
|
for ref in best_g.get("parentRefs", []) or []:
|
||||||
|
if ref.get("method") and not parent_method:
|
||||||
|
parent_method = ref.get("method")
|
||||||
|
if ref.get("confidence") and not parent_confidence:
|
||||||
|
parent_confidence = ref.get("confidence")
|
||||||
|
|
||||||
|
if extra_notes:
|
||||||
|
for n in extra_notes:
|
||||||
|
if n and n not in notes:
|
||||||
|
notes.append(n)
|
||||||
|
|
||||||
|
history = _build_gerbil_history(
|
||||||
|
records, best_g, field_source or {},
|
||||||
|
parent_method=parent_method,
|
||||||
|
any_decision=any_decision,
|
||||||
|
any_conflict=any_conflict,
|
||||||
|
conflict_notes=extra_notes or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_entity_provenance(
|
||||||
|
source_files,
|
||||||
|
merged_count,
|
||||||
|
notes=notes,
|
||||||
|
from_wurfchronik=from_wurfchronik,
|
||||||
|
extra={"parentMethod": parent_method, "parentConfidence": parent_confidence},
|
||||||
|
history=history,
|
||||||
|
)
|
||||||
|
|
||||||
# Group gerbils by name to perform deduplication
|
# Group gerbils by name to perform deduplication
|
||||||
gerbil_groups = {}
|
gerbil_groups = {}
|
||||||
for g in all_processed_gerbils:
|
for g in all_processed_gerbils:
|
||||||
@@ -1501,6 +1971,7 @@ def main():
|
|||||||
if is_placeholder:
|
if is_placeholder:
|
||||||
# Placeholders: do NOT merge, keep all separate
|
# Placeholders: do NOT merge, keep all separate
|
||||||
for g in group:
|
for g in group:
|
||||||
|
g["Provenance"] = build_provenance([g], g)
|
||||||
resolved_gerbils.append(g)
|
resolved_gerbils.append(g)
|
||||||
gerbil_id_map[g["Id"]] = g["Id"]
|
gerbil_id_map[g["Id"]] = g["Id"]
|
||||||
continue
|
continue
|
||||||
@@ -1521,6 +1992,7 @@ def main():
|
|||||||
for sub in sub_groups:
|
for sub in sub_groups:
|
||||||
if len(sub) == 1:
|
if len(sub) == 1:
|
||||||
g = sub[0]
|
g = sub[0]
|
||||||
|
g["Provenance"] = build_provenance([g], g)
|
||||||
resolved_gerbils.append(g)
|
resolved_gerbils.append(g)
|
||||||
gerbil_id_map[g["Id"]] = g["Id"]
|
gerbil_id_map[g["Id"]] = g["Id"]
|
||||||
continue
|
continue
|
||||||
@@ -1546,13 +2018,24 @@ def main():
|
|||||||
merged_notes = []
|
merged_notes = []
|
||||||
if best_g["Notes"]:
|
if best_g["Notes"]:
|
||||||
merged_notes.append(best_g["Notes"])
|
merged_notes.append(best_g["Notes"])
|
||||||
|
|
||||||
# Merge photos
|
# Merge photos
|
||||||
merged_photos = list(best_g.get("_photos", []))
|
merged_photos = list(best_g.get("_photos", []))
|
||||||
|
|
||||||
# Track sources for debugging
|
# Track sources for debugging
|
||||||
sources = [best_g["_filename"]]
|
sources = [best_g["_filename"]]
|
||||||
|
|
||||||
|
# Per-field file attribution: which raw record supplied each final
|
||||||
|
# field value. Seed with best_g for every field it already carries;
|
||||||
|
# the fill loop and voting loop update it as winners change.
|
||||||
|
ATTRIB_FIELDS = ["DateOfBirth", "DateOfDeath", "Gender", "Genotype",
|
||||||
|
"ColorVarietyId", "Name"]
|
||||||
|
field_source = {}
|
||||||
|
for fld in ATTRIB_FIELDS:
|
||||||
|
v = best_g.get(fld)
|
||||||
|
if v and v != "unknown":
|
||||||
|
field_source[fld] = best_g
|
||||||
|
|
||||||
for g in sub:
|
for g in sub:
|
||||||
if g == best_g:
|
if g == best_g:
|
||||||
continue
|
continue
|
||||||
@@ -1569,16 +2052,20 @@ def main():
|
|||||||
best_g["LitterId"] = g["LitterId"]
|
best_g["LitterId"] = g["LitterId"]
|
||||||
if not best_g["DateOfBirth"] and g["DateOfBirth"]:
|
if not best_g["DateOfBirth"] and g["DateOfBirth"]:
|
||||||
best_g["DateOfBirth"] = g["DateOfBirth"]
|
best_g["DateOfBirth"] = g["DateOfBirth"]
|
||||||
|
field_source["DateOfBirth"] = g
|
||||||
if not best_g["DateOfDeath"] and g["DateOfDeath"]:
|
if not best_g["DateOfDeath"] and g["DateOfDeath"]:
|
||||||
best_g["DateOfDeath"] = g["DateOfDeath"]
|
best_g["DateOfDeath"] = g["DateOfDeath"]
|
||||||
|
field_source["DateOfDeath"] = g
|
||||||
if not best_g["CauseOfDeath"] and g["CauseOfDeath"]:
|
if not best_g["CauseOfDeath"] and g["CauseOfDeath"]:
|
||||||
best_g["CauseOfDeath"] = g["CauseOfDeath"]
|
best_g["CauseOfDeath"] = g["CauseOfDeath"]
|
||||||
if not best_g["GoHomeDate"] and g["GoHomeDate"]:
|
if not best_g["GoHomeDate"] and g["GoHomeDate"]:
|
||||||
best_g["GoHomeDate"] = g["GoHomeDate"]
|
best_g["GoHomeDate"] = g["GoHomeDate"]
|
||||||
if not best_g["Genotype"] and g["Genotype"]:
|
if not best_g["Genotype"] and g["Genotype"]:
|
||||||
best_g["Genotype"] = g["Genotype"]
|
best_g["Genotype"] = g["Genotype"]
|
||||||
|
field_source["Genotype"] = g
|
||||||
if not best_g["ColorVarietyId"] and g["ColorVarietyId"]:
|
if not best_g["ColorVarietyId"] and g["ColorVarietyId"]:
|
||||||
best_g["ColorVarietyId"] = g["ColorVarietyId"]
|
best_g["ColorVarietyId"] = g["ColorVarietyId"]
|
||||||
|
field_source["ColorVarietyId"] = g
|
||||||
if not best_g["OriginContactId"] and g["OriginContactId"]:
|
if not best_g["OriginContactId"] and g["OriginContactId"]:
|
||||||
best_g["OriginContactId"] = g["OriginContactId"]
|
best_g["OriginContactId"] = g["OriginContactId"]
|
||||||
if not best_g["ReceiverContactId"] and g["ReceiverContactId"]:
|
if not best_g["ReceiverContactId"] and g["ReceiverContactId"]:
|
||||||
@@ -1589,10 +2076,12 @@ def main():
|
|||||||
# Reconcile Gender: prefer a known gender over unknown, and prefer stammbaum over other sources
|
# Reconcile Gender: prefer a known gender over unknown, and prefer stammbaum over other sources
|
||||||
if best_g["Gender"] == "unknown" and g["Gender"] != "unknown":
|
if best_g["Gender"] == "unknown" and g["Gender"] != "unknown":
|
||||||
best_g["Gender"] = g["Gender"]
|
best_g["Gender"] = g["Gender"]
|
||||||
|
field_source["Gender"] = g
|
||||||
elif best_g["Gender"] != "unknown" and g["Gender"] != "unknown" and best_g["Gender"] != g["Gender"]:
|
elif best_g["Gender"] != "unknown" and g["Gender"] != "unknown" and best_g["Gender"] != g["Gender"]:
|
||||||
if g["ImportSource"] and "stammbaum" in g["ImportSource"].lower():
|
if g["ImportSource"] and "stammbaum" in g["ImportSource"].lower():
|
||||||
if not best_g["ImportSource"] or "stammbaum" not in best_g["ImportSource"].lower():
|
if not best_g["ImportSource"] or "stammbaum" not in best_g["ImportSource"].lower():
|
||||||
best_g["Gender"] = g["Gender"]
|
best_g["Gender"] = g["Gender"]
|
||||||
|
field_source["Gender"] = g
|
||||||
|
|
||||||
# Status precedence: Deceased > GivenAway > Breeding/Pet
|
# Status precedence: Deceased > GivenAway > Breeding/Pet
|
||||||
if g["Status"] == "Deceased":
|
if g["Status"] == "Deceased":
|
||||||
@@ -1606,6 +2095,7 @@ def main():
|
|||||||
merged_notes.append(g["Notes"])
|
merged_notes.append(g["Notes"])
|
||||||
|
|
||||||
# Reconcile fields based on number of source files supporting them
|
# Reconcile fields based on number of source files supporting them
|
||||||
|
conflict_notes = []
|
||||||
for field in ["DateOfBirth", "DateOfDeath", "Gender", "Genotype", "ColorVarietyId"]:
|
for field in ["DateOfBirth", "DateOfDeath", "Gender", "Genotype", "ColorVarietyId"]:
|
||||||
votes = {}
|
votes = {}
|
||||||
for g in sub:
|
for g in sub:
|
||||||
@@ -1616,7 +2106,18 @@ def main():
|
|||||||
votes[val] = votes.get(val, 0) + sources_count
|
votes[val] = votes.get(val, 0) + sources_count
|
||||||
if votes:
|
if votes:
|
||||||
best_val = max(votes, key=votes.get)
|
best_val = max(votes, key=votes.get)
|
||||||
|
# If the records disagreed on a field, the merge had to pick a
|
||||||
|
# winner — record that as a provenance note.
|
||||||
|
if len(votes) > 1:
|
||||||
|
conflict_notes.append(
|
||||||
|
f"Konflikt bei {PROV_FIELD_LABELS[field]} per Mehrheitsentscheidung gelöst"
|
||||||
|
)
|
||||||
best_g[field] = best_val
|
best_g[field] = best_val
|
||||||
|
# Attribute the winning value to a record that actually holds
|
||||||
|
# it, so the history names the right file.
|
||||||
|
winner = next((g for g in sub if g.get(field) == best_val), None)
|
||||||
|
if winner is not None:
|
||||||
|
field_source[field] = winner
|
||||||
# Keep helper fields in sync if we changed DateOfBirth
|
# Keep helper fields in sync if we changed DateOfBirth
|
||||||
if field == "DateOfBirth":
|
if field == "DateOfBirth":
|
||||||
best_g["_birth_date"] = best_val
|
best_g["_birth_date"] = best_val
|
||||||
@@ -1624,12 +2125,15 @@ def main():
|
|||||||
|
|
||||||
if merged_notes:
|
if merged_notes:
|
||||||
best_g["Notes"] = " | ".join(merged_notes)
|
best_g["Notes"] = " | ".join(merged_notes)
|
||||||
|
|
||||||
best_g["_photos"] = merged_photos
|
best_g["_photos"] = merged_photos
|
||||||
|
best_g["Provenance"] = build_provenance(
|
||||||
|
sub, best_g, extra_notes=conflict_notes, field_source=field_source
|
||||||
|
)
|
||||||
|
|
||||||
# Print merge trace
|
# Print merge trace
|
||||||
print(f"Deduplicated same-animal name '{best_g['Name']}': merged {len(sub)} entries across files: {', '.join(sources)}")
|
print(f"Deduplicated same-animal name '{best_g['Name']}': merged {len(sub)} entries across files: {', '.join(sources)}")
|
||||||
|
|
||||||
resolved_gerbils.append(best_g)
|
resolved_gerbils.append(best_g)
|
||||||
gerbil_id_map[best_g["Id"]] = best_g["Id"]
|
gerbil_id_map[best_g["Id"]] = best_g["Id"]
|
||||||
|
|
||||||
@@ -1685,6 +2189,8 @@ def main():
|
|||||||
del g["_birth_date"]
|
del g["_birth_date"]
|
||||||
del g["_filename"]
|
del g["_filename"]
|
||||||
del g["_old_id"]
|
del g["_old_id"]
|
||||||
|
g.pop("_conflict", None)
|
||||||
|
g.pop("_resolved_by_decision", None)
|
||||||
|
|
||||||
# Gather final valid gerbil IDs
|
# Gather final valid gerbil IDs
|
||||||
valid_gerbil_ids = {g["Id"] for g in resolved_gerbils}
|
valid_gerbil_ids = {g["Id"] for g in resolved_gerbils}
|
||||||
@@ -1716,59 +2222,71 @@ def main():
|
|||||||
# Parent Resolver (Global Name Matching)
|
# Parent Resolver (Global Name Matching)
|
||||||
resolved_fathers = 0
|
resolved_fathers = 0
|
||||||
resolved_mothers = 0
|
resolved_mothers = 0
|
||||||
|
|
||||||
|
gerbil_by_id_final = {g["Id"]: g for g in resolved_gerbils}
|
||||||
|
|
||||||
|
def _final_gender(gid):
|
||||||
|
g = gerbil_by_id_final.get(gid)
|
||||||
|
return g["Gender"] if g else None
|
||||||
|
|
||||||
|
def _resolve_name(name, prefer_gender, litter_date):
|
||||||
|
"""Resolve a parent name to the best matching final gerbil.
|
||||||
|
|
||||||
|
Gender is a PREFERENCE, not a hard filter: a reversed parent (e.g. a
|
||||||
|
female listed in the father position, as the Stammbaum often does) still
|
||||||
|
resolves to a gerbil — the role is corrected afterwards by gender. This
|
||||||
|
is what previously left FatherId/MotherId null (the candidate was
|
||||||
|
filtered out for having the "wrong" gender for its slot).
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
cands = []
|
||||||
|
for c in gerbil_by_norm_name.get(normalize_name(name), []):
|
||||||
|
final_id = gerbil_id_map.get(c["Id"])
|
||||||
|
if not final_id:
|
||||||
|
continue
|
||||||
|
final_c = gerbil_by_id_final.get(final_id)
|
||||||
|
if not final_c:
|
||||||
|
continue
|
||||||
|
# Parent must be age-plausible: born before the litter and within the
|
||||||
|
# gerbil lifespan (skips e.g. a 2013 animal for a 2022 litter).
|
||||||
|
if not parent_age_plausible(final_c["DateOfBirth"], litter_date):
|
||||||
|
continue
|
||||||
|
cands.append(final_c)
|
||||||
|
if not cands:
|
||||||
|
return None
|
||||||
|
# Prefer the gender expected for this role, then unknown, then anything.
|
||||||
|
for pool in (
|
||||||
|
[c for c in cands if c["Gender"] == prefer_gender],
|
||||||
|
[c for c in cands if c["Gender"] == "unknown"],
|
||||||
|
cands,
|
||||||
|
):
|
||||||
|
if pool:
|
||||||
|
return pool[0]
|
||||||
|
return None
|
||||||
|
|
||||||
for l in resolved_litters:
|
for l in resolved_litters:
|
||||||
# Match Father by Name
|
# Pre-check: if _father_name points to a known female and _mother_name to a
|
||||||
f_name = l["_father_name"]
|
# known male → swap names (Stammbaum positions reversed). Helps the name
|
||||||
if f_name and not l["FatherId"]:
|
# resolver pick the right same-name candidate before role normalization.
|
||||||
f_norm = normalize_name(f_name)
|
f_name_pre = l.get("_father_name", "")
|
||||||
candidates = gerbil_by_norm_name.get(f_norm, [])
|
m_name_pre = l.get("_mother_name", "")
|
||||||
valid_candidates = []
|
if f_name_pre and m_name_pre:
|
||||||
for c in candidates:
|
f_gender = next((g["Gender"] for g in gerbil_by_norm_name.get(normalize_name(f_name_pre), []) if g["Gender"] != "unknown"), None)
|
||||||
# Map to final deduplicated ID
|
m_gender = next((g["Gender"] for g in gerbil_by_norm_name.get(normalize_name(m_name_pre), []) if g["Gender"] != "unknown"), None)
|
||||||
final_id = gerbil_id_map.get(c["Id"])
|
if f_gender == "female" and m_gender == "male":
|
||||||
if not final_id:
|
l["_father_name"], l["_mother_name"] = m_name_pre, f_name_pre
|
||||||
continue
|
|
||||||
# Retrieve final record
|
if l["_father_name"] and not l["FatherId"]:
|
||||||
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
|
cand = _resolve_name(l["_father_name"], "male", l["Date"])
|
||||||
if final_c and final_c["Gender"] in ["male", "unknown"]:
|
if cand:
|
||||||
# Ensure parent is born before litter if birth date is known
|
l["FatherId"] = cand["Id"]
|
||||||
if l["Date"] and final_c["DateOfBirth"]:
|
|
||||||
if final_c["DateOfBirth"] < l["Date"]:
|
|
||||||
valid_candidates.append(final_c)
|
|
||||||
else:
|
|
||||||
valid_candidates.append(final_c)
|
|
||||||
|
|
||||||
if len(valid_candidates) == 1:
|
|
||||||
l["FatherId"] = valid_candidates[0]["Id"]
|
|
||||||
resolved_fathers += 1
|
|
||||||
elif len(valid_candidates) > 1:
|
|
||||||
l["FatherId"] = valid_candidates[0]["Id"]
|
|
||||||
resolved_fathers += 1
|
resolved_fathers += 1
|
||||||
|
|
||||||
# Match Mother by Name
|
if l["_mother_name"] and not l["MotherId"]:
|
||||||
m_name = l["_mother_name"]
|
cand = _resolve_name(l["_mother_name"], "female", l["Date"])
|
||||||
if m_name and not l["MotherId"]:
|
if cand:
|
||||||
m_norm = normalize_name(m_name)
|
l["MotherId"] = cand["Id"]
|
||||||
candidates = gerbil_by_norm_name.get(m_norm, [])
|
|
||||||
valid_candidates = []
|
|
||||||
for c in candidates:
|
|
||||||
final_id = gerbil_id_map.get(c["Id"])
|
|
||||||
if not final_id:
|
|
||||||
continue
|
|
||||||
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
|
|
||||||
if final_c and final_c["Gender"] in ["female", "unknown"]:
|
|
||||||
if l["Date"] and final_c["DateOfBirth"]:
|
|
||||||
if final_c["DateOfBirth"] < l["Date"]:
|
|
||||||
valid_candidates.append(final_c)
|
|
||||||
else:
|
|
||||||
valid_candidates.append(final_c)
|
|
||||||
|
|
||||||
if len(valid_candidates) == 1:
|
|
||||||
l["MotherId"] = valid_candidates[0]["Id"]
|
|
||||||
resolved_mothers += 1
|
|
||||||
elif len(valid_candidates) > 1:
|
|
||||||
l["MotherId"] = valid_candidates[0]["Id"]
|
|
||||||
resolved_mothers += 1
|
resolved_mothers += 1
|
||||||
|
|
||||||
# Cleanup internal keys
|
# Cleanup internal keys
|
||||||
@@ -1776,8 +2294,159 @@ def main():
|
|||||||
del l["_mother_name"]
|
del l["_mother_name"]
|
||||||
del l["_filename"]
|
del l["_filename"]
|
||||||
|
|
||||||
|
# Role normalization: assign each resolved parent to the role matching its
|
||||||
|
# gender, eliminate self-pairings (same animal in both roles), and never let
|
||||||
|
# impossible duplicates survive (two males / two females). This corrects
|
||||||
|
# reversed Stammbaum positions including the cases the simple swap missed
|
||||||
|
# (one parent of "unknown" gender, or a self-paired litter).
|
||||||
|
role_fixes = 0
|
||||||
|
for l in resolved_litters:
|
||||||
|
father, mother = assign_parent_roles(l.get("FatherId"), l.get("MotherId"), _final_gender)
|
||||||
|
if (l.get("FatherId"), l.get("MotherId")) != (father, mother):
|
||||||
|
role_fixes += 1
|
||||||
|
l["FatherId"] = father
|
||||||
|
l["MotherId"] = mother
|
||||||
|
if role_fixes:
|
||||||
|
print(f"Role-normalization: corrected {role_fixes} litter(s) (gender roles / self-pairings).")
|
||||||
|
|
||||||
|
# Parent-age sanity check: drop any resolved parent that cannot belong to the
|
||||||
|
# litter — born after the offspring, or more than a gerbil lifespan earlier.
|
||||||
|
# Catches mis-resolved links the name matcher still let through (e.g. Jayjay,
|
||||||
|
# *2013, wrongly attached to Solice's 2022 litter).
|
||||||
|
age_drops = []
|
||||||
|
for l in resolved_litters:
|
||||||
|
ldate = l.get("Date")
|
||||||
|
for role in ("FatherId", "MotherId"):
|
||||||
|
pid = l.get(role)
|
||||||
|
if not pid:
|
||||||
|
continue
|
||||||
|
p = gerbil_by_id_final.get(pid)
|
||||||
|
if p and not parent_age_plausible(p.get("DateOfBirth"), ldate):
|
||||||
|
age_drops.append((l.get("Name"), role, p.get("Name"), p.get("DateOfBirth"), ldate))
|
||||||
|
l[role] = None
|
||||||
|
if age_drops:
|
||||||
|
print(f"Parent-age sanity check: dropped {len(age_drops)} implausible parent link(s):")
|
||||||
|
for lname, role, pname, pdob, ldate in age_drops[:20]:
|
||||||
|
print(f" {lname}: {role}={pname} (*{pdob}) vs litter {ldate}")
|
||||||
|
|
||||||
print(f"Globally resolved {resolved_fathers} fathers and {resolved_mothers} mothers.")
|
print(f"Globally resolved {resolved_fathers} fathers and {resolved_mothers} mothers.")
|
||||||
|
|
||||||
|
|
||||||
|
# 5b. Second-pass litter dedup: now that FatherId/MotherId are known,
|
||||||
|
# merge litters that have the same date AND the same parents.
|
||||||
|
# This is the core of the "sibling pairing" fix: Blue Wave and Sunny Sky
|
||||||
|
# both come from Wonderman × Unique — their two separate litter records
|
||||||
|
# must now become one, so their children share the same LitterId.
|
||||||
|
litter_by_id_post = {l["Id"]: l for l in resolved_litters}
|
||||||
|
gerbil_by_litter = {}
|
||||||
|
for g in resolved_gerbils:
|
||||||
|
lid = g.get("LitterId")
|
||||||
|
if lid:
|
||||||
|
gerbil_by_litter.setdefault(lid, []).append(g)
|
||||||
|
|
||||||
|
def _litter_same_parents(l1, l2):
|
||||||
|
"""Strict: same date + both parents known and matching."""
|
||||||
|
if l1["Date"] != l2["Date"]:
|
||||||
|
return False
|
||||||
|
f1, m1 = l1.get("FatherId"), l1.get("MotherId")
|
||||||
|
f2, m2 = l2.get("FatherId"), l2.get("MotherId")
|
||||||
|
if not f1 or not f2 or not m1 or not m2:
|
||||||
|
return False
|
||||||
|
return f1 == f2 and m1 == m2
|
||||||
|
|
||||||
|
by_date2 = {}
|
||||||
|
for l in resolved_litters:
|
||||||
|
by_date2.setdefault(l["Date"], []).append(l)
|
||||||
|
|
||||||
|
deduped2 = []
|
||||||
|
litter_remap2 = {} # old_id -> canonical_id
|
||||||
|
|
||||||
|
for date_val, group in by_date2.items():
|
||||||
|
sub_groups = []
|
||||||
|
for l in group:
|
||||||
|
placed = False
|
||||||
|
for sub in sub_groups:
|
||||||
|
if all(_litter_same_parents(l, m) for m in sub):
|
||||||
|
sub.append(l)
|
||||||
|
placed = True
|
||||||
|
break
|
||||||
|
if not placed:
|
||||||
|
sub_groups.append([l])
|
||||||
|
|
||||||
|
for sub in sub_groups:
|
||||||
|
# Prefer the canonical that has the most children
|
||||||
|
canonical = max(sub, key=lambda l: len(gerbil_by_litter.get(l["Id"], [])))
|
||||||
|
for l in sub:
|
||||||
|
litter_remap2[l["Id"]] = canonical["Id"]
|
||||||
|
if l is not canonical:
|
||||||
|
canonical["_source_files"] |= l.get("_source_files", set())
|
||||||
|
canonical["_merged_count"] += l.get("_merged_count", 1)
|
||||||
|
if not l.get("_virtual"):
|
||||||
|
canonical["_virtual"] = False
|
||||||
|
deduped2.append(canonical)
|
||||||
|
if len(sub) > 1:
|
||||||
|
siblings = [g["Name"] for l in sub for g in gerbil_by_litter.get(l["Id"], []) if l is not canonical]
|
||||||
|
print(f"Sibling-Litter-Merge on {date_val}: {canonical['Name']} absorbed sibling half — children now share LitterId: {[g['Name'] for g in gerbil_by_litter.get(canonical['Id'], [])] + siblings}")
|
||||||
|
|
||||||
|
# Remap LitterId in all gerbils
|
||||||
|
n_remapped = 0
|
||||||
|
for g in resolved_gerbils:
|
||||||
|
old_lid = g.get("LitterId")
|
||||||
|
if old_lid and old_lid in litter_remap2 and litter_remap2[old_lid] != old_lid:
|
||||||
|
g["LitterId"] = litter_remap2[old_lid]
|
||||||
|
n_remapped += 1
|
||||||
|
|
||||||
|
n_merged2 = len(resolved_litters) - len(deduped2)
|
||||||
|
if n_merged2:
|
||||||
|
print(f"Sibling-Litter-Dedup: {n_merged2} additional litter record(s) merged ({n_remapped} gerbil LitterIds remapped).")
|
||||||
|
resolved_litters = deduped2
|
||||||
|
litter_by_scoped_id = {l["Id"]: l for l in resolved_litters}
|
||||||
|
|
||||||
|
# Datenherkunft for litters: which source files contributed, whether this is
|
||||||
|
# a Wurfchronik litter vs a Stammbaum-reconstructed ("virtual") litter, how
|
||||||
|
# many raw records merged into it, plus human-readable notes. Accumulators
|
||||||
|
# (_source_files/_merged_count/_virtual) were filled during the two dedup
|
||||||
|
# passes above; strip them after use.
|
||||||
|
for l in resolved_litters:
|
||||||
|
l_source_files = l.pop("_source_files", set())
|
||||||
|
l_merged_count = l.pop("_merged_count", 1)
|
||||||
|
is_virtual = l.pop("_virtual", False)
|
||||||
|
l_from_wurfchronik = any("wurfchronik" in str(f).lower() for f in l_source_files)
|
||||||
|
l_notes = []
|
||||||
|
if is_virtual and not l_from_wurfchronik:
|
||||||
|
l_notes.append("aus Stammbaum-Diagramm rekonstruiert")
|
||||||
|
elif l_from_wurfchronik:
|
||||||
|
l_notes.append("aus Wurfchronik")
|
||||||
|
if l_merged_count > 1:
|
||||||
|
l_notes.append(f"aus {l_merged_count} Datensätzen zusammengeführt")
|
||||||
|
l_notes.append("Geschwister-Würfe zusammengeführt")
|
||||||
|
|
||||||
|
# Chronological, file-attributed history for the litter.
|
||||||
|
l_files_sorted = sorted({f for f in l_source_files if f})
|
||||||
|
l_history = []
|
||||||
|
first_file = l_files_sorted[0] if l_files_sorted else None
|
||||||
|
if is_virtual and not l_from_wurfchronik:
|
||||||
|
if first_file:
|
||||||
|
l_history.append(
|
||||||
|
f"Aus Stammbaum-Diagramm rekonstruiert ({_quote_file(first_file)})."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
l_history.append("Aus Stammbaum-Diagramm rekonstruiert.")
|
||||||
|
elif first_file:
|
||||||
|
l_history.append(f"Wurf aus Wurfchronik {_quote_file(first_file)}.")
|
||||||
|
else:
|
||||||
|
l_history.append("Wurf im Import gefunden.")
|
||||||
|
for f in l_files_sorted[1:]:
|
||||||
|
l_history.append(
|
||||||
|
f"Auch in {_quote_file(f)} gefunden → Datensätze zusammengeführt."
|
||||||
|
)
|
||||||
|
if l_merged_count > 1:
|
||||||
|
l_history.append("Geschwister-Würfe zusammengeführt.")
|
||||||
|
l["Provenance"] = build_entity_provenance(
|
||||||
|
l_source_files, l_merged_count, notes=l_notes,
|
||||||
|
from_wurfchronik=l_from_wurfchronik, history=l_history,
|
||||||
|
)
|
||||||
|
|
||||||
# Set IsBreeder and IsReceiver flags on contacts
|
# Set IsBreeder and IsReceiver flags on contacts
|
||||||
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
|
breeder_ids = {g["OriginContactId"] for g in resolved_gerbils if g.get("OriginContactId")}
|
||||||
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}
|
receiver_ids = {g["ReceiverContactId"] for g in resolved_gerbils if g.get("ReceiverContactId")}
|
||||||
@@ -1790,6 +2459,37 @@ def main():
|
|||||||
c["IsBreeder"] = is_breeder
|
c["IsBreeder"] = is_breeder
|
||||||
c["IsReceiver"] = is_receiver
|
c["IsReceiver"] = is_receiver
|
||||||
|
|
||||||
|
# Datenherkunft: where this (deduplicated) contact came from, plus the
|
||||||
|
# role we inferred. Accumulator keys (_source_files/_merged_count) were
|
||||||
|
# filled during the contact dedup above; strip them after use.
|
||||||
|
c_source_files = c.pop("_source_files", set())
|
||||||
|
c_merged_count = c.pop("_merged_count", 1)
|
||||||
|
c_notes = []
|
||||||
|
if c_merged_count > 1:
|
||||||
|
c_notes.append(f"aus {c_merged_count} Datensätzen zusammengeführt")
|
||||||
|
if is_breeder:
|
||||||
|
c_notes.append("als Züchter erkannt")
|
||||||
|
if is_receiver:
|
||||||
|
c_notes.append("als Abnehmer erkannt")
|
||||||
|
|
||||||
|
# Chronological, file-attributed history for the contact.
|
||||||
|
c_files_sorted = sorted({f for f in c_source_files if f})
|
||||||
|
c_history = []
|
||||||
|
role_word = "Züchter" if is_breeder else "Abnehmer"
|
||||||
|
if c_files_sorted:
|
||||||
|
c_history.append(f"In {_quote_file(c_files_sorted[0])} als {role_word} erkannt.")
|
||||||
|
for f in c_files_sorted[1:]:
|
||||||
|
c_history.append(
|
||||||
|
f"Auch in {_quote_file(f)} gefunden → Datensätze zusammengeführt."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
c_history.append(f"Im Import als {role_word} erkannt.")
|
||||||
|
if is_breeder and is_receiver:
|
||||||
|
c_history.append("Sowohl als Züchter als auch als Abnehmer geführt.")
|
||||||
|
c["Provenance"] = build_entity_provenance(
|
||||||
|
c_source_files, c_merged_count, notes=c_notes, history=c_history
|
||||||
|
)
|
||||||
|
|
||||||
# Set and map gerbilPhotos
|
# Set and map gerbilPhotos
|
||||||
resolved_photos = []
|
resolved_photos = []
|
||||||
for g in resolved_gerbils:
|
for g in resolved_gerbils:
|
||||||
|
|||||||
@@ -4,32 +4,21 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
|
|||||||
|
|
||||||
## Überblick
|
## Überblick
|
||||||
|
|
||||||
- Rohe Tier-Einträge aus den Stammbäumen: **2449**
|
- Rohe Tier-Einträge aus den Stammbäumen: **2548**
|
||||||
- Nach Zusammenführung (eindeutige Tiere): **1000**
|
- Nach Zusammenführung (eindeutige Tiere): **1044**
|
||||||
- davon mit Geburtsdatum: 677
|
- davon mit Geburtsdatum: 685
|
||||||
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 462
|
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 476
|
||||||
- Konflikte zur Klärung: **4**
|
- Konflikte zur Klärung: **7**
|
||||||
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
|
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **380**
|
||||||
- Fotos zugeordnet: **422**
|
- Fotos zugeordnet: **435**
|
||||||
- Würfe aus der Wurfchronik: **752**
|
- Würfe aus der Wurfchronik: **752**
|
||||||
- Tiere mit Wurf verknüpft: **269** (davon über Geburtsdatum **und** Eltern: 165, nur über Geburtsdatum: 104; mehrdeutig: 16)
|
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 168, nur über Geburtsdatum: 102; mehrdeutig: 17)
|
||||||
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
|
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)
|
||||||
|
|
||||||
## Zusammenführungs-Schlüssel
|
## Zusammenführungs-Schlüssel
|
||||||
|
|
||||||
Tiere wurden zusammengeführt über **normalisierter Rufname + Geburtsdatum**, mit der **Zucht als Unterscheidungsmerkmal** (Julians Regel: die `[Klammern]` in der Wurfchronik und das `of/von <Linie>`-Suffix der Stammbäume bezeichnen beide die Zucht und werden zusammengeführt — z. B. `[ZdkC]` ≙ `von den Kleinen Chaoten`). Namensvarianten (z. B. `v.d.` ↔ `von den`, `gen.`-Spitznamen) werden als `nameVariants` erhalten.
|
Tiere wurden zusammengeführt über **normalisierter Rufname + Geburtsdatum**, mit der **Zucht als Unterscheidungsmerkmal** (Julians Regel: die `[Klammern]` in der Wurfchronik und das `of/von <Linie>`-Suffix der Stammbäume bezeichnen beide die Zucht und werden zusammengeführt — z. B. `[ZdkC]` ≙ `von den Kleinen Chaoten`). Namensvarianten (z. B. `v.d.` ↔ `von den`, `gen.`-Spitznamen) werden als `nameVariants` erhalten.
|
||||||
|
|
||||||
### Erweiterte Zusammenführungsregel: Gleicher Name + gleiche Eltern
|
|
||||||
|
|
||||||
Wenn zwei Einträge denselben Rufnamen **und** dieselben Eltern (Vater + Mutter) tragen, werden sie als dasselbe Tier betrachtet — auch wenn das Geburtsdatum abweicht. Das DOB des Eintrags, in dem das Tier Proband ist (`_gen == 0`), hat Priorität. Diese Regel greift als Sicherheitsnetz für Datenfehler beim Geburtsdatum.
|
|
||||||
|
|
||||||
**Im aktuellen Datensatz ausgelöst für:**
|
|
||||||
|
|
||||||
| Tier | DOB (falsch) | DOB (korrekt) | Vater | Mutter | Lösung |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| Kazuya von den Kleinen Chaoten | 22.08.2018 | 14.07.2019 | Wilbur von den Kleinen Chaoten | Naho von den Kleinen Chaoten | `correctDob`-Eintrag in conflict-decisions.json → DOB vor Dedup remapped |
|
|
||||||
|
|
||||||
|
|
||||||
### Gleicher Name + Geburtsdatum, aber unterschiedliche Zucht (NICHT zusammengeführt — bitte prüfen)
|
### Gleicher Name + Geburtsdatum, aber unterschiedliche Zucht (NICHT zusammengeführt — bitte prüfen)
|
||||||
|
|
||||||
| Tier | Geburtsdatum | Zuchten | Dateien |
|
| Tier | Geburtsdatum | Zuchten | Dateien |
|
||||||
@@ -45,14 +34,17 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
|||||||
|
|
||||||
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
| Tier | Geburtsdatum | abweichende Genotypen | abweichende Farbschläge | Sterbedaten | Dateien |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
|
| Ella | 10.06.2019 | Aa C D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] GG P- spsp // Aa Cc[chm] D- ee[f] UwUw P- spsp | Algierfuchsschimmel, hell | 03.02.2023 // 31.01.2023 | Stammbaum von Akio Kids, Stammbaum von CP-Fuchs, CP-Sa Sp von Unity, Stammbaum von Ella, Stammbaum von Picus Son, Stammbaum von Valentino Firehearts Kids |
|
||||||
|
| Lui von den Kleinen Chaoten | 16.04.2021 | Aa Cc[chm] DD ee[f] Gg PP Spsp // Aa c[chm]c[chm] D- Ee[f] Gg PP spsp | — | 15.12.2020 | Stammbaum von Alberto Kids, Stammbaum von Picus Son |
|
||||||
|
| Osamu | 10.12.2015 | AA CC DD ee gg P- spsp // AA CC DD ee gg PP spsp // AA CC DD ee uw[d]uw[d] PP spsp | — | 01.10.2020 // 18.12.2020 | Stammbaum von Danako, Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Kentucky, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Stella Kids, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
|
||||||
|
| Quebec v.d. Kleinen Chaoten | 21.06.2014 | aa Cc[chm] dd Ee Gg Pp spsp // aa Cc[chm] dd Ee Uwuw[d] Pp spsp // aa Cc[chm] dd Ee gg Pp spsp | — | 21.10.2016 | Stammbaum von Ella, Stammbaum von Jin, Stammbaum von Kazuya, Stammbaum von Martin, Stammbaum von Picus Son, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von South Dakota, Stammbaum von Tennessee, Stammbaum von Zenon von Elea |
|
||||||
| | 20.04.2024 | Aa C- dd Ee Gg P- Spsp | Dilute Agouti Kragenschecke // Dilute Kohlfuchs Kragenschecke DP | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
| | 20.04.2024 | Aa C- dd Ee Gg P- Spsp | Dilute Agouti Kragenschecke // Dilute Kohlfuchs Kragenschecke DP | — | Stammbaum von Fire Kids, Stammbaum von Stella Kids |
|
||||||
| Hanami | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 // 14.01.2020 | Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Stella Kids, Stammbaum von Vance, Stammbaum von Zac (Vance.Dorie) |
|
| Hanami | 10.09.2015 | aa Cc[chm] D- Ee gg P- spsp // aa Cc[chm] D- Ee uw[d]uw[d] P- spsp | — | 02.01.2020 // 12.12.2019 // 14.01.2020 | Stammbaum von Hana, Stammbaum von Kentucky, Stammbaum von Rainny, Stammbaum von Ren, Stammbaum von Stella Kids, Stammbaum von Vance, Stammbaum von Zac (Vance.Dorie) |
|
||||||
| | 13.08.2025 | Aa C- D- ee[f] G(G) pp Spsp // aa Cc[chm] D- ee[f] Gg Pp Spsp | Goldfuchsschimmel Kragenschecke // Kohlfuchsschimmel, hell | — | Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
|
| | 13.08.2025 | Aa C- D- ee[f] G(G) pp Spsp // aa Cc[chm] D- ee[f] Gg Pp Spsp | Goldfuchsschimmel Kragenschecke // Kohlfuchsschimmel, hell | — | Stammbaum von Kohlief, Goldfuchsef Sp von Chrissi, Stammbaum von Watarus Kids |
|
||||||
|
|
||||||
## Mehrdeutige / unvollständige Einträge
|
## Mehrdeutige / unvollständige Einträge
|
||||||
|
|
||||||
342 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt.
|
380 Einträge ohne sichere Name+Datum-Kombination (z. B. `Name1 & Name2`-Paarzellen der tiefsten Generation, oder Zellen ohne Datum). Diese werden NICHT automatisch zusammengeführt.
|
||||||
|
|
||||||
- Oskar v.d. bunten Fellnase · Stammbaum von Akio Kids.xlsx
|
- Oskar v.d. bunten Fellnase · Stammbaum von Akio Kids.xlsx
|
||||||
- Raya v.d. bunten Fellnasen · Stammbaum von Akio Kids.xlsx
|
- Raya v.d. bunten Fellnasen · Stammbaum von Akio Kids.xlsx
|
||||||
@@ -97,7 +89,7 @@ Gleiches Tier (Name+Datum), aber widersprüchliche Angaben in verschiedenen Date
|
|||||||
|
|
||||||
## Wahrscheinliche Zuordnungen unvollständiger Einträge
|
## Wahrscheinliche Zuordnungen unvollständiger Einträge
|
||||||
|
|
||||||
89 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
|
98 namenlose/datenlose Einträge tragen denselben Namen wie ein vollständiges Tier — vermutlich dasselbe Tier (zur Bestätigung):
|
||||||
|
|
||||||
- „Tai of Lennylengo“ → Tai of Lennylengo (*01.10.2011)
|
- „Tai of Lennylengo“ → Tai of Lennylengo (*01.10.2011)
|
||||||
- „Arrow PZ Niederlande“ → Arrow PZ Niederlande (*20.05.2014)
|
- „Arrow PZ Niederlande“ → Arrow PZ Niederlande (*20.05.2014)
|
||||||
@@ -176,7 +168,6 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
|||||||
| `!` | 2 | ? |
|
| `!` | 2 | ? |
|
||||||
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
| `C(C)` | 2 | Schreibweise (C trägt c) |
|
||||||
| `(KW` | 2 | ? |
|
| `(KW` | 2 | ? |
|
||||||
| `Cc[]` | 1 | ? |
|
|
||||||
| `[WFNZ-Maroon]` | 1 | ? |
|
| `[WFNZ-Maroon]` | 1 | ? |
|
||||||
| `/+April'2017` | 1 | ? |
|
| `/+April'2017` | 1 | ? |
|
||||||
| `[meliert]-[DP]` | 1 | ? |
|
| `[meliert]-[DP]` | 1 | ? |
|
||||||
@@ -191,6 +182,7 @@ Diese Tokens stehen weiter in `rawGenotype`/`unmappedTokens` — Entscheidung (M
|
|||||||
| `+2018` | 1 | ? |
|
| `+2018` | 1 | ? |
|
||||||
| `AAA` | 1 | ? |
|
| `AAA` | 1 | ? |
|
||||||
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
|
| `chmchm` | 1 | Schreibweise (c[chm]c[chm]) |
|
||||||
|
| `c[chm]chm]` | 1 | ? |
|
||||||
|
|
||||||
## Wurfchronik — Datenqualitäts-Hinweise
|
## Wurfchronik — Datenqualitäts-Hinweise
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,27 @@ if os.path.exists(danako_path):
|
|||||||
else:
|
else:
|
||||||
print("\nWarning: Danako stammbaum file not found, skipping integration checks.")
|
print("\nWarning: Danako stammbaum file not found, skipping integration checks.")
|
||||||
|
|
||||||
|
# --- Stammbaum von Kazuya: photo generation-shift regression (PHOTO-LEFT-STYLE) ---
|
||||||
|
# This sheet has neither col-1 nor col-4 image anchors, so the old fixed-column
|
||||||
|
# heuristic misread it as right-style and shifted every photo one generation
|
||||||
|
# toward the proband: Kazuya wore his father's (Wilbur's) photo, Wilbur wore the
|
||||||
|
# grandfather's (Elay's). The fix picks the layout that places photos closest to
|
||||||
|
# their animal's name column → photos land on the correct generation.
|
||||||
|
kazuya_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Kazuya.xlsx"
|
||||||
|
if os.path.exists(kazuya_path):
|
||||||
|
print(f"\nFound Kazuya stammbaum, running photo generation-shift validation...")
|
||||||
|
kz = {a["name"]: a for a in e.extract_stammbaum(kazuya_path)}
|
||||||
|
if "Kazuya" in kz:
|
||||||
|
check("Kazuya (proband) has NO photo of his own", kz["Kazuya"]["photos"] == [])
|
||||||
|
if "Wilbur" in kz:
|
||||||
|
check("Wilbur (father) gets his own photo (image7), not the grandfather's",
|
||||||
|
kz["Wilbur"]["photos"] == ["photos/wilbur-19032017/image7.jpeg"])
|
||||||
|
if "Elay" in kz:
|
||||||
|
check("Elay (grandfather) gets his own photo (image2)",
|
||||||
|
kz["Elay"]["photos"] == ["photos/elay-16032016/image2.jpeg"])
|
||||||
|
else:
|
||||||
|
print("\nWarning: Kazuya stammbaum file not found, skipping photo-shift checks.")
|
||||||
|
|
||||||
if failed:
|
if failed:
|
||||||
print(f"\n{failed} test(s) FAILED")
|
print(f"\n{failed} test(s) FAILED")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
283
tools/import/test_merge_resolve.py
Normal file
283
tools/import/test_merge_resolve.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
"""Zero-dep tests for merge_and_resolve.py litter-dedup & parent-role logic.
|
||||||
|
|
||||||
|
Run: python test_merge_resolve.py (exit 0 = all pass)
|
||||||
|
|
||||||
|
Covers the sibling-pairing data fix:
|
||||||
|
- litter_compatible(): same-date / compatible-parent dedup, incl. the dateless
|
||||||
|
guard that stops unrelated nameless stubs from blind-merging.
|
||||||
|
- assign_parent_roles(): gender-correct role assignment, self-pairing removal,
|
||||||
|
no two same-role parents — the fix for the 16 self-pairings / 30 gender-role
|
||||||
|
errors that the old simple swap missed.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import merge_and_resolve as m
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond):
|
||||||
|
if not cond:
|
||||||
|
print(f"FAIL: {name}")
|
||||||
|
check.failed += 1
|
||||||
|
else:
|
||||||
|
print(f"ok: {name}")
|
||||||
|
check.failed = 0
|
||||||
|
|
||||||
|
|
||||||
|
def litter(date, fid=None, mid=None, fname=None, mname=None):
|
||||||
|
return {
|
||||||
|
"Date": date,
|
||||||
|
"FatherId": fid,
|
||||||
|
"MotherId": mid,
|
||||||
|
"_father_name": fname,
|
||||||
|
"_mother_name": mname,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── litter_compatible: both sides fully parented ──
|
||||||
|
check(
|
||||||
|
"same date + same parents → compatible",
|
||||||
|
m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "F", "M")),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"same date + different parents → NOT compatible",
|
||||||
|
not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2018-09-22", "X", "Y")),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"different date → NOT compatible",
|
||||||
|
not m.litter_compatible(litter("2018-09-22", "F", "M"), litter("2019-01-01", "F", "M")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── asymmetric (one resolved, one not), real date ──
|
||||||
|
check(
|
||||||
|
"asymmetric same real date, names agree → merge",
|
||||||
|
m.litter_compatible(
|
||||||
|
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
|
||||||
|
litter("2018-09-22", None, None, "Wonderman", "Unique"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"asymmetric same real date, conflicting names → NO merge",
|
||||||
|
not m.litter_compatible(
|
||||||
|
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
|
||||||
|
litter("2018-09-22", None, None, "Someone", "Else"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"asymmetric real date, parented side nameless stub → merge (no conflict)",
|
||||||
|
m.litter_compatible(
|
||||||
|
litter("2018-09-22", "F", "M", "Wonderman", "Unique"),
|
||||||
|
litter("2018-09-22", None, None, None, None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── dateless guard: the bug that wrongly merged unrelated stubs ──
|
||||||
|
check(
|
||||||
|
"dateless asymmetric, NO name evidence → do NOT merge (was the bug)",
|
||||||
|
not m.litter_compatible(
|
||||||
|
litter(None, "F", "M", "Akina", "Arrow"),
|
||||||
|
litter(None, None, None, None, None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"dateless asymmetric WITH positive name match → merge",
|
||||||
|
m.litter_compatible(
|
||||||
|
litter(None, "F", "M", "Akina", "Arrow"),
|
||||||
|
litter(None, None, None, "Akina", None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"dateless asymmetric, contradicting names → do NOT merge",
|
||||||
|
not m.litter_compatible(
|
||||||
|
litter(None, "F", "M", "Akina", "Arrow"),
|
||||||
|
litter(None, None, None, "Mismatch", None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── neither side parented → never blind-merge ──
|
||||||
|
check(
|
||||||
|
"neither parented, same date → NOT compatible",
|
||||||
|
not m.litter_compatible(litter("2018-09-22"), litter("2018-09-22")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── assign_parent_roles ──
|
||||||
|
GENDER = {"bock": "male", "bock2": "male", "maus": "female", "maus2": "female", "u": "unknown", "u2": "unknown"}
|
||||||
|
gof = lambda gid: GENDER.get(gid)
|
||||||
|
|
||||||
|
check("correct roles stay put", m.assign_parent_roles("bock", "maus", gof) == ("bock", "maus"))
|
||||||
|
check("reversed roles get swapped", m.assign_parent_roles("maus", "bock", gof) == ("bock", "maus"))
|
||||||
|
check(
|
||||||
|
"self-pairing collapses to gender-correct single role (male→father)",
|
||||||
|
m.assign_parent_roles("bock", "bock", gof) == ("bock", None),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"self-pairing collapses to gender-correct single role (female→mother)",
|
||||||
|
m.assign_parent_roles("maus", "maus", gof) == (None, "maus"),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"female in father slot, empty mother → moved to mother",
|
||||||
|
m.assign_parent_roles("maus", None, gof) == (None, "maus"),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"male in mother slot, empty father → moved to father",
|
||||||
|
m.assign_parent_roles(None, "bock", gof) == ("bock", None),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"two males → keep one father, drop impossible second",
|
||||||
|
m.assign_parent_roles("bock", "bock2", gof) == ("bock", None),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"two females → keep one mother, drop impossible second",
|
||||||
|
m.assign_parent_roles("maus", "maus2", gof) == (None, "maus"),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"male + unknown → unknown fills mother",
|
||||||
|
m.assign_parent_roles("bock", "u", gof) == ("bock", "u"),
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
"female + unknown → unknown fills father",
|
||||||
|
m.assign_parent_roles("u", "maus", gof) == ("u", "maus"),
|
||||||
|
)
|
||||||
|
check("both empty → both None", m.assign_parent_roles(None, None, gof) == (None, None))
|
||||||
|
check(
|
||||||
|
"single unknown parent kept as father",
|
||||||
|
m.assign_parent_roles("u", None, gof) == ("u", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── name helpers ──
|
||||||
|
check("names_no_conflict: one side empty", m.names_no_conflict(litter(None, fname="A"), litter(None)))
|
||||||
|
check(
|
||||||
|
"names_no_conflict: contradiction detected",
|
||||||
|
not m.names_no_conflict(litter(None, fname="A"), litter(None, fname="B")),
|
||||||
|
)
|
||||||
|
check("names_overlap: matching father name", m.names_overlap(litter(None, fname="A"), litter(None, fname="A")))
|
||||||
|
check("names_overlap: nothing in common", not m.names_overlap(litter(None, fname="A"), litter(None, mname="B")))
|
||||||
|
|
||||||
|
|
||||||
|
# ── parent_age_plausible: born before child, within ~6y lifespan ──
|
||||||
|
check("age: parent 1y before child → plausible", m.parent_age_plausible("16.04.2021", "27.03.2022"))
|
||||||
|
check("age: parent born AFTER child → implausible", not m.parent_age_plausible("2023-01-01", "2022-03-27"))
|
||||||
|
check("age: parent born SAME day → implausible", not m.parent_age_plausible("2022-03-27", "2022-03-27"))
|
||||||
|
check("age: 9 years older (Jayjay→Solice) → implausible", not m.parent_age_plausible("19.06.2013", "27.03.2022"))
|
||||||
|
check("age: exactly ~5y older → plausible", m.parent_age_plausible("01.06.2017", "01.05.2022"))
|
||||||
|
check("age: 7 years older → implausible", not m.parent_age_plausible("25.10.2018", "13.08.2025"))
|
||||||
|
check("age: unknown parent dob → plausible (can't disprove)", m.parent_age_plausible(None, "2022-03-27"))
|
||||||
|
check("age: unknown litter date → plausible", m.parent_age_plausible("2021-04-16", None))
|
||||||
|
|
||||||
|
|
||||||
|
# ── pick_parent_ref: prefer age-plausible ref, avoid duplicating the other role ──
|
||||||
|
def pref(name, role, dob=None):
|
||||||
|
return {"name": name, "roleGuess": role, "dob": dob}
|
||||||
|
|
||||||
|
# Solice case: first father ref (Jayjay, no DOB) loses to the dated, plausible Lui.
|
||||||
|
solice_refs = [
|
||||||
|
pref("Jayjay", "father"),
|
||||||
|
pref("Lui von den Kleinen Chaoten", "mother", "16.04.2021"),
|
||||||
|
pref("Lui von den Kleinen Chaoten", "father", "16.04.2021"),
|
||||||
|
pref("Molly of Black Forest", "mother", "13.09.2021"),
|
||||||
|
]
|
||||||
|
f = m.pick_parent_ref(solice_refs, "father", "27.03.2022")
|
||||||
|
check("pick: father = plausible-dated Lui, not first-listed Jayjay",
|
||||||
|
f and f["name"] == "Lui von den Kleinen Chaoten")
|
||||||
|
mo = m.pick_parent_ref(solice_refs, "mother", "27.03.2022", avoid_name=f["name"])
|
||||||
|
check("pick: mother = Molly (Lui avoided as it is the father)",
|
||||||
|
mo and mo["name"] == "Molly of Black Forest")
|
||||||
|
|
||||||
|
check("pick: a dated-but-impossible ref loses to a plausible one",
|
||||||
|
m.pick_parent_ref([pref("Old", "father", "2010-01-01"), pref("Dad", "father", "2021-01-01")],
|
||||||
|
"father", "2022-03-27")["name"] == "Dad")
|
||||||
|
check("pick: no ref for role → None",
|
||||||
|
m.pick_parent_ref([pref("X", "mother", "2021-01-01")], "father", "2022-03-27") is None)
|
||||||
|
check("pick: single ref is returned",
|
||||||
|
m.pick_parent_ref([pref("Solo", "father")], "father", "2022-03-27")["name"] == "Solo")
|
||||||
|
|
||||||
|
# Gender-aware: a dated FEMALE ref must not win the father slot over an undated
|
||||||
|
# male/unknown one (Molly regression: Hagrid (unknown, no DOB) vs Danielle
|
||||||
|
# (female, dated) → father must be Hagrid, not Danielle).
|
||||||
|
molly_refs = [
|
||||||
|
pref("Hagrid Rubeus of Black Forest", "father"),
|
||||||
|
pref("Arya Stark von den Kleinen Chaoten", "mother", "30.06.2020"),
|
||||||
|
pref("Danielle von den Kleinen Chaoten", "father", "04.03.2020"),
|
||||||
|
pref("Hagrid Rubeus of Black Forest", "mother", "18.07.2019"),
|
||||||
|
]
|
||||||
|
gender = {
|
||||||
|
m.normalize_name("Hagrid Rubeus of Black Forest"): None, # unknown
|
||||||
|
m.normalize_name("Danielle von den Kleinen Chaoten"): "female",
|
||||||
|
m.normalize_name("Arya Stark von den Kleinen Chaoten"): "female",
|
||||||
|
}
|
||||||
|
gof = lambda name: gender.get(m.normalize_name(name))
|
||||||
|
fr = m.pick_parent_ref(molly_refs, "father", "13.09.2021", gender_of=gof)
|
||||||
|
check("pick(gender): father = unknown-sex Hagrid, not dated female Danielle",
|
||||||
|
fr and fr["name"] == "Hagrid Rubeus of Black Forest")
|
||||||
|
mr = m.pick_parent_ref(molly_refs, "mother", "13.09.2021", avoid_name=fr["name"], gender_of=gof)
|
||||||
|
check("pick(gender): mother = Arya (female)", mr and mr["name"].startswith("Arya"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Provenance history: chronological, file-attributed German log ──
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
|
||||||
|
def _hist(prov_json):
|
||||||
|
return _json.loads(prov_json)["history"]
|
||||||
|
|
||||||
|
|
||||||
|
# build_entity_provenance carries history through verbatim.
|
||||||
|
_prov = _json.loads(
|
||||||
|
m.build_entity_provenance(["A.xlsx"], 1, notes=["x"], history=["line one"])
|
||||||
|
)
|
||||||
|
check("build_entity_provenance includes history key", _prov.get("history") == ["line one"])
|
||||||
|
check("build_entity_provenance defaults history to []",
|
||||||
|
_json.loads(m.build_entity_provenance(["A.xlsx"], 1)).get("history") == [])
|
||||||
|
|
||||||
|
# Single-record gerbil history: names the file and the per-field facts.
|
||||||
|
g_single = {
|
||||||
|
"Name": "Picus", "DateOfBirth": "2022-03-27", "Gender": "male",
|
||||||
|
"Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
|
||||||
|
"ImportSource": "Stammbaum von Picus Son.xlsx",
|
||||||
|
"_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
|
||||||
|
}
|
||||||
|
h = m._build_gerbil_history([g_single], g_single, {})
|
||||||
|
check("history: first line names the source file",
|
||||||
|
h[0] == "In „Stammbaum von Picus Son.xlsx“ gefunden.")
|
||||||
|
check("history: dob line names file + formatted date",
|
||||||
|
"Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h)
|
||||||
|
check("history: gender line is German + file-attributed",
|
||||||
|
"Geschlecht (männlich) aus „Stammbaum von Picus Son.xlsx“." in h)
|
||||||
|
check("history: genotype line file-attributed",
|
||||||
|
"Genotyp aus „Stammbaum von Picus Son.xlsx“." in h)
|
||||||
|
|
||||||
|
# Merged gerbil: a field sourced from a DIFFERENT file is attributed to THAT file.
|
||||||
|
g_best = {
|
||||||
|
"Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
|
||||||
|
"Genotype": None, "ColorVarietyId": None, "DateOfDeath": None,
|
||||||
|
"ImportSource": "Stammbaum von Picus Son.xlsx",
|
||||||
|
"_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
|
||||||
|
}
|
||||||
|
g_other = {
|
||||||
|
"Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
|
||||||
|
"Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
|
||||||
|
"ImportSource": "Wurfchronik-Detail.docx",
|
||||||
|
"_filename": "Wurfchronik-Detail.docx", "parentRefs": [],
|
||||||
|
}
|
||||||
|
# Genotype was filled from g_other → its line must name the docx file.
|
||||||
|
g_best["Genotype"] = "aa"
|
||||||
|
fs = {"DateOfBirth": g_best, "Gender": g_best, "Genotype": g_other}
|
||||||
|
h2 = m._build_gerbil_history([g_best, g_other], g_best, fs)
|
||||||
|
check("history(merge): genotype attributed to the file that supplied it",
|
||||||
|
"Genotyp aus „Wurfchronik-Detail.docx“." in h2)
|
||||||
|
check("history(merge): dob attributed to primary file",
|
||||||
|
"Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h2)
|
||||||
|
check("history(merge): merge line names the absorbed file",
|
||||||
|
"Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt." in h2)
|
||||||
|
check("history(merge): Wurfchronik line present",
|
||||||
|
"Angaben aus der Wurfchronik übernommen." in h2)
|
||||||
|
|
||||||
|
# Date formatting helper.
|
||||||
|
check("_de_date: ISO → DD.MM.YYYY", m._de_date("2022-03-27") == "27.03.2022")
|
||||||
|
check("_de_date: passes through non-ISO", m._de_date("unbekannt") == "unbekannt")
|
||||||
|
|
||||||
|
|
||||||
|
if check.failed:
|
||||||
|
print(f"\n{check.failed} test(s) FAILED")
|
||||||
|
sys.exit(1)
|
||||||
|
print("\nAll merge_and_resolve tests passed.")
|
||||||
Reference in New Issue
Block a user