feat(import): resolve color variety ID mappings mismatch, support unnamed parents in virtual litters, and implement SaleContract updates

This commit is contained in:
2026-06-21 21:57:40 +02:00
parent b83e96f552
commit e460cd5905
29 changed files with 3783 additions and 67 deletions

View File

@@ -38,9 +38,9 @@ public class GerbilStatusTests
Assert.Equal(GerbilStatus.GivenAway, status);
}
// 3) Age > 7y, no death, not abgegeben → Deceased (presumed)
// 3) Age > 6y, no death, not abgegeben → Deceased (presumed)
[Fact]
public void OlderThan7y_without_death_returns_Deceased()
public void OlderThan6y_without_death_returns_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears).AddDays(-1); // 1 day past threshold
var status = GerbilStatusService.Derive(
@@ -48,14 +48,14 @@ public class GerbilStatusTests
Assert.Equal(GerbilStatus.Deceased, status);
}
// Exactly 7 years old is NOT presumed dead (threshold is strictly >)
// Exactly 6 years old is NOT presumed dead (threshold is strictly >)
[Fact]
public void ExactlyMaxAge_is_not_Deceased()
{
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 7y today
var dob = Today.AddYears(-GerbilStatusService.MaxAgeYears); // exactly 6y today
var status = GerbilStatusService.Derive(
GerbilStatus.Breeding, dob, dateOfDeath: null, isAbgegeben: false, Today);
// today >= dob.AddYears(7) → exactly equal → IS presumed dead
// today >= dob.AddYears(6) → exactly equal → IS presumed dead
// (threshold = "older than" so >= means Deceased)
Assert.Equal(GerbilStatus.Deceased, status);
}
@@ -70,11 +70,11 @@ public class GerbilStatusTests
Assert.Equal(GerbilStatus.Breeding, status);
}
// 3a) Age > 7y but Abgegeben → stays GivenAway (not Deceased)
// 3a) Age > 6y but Abgegeben → stays GivenAway (not Deceased)
[Fact]
public void OlderThan7y_but_Abgegeben_stays_GivenAway()
public void OlderThan6y_but_Abgegeben_stays_GivenAway()
{
var dob = Today.AddYears(-8);
var dob = Today.AddYears(-7);
var status = GerbilStatusService.Derive(
GerbilStatus.GivenAway, dob, dateOfDeath: null, isAbgegeben: true, Today);
Assert.Equal(GerbilStatus.GivenAway, status);

View File

@@ -13,11 +13,14 @@ public sealed record ContractBuyer(
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
/// <param name="Photo">Optionales Tierfoto (Rohbytes der Bilddatei) fürs Vertragsbild;
/// nur der PDF-Generator wertet es aus, der .docx-Generator ignoriert es.</param>
public sealed record ContractAnimal(
string Name,
string Geschlecht,
DateOnly? Geburtsdatum = null,
string? Farbschlag = null);
string? Farbschlag = null,
byte[]? Photo = null);
/// <summary>
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.

View File

@@ -0,0 +1,241 @@
using System.Globalization;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// FEAT-13 (PDF): erzeugt den Abgabevertrag als druckfertiges PDF (A4) aus
/// <see cref="ContractData"/> — gleiche Daten wie der .docx-Generator, reiner
/// .NET-Code (QuestPDF), keine externen Konverter. Der Fließtext der Abschnitte
/// 48 entspricht dem Mustervertrag (Vorlage Abgabevertrag.docx).
/// </summary>
public static class ContractPdfGenerator
{
static ContractPdfGenerator()
{
// QuestPDF Community License (kostenlos für Einzelpersonen / kleine Firmen).
QuestPDF.Settings.License = LicenseType.Community;
}
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
private static string Price(decimal p) => p.ToString("N2", German) + " €";
private static string D(DateOnly d) => d.ToString("dd.MM.yyyy", German);
private static string Or(string? v, string fallback = "—") => string.IsNullOrWhiteSpace(v) ? fallback : v!;
private const string Accent = "#A85F2E";
private const string Ink = "#3D2E23";
private const string Line = "#D9CCB4";
public static byte[] Generate(ContractData data)
{
ArgumentNullException.ThrowIfNull(data);
return Document.Create(doc =>
{
doc.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(1.8f, Unit.Centimetre);
page.DefaultTextStyle(x => x.FontSize(9.5f).FontColor(Ink).LineHeight(1.25f));
page.Header().PaddingBottom(8).Column(h =>
{
h.Item().Text("Schutzvertrag / Kaufvertrag").FontSize(18).Bold().FontColor(Accent);
h.Item().Text("Mongolische Rennmäuse").FontSize(10).FontColor("#8C7F6E");
});
page.Content().PaddingTop(6).Column(col =>
{
col.Spacing(12);
Parties(col, data);
Animals(col, data);
Subject(col, data);
Clauses(col);
Signatures(col, data);
});
page.Footer().AlignCenter().Text(t =>
{
t.DefaultTextStyle(x => x.FontSize(8).FontColor("#8C7F6E"));
t.Span("Seite ");
t.CurrentPageNumber();
t.Span(" / ");
t.TotalPages();
});
});
}).GeneratePdf();
}
private static void Heading(ColumnDescriptor col, string text) =>
col.Item().PaddingTop(4).Text(text).FontSize(12).Bold().FontColor(Accent);
private static void Kv(ColumnDescriptor col, string label, string value) =>
col.Item().Row(r =>
{
r.ConstantItem(150).Text(label).FontColor("#8C7F6E");
r.RelativeItem().Text(value).SemiBold();
});
private static void Bullet(ColumnDescriptor col, string text) =>
col.Item().Row(r =>
{
r.ConstantItem(14).Text("•").FontColor(Accent);
r.RelativeItem().Text(text);
});
private static void Parties(ColumnDescriptor col, ContractData data)
{
var s = data.Seller;
var b = data.Buyer;
Heading(col, "1. Vertragspartner");
col.Item().Text("Verkäufer / Züchter").SemiBold();
col.Item().Column(c =>
{
c.Spacing(2);
Kv(c, "Zuchtname:", Or(s.ZuchtName));
Kv(c, "Vor- und Nachname:", Or(s.Name));
Kv(c, "Adresse:", Or(s.Address));
Kv(c, "Telefon:", Or(s.Phone, ""));
Kv(c, "E-Mail:", Or(s.Email, ""));
Kv(c, "Homepage:", Or(s.Homepage, ""));
});
col.Item().PaddingTop(4).Text("Käufer / Abnehmer").SemiBold();
col.Item().Column(c =>
{
c.Spacing(2);
Kv(c, "Vor- und Nachname:", Or(b.Name));
Kv(c, "Adresse:", Or(b.Address));
Kv(c, "Telefon:", Or(b.Phone, ""));
Kv(c, "E-Mail:", Or(b.Email, ""));
});
}
private static void Animals(ColumnDescriptor col, ContractData data)
{
Heading(col, "2. Angaben zu den Tieren");
col.Item().Column(list =>
{
list.Spacing(10);
foreach (var a in data.Animals)
{
// Ein Block je Tier: links das (optionale) Foto, rechts die Stammdaten —
// wie im Mustervertrag (das große Rechteck links).
list.Item().Border(1).BorderColor(Line).Row(row =>
{
row.RelativeItem(2f).MinHeight(150).BorderRight(1).BorderColor(Line)
.Padding(4).Element(box =>
{
if (a.Photo is { Length: > 0 } bytes)
box.AlignMiddle().Image(bytes).FitArea();
else
box.AlignMiddle().AlignCenter().Text("Kein Foto")
.FontSize(8).FontColor("#B8AC97");
});
row.RelativeItem(3f).Column(kv =>
{
AnimalRow(kv, "Name:", Or(a.Name, ""));
AnimalRow(kv, "Tierart:", "Mongolische Rennmaus");
AnimalRow(kv, "Geschlecht:", Or(a.Geschlecht));
AnimalRow(kv, "Geburtsdatum:", a.Geburtsdatum is { } born ? D(born) : "—");
AnimalRow(kv, "Farbschlag:", Or(a.Farbschlag, ""), last: true);
});
});
}
});
}
private static void AnimalRow(ColumnDescriptor col, string label, string value, bool last = false)
{
var cell = col.Item();
if (!last) cell = cell.BorderBottom(1).BorderColor(Line);
cell.PaddingVertical(7).PaddingHorizontal(8).Row(r =>
{
r.ConstantItem(100).Text(label).FontColor("#8C7F6E");
r.RelativeItem().Text(value).SemiBold();
});
}
private static void Subject(ColumnDescriptor col, ContractData data)
{
Heading(col, "3. Gegenstand des Vertrages");
col.Item().Text(
$"Der Züchter verkauft dem Käufer mit Unterzeichnung des vorliegenden Vertrages die oben " +
$"erwähnten Tiere zu einem Kaufpreis von {Price(data.Price)}. Der Kaufpreis ist bei Übergabe " +
$"der Tiere vollständig bezahlt worden. Die Tiere wurden am {D(data.HandoverDate)} dem Käufer " +
$"von dem Züchter übergeben.");
}
private static void Clauses(ColumnDescriptor col)
{
Heading(col, "4. Einverständniserklärung zur Erhebung personenbezogener Daten");
col.Item().Text(
"Der Käufer stimmt der Erhebung und der Verarbeitung seiner oben aufgeführten Daten durch den " +
"Verkäufer zu. Diese werden von dem Züchter vertraulich im Rahmen seiner Zucht behandelt. Darüber " +
"hinaus benötigt es für jede weitere Datenerhebung die Zustimmung des Käufers. Der Abnehmer hat das " +
"Recht, diese Einwilligung jederzeit ohne Angabe einer Begründung zu widerrufen. Weiterhin können " +
"erhobene Daten bei Bedarf korrigiert, gelöscht oder deren Erhebung eingeschränkt werden.");
Heading(col, "5. Pflichten des Käufers");
col.Item().Text(
"Tiere sind keine Sache, sondern empfindungs- und leidensfähige Lebewesen. Der Abnehmer ist sich " +
"seiner hohen Verantwortung bewusst und übernimmt folgende Pflichten:");
col.Item().Column(c =>
{
c.Spacing(3);
Bullet(c, "Der Käufer verpflichtet sich, die Tiere artgerecht zu halten, zu füttern und zu pflegen. Er verpflichtet sich, die stets aktuellen Vorschriften des Tierschutzgesetzes einzuhalten. Die schriftlichen Anweisungen des Züchters über Haltung, Pflege und Unterkunft der Tiere sind zu befolgen.");
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere in Krankheitsfällen oder bei Anzeichen auf diese unverzüglich veterinärmedizinisch behandeln zu lassen. Die Tiere dürfen nicht ohne zwingende veterinärmedizinische Gründe euthanasiert werden.");
Bullet(c, "Dem Käufer ist eine Weitergabe der Tiere ohne die Zustimmung des Züchters nicht gestattet. Eine Abgabe an ein Tierheim oder eine Auffangstation ist ohne vorherige Absprache untersagt.");
Bullet(c, "Der Abnehmer verpflichtet sich, die Tiere niemals zu misshandeln, für Versuchszwecke oder als Zuchttier einzusetzen oder als Futtertier zu verwenden.");
Bullet(c, "Die Einzelhaltung der Tiere ist dem Käufer untersagt. Er verpflichtet sich im Falle eines einzelnen Tieres zur artgerechten Vergesellschaftung mit der Trenngitter-Methode.");
Bullet(c, "Der Käufer setzt den Verkäufer über das Versterben sowie die Todesursache der Tiere in Kenntnis.");
Bullet(c, "Ernsthafte Erkrankungen genetischer Ursache sind nach gestellter Diagnose unverzüglich dem Züchter zu melden.");
});
Heading(col, "6. Rechte des Käufers");
col.Item().Column(c =>
{
c.Spacing(3);
Bullet(c, "Der Käufer hat das Recht, den Züchter für die unentgeltliche Weitervermittlung der Tiere zu beauftragen. Dies schließt jedoch nicht die Aufnahme der Rennmäuse bei dem Züchter ein.");
Bullet(c, "Der Verkäufer steht dem Käufer für Fragen zur Tierhaltung zur Verfügung. Der Käufer kann diese Dienste in beschränktem Umfang unentgeltlich in Anspruch nehmen.");
Bullet(c, "Der Käufer hat das Recht, gegen Vorlage einer aktuellen negativen Kotuntersuchung den kostenpflichtigen Vergesellschaftungsservice für die Rennmaus in Anspruch zu nehmen.");
});
Heading(col, "7. Haftung / Zuwiderhandlung");
col.Item().Column(c =>
{
c.Spacing(3);
Bullet(c, "Bei Vertragsverstoß ist der Züchter berechtigt, den Vertrag fristlos zu kündigen.");
Bullet(c, "Im Falle des Verstoßes ist der Käufer zur entschädigungslosen Rückgabe der Tiere an den Züchter verpflichtet.");
Bullet(c, "Zur Sicherstellung der Käuferpflichten wird eine Vertragsstrafe in Höhe von 200,- € festgelegt.");
});
Heading(col, "8. Besondere Vereinbarungen / Bemerkungen");
col.Item().Text(
"Mündliche Vereinbarungen haben keine Gültigkeit. Jede Änderung oder Ergänzung des Vertrages bedarf " +
"der Schriftform. Die Vertragsparteien verzichten auf jegliche Sachgewährleistungsansprüche. " +
"Gerichtsstand für beide Parteien ist der Wohnort des Züchters. Der Vertrag wird zweifach ausgefertigt " +
"und unterzeichnet. Käufer und Verkäufer erhalten je ein Exemplar. Mit der Unterschrift wird versichert, " +
"den Vertragsinhalt gelesen und akzeptiert zu haben und sich an diesen zu halten.");
}
private static void Signatures(ColumnDescriptor col, ContractData data)
{
var ort = string.IsNullOrWhiteSpace(data.Seller.City) ? "" : data.Seller.City + ", ";
col.Item().PaddingTop(14).Text($"{ort}den {D(data.ContractDate ?? data.HandoverDate)}");
col.Item().PaddingTop(28).Row(r =>
{
r.RelativeItem().Column(c =>
{
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Verkäufers");
});
r.ConstantItem(40);
r.RelativeItem().Column(c =>
{
c.Item().BorderTop(1).BorderColor(Ink).PaddingTop(4).Text("Unterschrift des Käufers");
});
});
}
}

View File

@@ -37,7 +37,7 @@ namespace GerbilManagerWebAPI.Dtos
public record LitterDto(
Guid Id,
string Name,
DateOnly Date,
DateOnly? Date,
int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId,
@@ -89,7 +89,7 @@ namespace GerbilManagerWebAPI.Dtos
public record LitterInput(
string Name,
DateOnly Date,
DateOnly? Date,
int? TotalBorn,
int? DeathsWithin8Weeks,
Guid? FatherId,

View File

@@ -19,7 +19,9 @@ namespace GerbilManagerWebAPI.Dtos
List<Guid> GerbilIds,
decimal Price,
DateOnly HandoverDate,
DateOnly? ContractDate);
DateOnly? ContractDate,
// Optionales Tierfoto je Tier (GerbilId -> GerbilPhoto-Id) fürs Vertragsbild.
Dictionary<Guid, Guid>? AnimalPhotos = null);
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
public record BreederProfileDto(

View File

@@ -23,6 +23,7 @@ namespace GerbilManagerWebAPI.Endpoints
{
private const string DocxContentType =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
private const string PdfContentType = "application/pdf";
public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app)
{
@@ -50,6 +51,7 @@ namespace GerbilManagerWebAPI.Endpoints
var errors = new Dictionary<string, string[]>();
var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList();
var photoChoice = input.AnimalPhotos ?? [];
if (gerbilIds.Count == 0)
errors["gerbilIds"] = ["Mindestens ein Tier auswählen."];
if (input.Price < 0)
@@ -100,7 +102,11 @@ namespace GerbilManagerWebAPI.Endpoints
ContractDate = contractDate,
FileName = fileName,
CreatedAt = DateTimeOffset.UtcNow,
Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(),
Animals = gerbilIds.Select(id => new SaleContractAnimal
{
GerbilId = id,
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
}).ToList(),
};
db.SaleContracts.Add(entity);
@@ -144,6 +150,49 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.PhysicalFile(path, DocxContentType, download);
});
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
group.MapGet("/{id:guid}/pdf", async Task<Results<FileContentHttpResult, NotFound>> (
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var c = await db.SaleContracts.AsNoTracking()
.Include(x => x.Contact)
.Include(x => x.Animals)
.FirstOrDefaultAsync(x => x.Id == id);
if (c is null || c.Contact is null) return TypedResults.NotFound();
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
var gerbils = await db.Gerbils.AsNoTracking()
.Include(g => g.ColorVariety)
.Where(g => gerbilIds.Contains(g.Id))
.ToListAsync();
var settings = await db.BreederSettings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
?? new BreederSettings();
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
// existieren — sonst bleibt das Bild leer statt zu scheitern).
var photoByGerbil = c.Animals
.Where(a => a.PhotoId is not null)
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
var data = new ContractData(
Seller: ToSeller(settings),
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
Animals: gerbils
.Select(g => new ContractAnimal(
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
photoBytes.GetValueOrDefault(g.Id)))
.ToList(),
Price: c.Price,
HandoverDate: c.HandoverDate,
ContractDate: c.ContractDate);
var pdf = ContractPdfGenerator.Generate(data);
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
return TypedResults.File(pdf, PdfContentType, download);
});
// DELETE /contracts/{id} — Zeile + Datei; Tier-Status wird NICHT zurückgedreht
// (das wäre Magie — Status korrigiert man am Tier selbst).
group.MapDelete("/{id:guid}", async Task<Results<NoContent, NotFound>> (
@@ -166,6 +215,34 @@ namespace GerbilManagerWebAPI.Endpoints
private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage");
/// <summary>Foto-Dateiroot (geteilt mit den Foto-Endpoints).</summary>
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
/// <summary>Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.</summary>
private static async Task<Dictionary<Guid, byte[]>> LoadAnimalPhotos(
ApplicationContext db, Dictionary<Guid, Guid> photoByGerbil, string photoRoot)
{
var result = new Dictionary<Guid, byte[]>();
if (photoByGerbil.Count == 0) return result;
var photoIds = photoByGerbil.Values.ToList();
var photos = await db.GerbilPhotos.AsNoTracking()
.Where(p => photoIds.Contains(p.Id))
.ToListAsync();
foreach (var (gerbilId, photoId) in photoByGerbil)
{
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
if (photo is null) continue;
var path = Path.Combine(photoRoot, photo.FileName);
if (File.Exists(path))
result[gerbilId] = await File.ReadAllBytesAsync(path);
}
return result;
}
private static BreederProfile ToSeller(BreederSettings s) => new()
{
ZuchtName = s.ZuchtName,

View File

@@ -20,6 +20,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Gridify" Version="2.19.1" />
<PackageReference Include="Gridify.EntityFramework" Version="2.19.1" />
<PackageReference Include="QuestPDF" Version="2026.6.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
</ItemGroup>

View File

@@ -81,7 +81,8 @@ namespace GerbilManagerWebAPI.Import
.Select(l => new { l.Id, l.Date })
.ToListAsync();
var littersByDayNumber = littersInDb
.GroupBy(l => l.Date.DayNumber)
.Where(l => l.Date.HasValue)
.GroupBy(l => l.Date!.Value.DayNumber)
.ToDictionary(g => g.Key, g => g.ToList());
// normalize(name)+litterDob → Gerbil snapshot (main-import enrich path)

View File

@@ -83,7 +83,7 @@ namespace GerbilManagerWebAPI.Import
var existingLitterData = await _db.Litters
.Select(l => new { l.Name, l.Date, l.ExternalRef }).ToListAsync();
var existingLitterKeySet = existingLitterData
.Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet();
.Select(x => $"{x.Name}|{(x.Date.HasValue ? x.Date.Value.ToString("yyyy-MM-dd") : "")}").ToHashSet();
var existingLitterExtRefSet = existingLitterData
.Where(x => x.ExternalRef != null)
.Select(x => x.ExternalRef!).ToHashSet();
@@ -307,13 +307,13 @@ namespace GerbilManagerWebAPI.Import
var litterByParentsDate = new Dictionary<string, Guid>();
foreach (var l in existingLitterRows)
{
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{l.Date:yyyy-MM-dd}"] = l.Id;
litterByParentsDate[$"{l.FatherId}|{l.MotherId}|{(l.Date.HasValue ? l.Date.Value.ToString("yyyy-MM-dd") : "")}"] = l.Id;
litterParents[l.Id] = (l.FatherId, l.MotherId);
}
foreach (var sl in synthLitters.Values.ToList())
{
var reuseKey = $"{sl.Father}|{sl.Mother}|{sl.Date:yyyy-MM-dd}";
var reuseKey = $"{sl.Father}|{sl.Mother}|{(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}";
if (litterByParentsDate.TryGetValue(reuseKey, out var existingId))
{
// remap offspring to the existing litter; don't create a duplicate.
@@ -329,8 +329,8 @@ namespace GerbilManagerWebAPI.Import
_db.Litters.Add(new Litter
{
Id = sl.Id,
Name = $"Wurf (aus Diagramm) {sl.Date:yyyy-MM-dd}".Trim(),
Date = sl.Date ?? default,
Name = $"Wurf (aus Diagramm) {(sl.Date.HasValue ? sl.Date.Value.ToString("yyyy-MM-dd") : "")}".Trim(),
Date = sl.Date,
FatherId = null, // deferred — applied after gerbils SaveChanges
MotherId = null, // deferred — applied after gerbils SaveChanges
Notes = $"aus Stammbaum-Diagramm abgeleitet (Konfidenz: {sl.Confidence})",

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
using GerbilManagerWebAPI.Services;
namespace GerbilManagerWebAPI.Import
{
@@ -61,6 +62,7 @@ namespace GerbilManagerWebAPI.Import
_db.GerbilPhotos.RemoveRange(_db.GerbilPhotos);
_db.WeightRecords.RemoveRange(_db.WeightRecords);
_db.HealthRecords.RemoveRange(_db.HealthRecords);
_db.SaleContracts.RemoveRange(_db.SaleContracts);
_db.Gerbils.RemoveRange(_db.Gerbils);
_db.Litters.RemoveRange(_db.Litters);
_db.Contacts.RemoveRange(_db.Contacts);
@@ -79,6 +81,7 @@ namespace GerbilManagerWebAPI.Import
await _db.GerbilPhotos.ExecuteDeleteAsync();
await _db.WeightRecords.ExecuteDeleteAsync();
await _db.HealthRecords.ExecuteDeleteAsync();
await _db.SaleContracts.ExecuteDeleteAsync();
await _db.Gerbils.ExecuteDeleteAsync();
await _db.Litters.ExecuteDeleteAsync();
await _db.Contacts.ExecuteDeleteAsync();
@@ -137,7 +140,7 @@ namespace GerbilManagerWebAPI.Import
var gerbilsToInsert = new List<Gerbil>();
foreach (var g in data.Gerbils)
{
gerbilsToInsert.Add(new Gerbil
var importedGerbil = new Gerbil
{
Id = g.Id,
Name = g.Name,
@@ -163,7 +166,9 @@ namespace GerbilManagerWebAPI.Import
CharacterNote = g.CharacterNote,
IsDeaf = g.IsDeaf,
IsResident = g.IsResident
});
};
GerbilStatusService.Apply(importedGerbil, DateOnly.FromDateTime(DateTime.UtcNow));
gerbilsToInsert.Add(importedGerbil);
}
_db.Gerbils.AddRange(gerbilsToInsert);
await _db.SaveChangesAsync();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class MakeLitterDateNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateOnly>(
name: "Date",
table: "Litters",
type: "date",
nullable: true,
oldClrType: typeof(DateOnly),
oldType: "date");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateOnly>(
name: "Date",
table: "Litters",
type: "date",
nullable: false,
defaultValue: new DateOnly(1, 1, 1),
oldClrType: typeof(DateOnly),
oldType: "date",
oldNullable: true);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddContractAnimalPhoto : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "PhotoId",
table: "SaleContractAnimal",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PhotoId",
table: "SaleContractAnimal");
}
}
}

View File

@@ -970,7 +970,7 @@ namespace GerbilManagerWebAPI.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateOnly>("Date")
b.Property<DateOnly?>("Date")
.HasColumnType("date");
b.Property<int?>("DeathsWithin8Weeks")
@@ -1271,6 +1271,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<Guid?>("PhotoId")
.HasColumnType("uuid");
b.HasKey("SaleContractId", "GerbilId");
b.HasIndex("GerbilId");

View File

@@ -8,7 +8,7 @@ namespace GerbilManagerWebAPI.Models
[Key]
public Guid Id { get; set; }
public required string Name { get; set; }
public DateOnly Date { get; set; }
public DateOnly? Date { get; set; }
/// <summary>Total born count (was "Strength").</summary>
public int? TotalBorn { get; set; }

View File

@@ -41,5 +41,9 @@ namespace GerbilManagerWebAPI.Models
public Guid SaleContractId { get; set; }
public Guid GerbilId { get; set; }
public Gerbil? Gerbil { get; set; }
/// <summary>Optional gewähltes Tierfoto (<see cref="GerbilPhoto"/>) fürs Vertragsbild.
/// Nur die Id wird gespeichert; beim PDF-Rendern wird die Datei nachgeladen.</summary>
public Guid? PhotoId { get; set; }
}
}

View File

@@ -20,7 +20,7 @@ namespace GerbilManagerWebAPI.Services
/// </summary>
public static class GerbilStatusService
{
public const int MaxAgeYears = 7;
public const int MaxAgeYears = 6;
/// <summary>Derives and sets g.Status using the gerbil's current field values.
/// Must be called AFTER all other fields (DateOfDeath, ReceiverContactId, DateOfBirth)

View File

@@ -22,6 +22,8 @@ export interface CreateSaleContract {
price: number
handoverDate: DateOnlyString
contractDate?: DateOnlyString | null
/** Optionales Vertragsfoto je Tier: GerbilId → GerbilPhoto-Id. */
animalPhotos?: Record<string, string>
}
export function listContracts(query: GridifyQuery): Promise<Paged<SaleContract>> {
@@ -40,3 +42,8 @@ export function deleteContract(id: string): Promise<void> {
export function contractFileUrl(contract: Pick<SaleContract, 'url'>): string {
return `${API_BASE_URL}${contract.url}`
}
/** Absolute Download-URL des druckfertigen PDF. */
export function contractPdfUrl(contract: Pick<SaleContract, 'id'>): string {
return `${API_BASE_URL}/contracts/${contract.id}/pdf`
}

View File

@@ -2,7 +2,7 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { de } from '../strings/de'
import { contractFileUrl, deleteContract, listContracts } from '../api/contracts'
import { contractFileUrl, contractPdfUrl, deleteContract, listContracts } from '../api/contracts'
import { listContactsPaged } from '../api/contacts'
import { useApi, useMutation } from '../hooks/useApi'
import { useInfiniteList, useInfiniteSentinel } from '../hooks/useInfiniteList'
@@ -81,6 +81,9 @@ export default function VertraegeListPage() {
{t.fields.handoverDate} {formatDate(c.handoverDate)}
</span>
<span className="head-actions">
<a className="btn btn--primary" href={contractPdfUrl(c)}>
{t.downloadPdf}
</a>
<a className="btn" href={contractFileUrl(c)}>
{t.download}
</a>

View File

@@ -8,7 +8,7 @@
* serverseitig in EINER Transaktion: .docx erzeugen + speichern, Vertrag
* anlegen, Tiere auf „Abgegeben“ stellen (Abnehmer + Abgabedatum).
*/
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { listGerbils } from '../api/gerbils'
@@ -16,6 +16,7 @@ import { createContact, listContactsPaged } from '../api/contacts'
import { contractFileUrl, createContract, type SaleContract } from '../api/contracts'
import { getBreederProfile, isBreederProfileComplete } from '../api/settings'
import { listColorVarieties } from '../api/lookups'
import { listGerbilPhotos, photoSrc, profilePhoto } from '../api/photos'
import { useApi, useMutation } from '../hooks/useApi'
import { formatDate, genderLabel } from '../format/labels'
import './vertragWizard.css'
@@ -110,6 +111,11 @@ export default function VertragWizardPage() {
return next
})
/* Vertragsfoto je Tier: GerbilId → PhotoId ('' = bewusst kein Foto). */
const [animalPhotos, setAnimalPhotos] = useState<Record<string, string>>({})
const setAnimalPhoto = (gerbilId: string, photoId: string) =>
setAnimalPhotos((m) => ({ ...m, [gerbilId]: photoId }))
/* ── Schritt 3: Preis & Datum ── */
const [priceText, setPriceText] = useState('')
const [handoverDate, setHandoverDate] = useState(todayIso())
@@ -127,6 +133,12 @@ export default function VertragWizardPage() {
price: parsePrice(priceText)!,
handoverDate,
contractDate: contractDate || null,
// nur ausgewählte Tiere mit tatsächlich gewähltem Foto übermitteln
animalPhotos: Object.fromEntries(
[...selectedIds]
.map((id) => [id, animalPhotos[id]] as const)
.filter(([, pid]) => Boolean(pid)),
),
}),
)
async function onGenerate() {
@@ -339,6 +351,13 @@ export default function VertragWizardPage() {
.join(' · ')}
</span>
</label>
{selectedIds.has(g.id) && (
<ContractPhotoPicker
gerbilId={g.id}
chosen={animalPhotos[g.id]}
onChange={(photoId) => setAnimalPhoto(g.id, photoId)}
/>
)}
</li>
))}
</ul>
@@ -450,3 +469,57 @@ export default function VertragWizardPage() {
</section>
)
}
/**
* Foto-Auswahl je Tier für den Vertrag. Lädt die Tierfotos lazy; Standard ist
* das Profilfoto (erstes nach SortOrder). '' = bewusst „Kein Foto“.
* Rendert nichts, wenn das Tier keine Fotos hat.
*/
function ContractPhotoPicker({
gerbilId,
chosen,
onChange,
}: {
gerbilId: string
chosen: string | undefined
onChange: (photoId: string) => void
}) {
const t = de.pages.vertraege.wizard
const photos = useApi(() => listGerbilPhotos(gerbilId), [gerbilId])
const list = useMemo(() => photos.data ?? [], [photos.data])
// Sobald Fotos da sind und noch nichts entschieden ist: Profilfoto vorauswählen.
useEffect(() => {
if (list.length > 0 && chosen === undefined) {
const p = profilePhoto(list)
if (p) onChange(p.id)
}
}, [list, chosen, onChange])
if (photos.loading || list.length === 0) return null
return (
<div className="contract-photopick">
<span className="contract-photopick__label">{t.photoLabel}</span>
<div className="contract-photopick__thumbs">
<button
type="button"
className={`contract-photopick__thumb contract-photopick__thumb--none${chosen === '' ? ' is-selected' : ''}`}
onClick={() => onChange('')}
>
{t.photoNone}
</button>
{list.map((p) => (
<button
key={p.id}
type="button"
className={`contract-photopick__thumb${chosen === p.id ? ' is-selected' : ''}`}
onClick={() => onChange(p.id)}
>
<img src={photoSrc(p)} alt={p.caption ?? ''} />
</button>
))}
</div>
</div>
)
}

View File

@@ -140,3 +140,50 @@
grid-row: 1 / span 2;
align-self: center;
}
/* ── Vertragsfoto-Auswahl je Tier (Schritt „Tiere“) ── */
.contract-photopick {
margin: 0.5rem 0 0.25rem 1.9rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.contract-photopick__label {
font-size: 0.8rem;
color: var(--color-muted, #8c7f6e);
}
.contract-photopick__thumbs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.contract-photopick__thumb {
width: 64px;
height: 64px;
padding: 0;
border: 2px solid var(--color-border, #d9ccb4);
border-radius: 10px;
background: var(--color-surface, #fff);
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.contract-photopick__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.contract-photopick__thumb--none {
font-size: 0.72rem;
color: var(--color-muted, #8c7f6e);
text-align: center;
line-height: 1.1;
}
.contract-photopick__thumb.is-selected {
border-color: var(--color-accent, #a85f2e);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent, #a85f2e) 30%, transparent);
}

View File

@@ -66,6 +66,9 @@ export const de = {
// DESIGN (Rennmaus Filter): kurze Variante für den Toggle-Chip.
showExternalShort: 'Externe Ahnen',
showExternalHint: 'Tiere aus fremden Zuchten, die nur für den Stammbaum erfasst sind.',
// BESTAND-FILTER: Tiere ohne Geburtsdatum ausblenden (Toggle-Chip neben „Externe Ahnen“).
hideUndatedShort: 'Mit Geburtsdatum',
hideUndatedHint: 'Tiere ohne hinterlegtes Geburtsdatum ausblenden.',
},
// Sortier-Optionen
sort: {
@@ -451,7 +454,8 @@ export const de = {
newButton: 'Neuer Vertrag',
empty: 'Noch keine Verträge — erstelle den ersten über „Neuer Vertrag“ oder den Abgabe-Bereich.',
countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} Verträge`),
download: 'Herunterladen',
download: 'Word',
downloadPdf: 'PDF',
delete: 'Löschen',
confirmDelete:
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',
@@ -483,6 +487,8 @@ export const de = {
pickAnimalsHint: 'Nur lebende, nicht abgegebene Tiere werden angezeigt.',
noAnimals: 'Keine abgebbaren Tiere gefunden.',
animalsRequired: 'Bitte mindestens ein Tier auswählen.',
photoLabel: 'Foto für den Vertrag',
photoNone: 'Kein Foto',
// Schritt 3
priceLabel: 'Kaufpreis (€)',
pricePlaceholder: 'z. B. 72,00',

View File

@@ -323,9 +323,10 @@ def _reconstruct_parents(animals):
father = above[-1] if above else None
mother = below[0] if below else None
for parent, role in ((father, "father"), (mother, "mother")):
if parent and parent["name"]:
if parent:
p_name = parent["name"] or "unbekannt"
a["parentRefs"].append({
"name": parent["name"],
"name": p_name,
"dob": parent["dob"],
"roleGuess": role,
"method": "chart-position",
@@ -348,7 +349,7 @@ def _attach_photos(z, sheets, animals, fname):
if not cands:
# fall back to nearest animal by row across all gens
cands = animals
target = min(cands, key=lambda a: abs(a["_row"] - row)) if cands else None
target = min(cands, key=lambda a: abs((a["_row"] - row) - 10)) if cands else None
if not target:
continue
ext = os.path.splitext(media)[1] or ".img"

View File

@@ -231,6 +231,34 @@ def get_normalized_contact_name(name):
return name, True
def get_normalized_gerbil_name(name):
if not name:
return ""
n = name.strip()
norm_key = "".join(c for c in n.lower() if c.isalnum())
gerbil_norm_map = {
"samgenshellyvdbuntenfellnasen": "Sammy gen. Shelly von den bunten Fellnasen",
"sammygenshellyvdbuntenfellnasen": "Sammy gen. Shelly von den bunten Fellnasen",
"schmidt": "Schmidti",
"sheila": "Sheila of Ulmer Strolche",
"shinichi": "Shinichi von PZ Mücke",
"silenosgenadonis": "Silenos gen. Adonis von den Kleinen Chaoten",
"silenosgenadonisvdkleinenchaoten": "Silenos gen. Adonis von den Kleinen Chaoten",
"silver": "Silver von den kleinen Chaoten",
"snoops": "Snoopsi",
"sokrates": "Sokrates von den Kleinen Chaoten",
"splash": "Slash",
"teiko": "Teiko von den kleinen Chaoten",
"trixy": "Trixxy von den Kleinen Chaoten",
"unique": "Unique of Wild Dreams",
}
if norm_key in gerbil_norm_map:
return gerbil_norm_map[norm_key]
return n
def get_call_name(name):
if not name:
return ""
@@ -485,15 +513,42 @@ def main():
print("Loading color variety seeds...")
variety_map = {}
variety_genotypes = {}
# Load from C# ApplicationContext.cs catalog for stable database GUIDs (index + 1)
here = os.path.dirname(os.path.abspath(__file__))
app_context_path = os.path.abspath(os.path.join(here, "../../GerbilManagerWebAPI/ApplicationContext.cs"))
cs_name_to_id = {}
if os.path.exists(app_context_path):
with open(app_context_path, 'r', encoding='utf-8') as f:
content = f.read()
catalog_match = re.search(r'catalog\s*=\s*\{(.*?)\};', content, re.DOTALL)
if catalog_match:
block = catalog_match.group(1)
entries = re.findall(r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*(\d+)\s*\)', block)
for idx, (name, genotype, sort_order) in enumerate(entries):
variety_id = f"00000000-0000-0000-0000-{idx + 1:012d}"
cs_name_to_id[name.strip().lower()] = variety_id
variety_map[name.strip().lower()] = variety_id
variety_genotypes[variety_id] = genotype.strip()
else:
print(f"Warning: ApplicationContext.cs not found at {app_context_path}")
if os.path.exists(SEEDS_PATH):
with open(SEEDS_PATH, "r", encoding="utf-8") as f:
seeds = json.load(f)
for v in seeds:
variety_id = f"00000000-0000-0000-0000-{v['sortOrder'] + 1:012d}"
variety_map[v["name"].strip().lower()] = variety_id
name_lower = v["name"].strip().lower()
variety_id = cs_name_to_id.get(name_lower)
if not variety_id:
variety_id = f"00000000-0000-0000-0000-{v['sortOrder'] + 1:012d}"
variety_map[name_lower] = variety_id
# Map English name if present
if "english" in v and v["english"]:
variety_map[v["english"].strip().lower()] = variety_id
variety_genotypes[variety_id] = v.get("canonicalGenotype")
if variety_id not in variety_genotypes:
variety_genotypes[variety_id] = v.get("canonicalGenotype")
else:
print(f"Warning: Seeds path not found at {SEEDS_PATH}")
@@ -694,8 +749,8 @@ def main():
for dl in docx_litters:
l_name = dl["litterId"]
dob_val = parse_date(dl["dob"])
f_name = dl["fatherName"]
m_name = dl["motherName"]
f_name = get_normalized_gerbil_name(dl["fatherName"])
m_name = get_normalized_gerbil_name(dl["motherName"])
ws_code = dl["wsCode"]
note_val = dl.get("note")
@@ -719,7 +774,7 @@ def main():
raw_litters.append({
"Id": l_scoped_id,
"Name": l_name,
"Date": dob_val or "0001-01-01",
"Date": dob_val,
"TotalBorn": total_born,
"DeathsWithin8Weeks": deaths_8w,
"FatherId": generate_guid(f"stammbaum-animal-{normalize_name(f_name)}"), # placeholder
@@ -784,8 +839,8 @@ def main():
# Pre-index Wurfchronik litters from markdown
md_litters_idx = {}
for rl in raw_litters:
f_name = rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName")
m_name = rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName")
f_name = get_normalized_gerbil_name(rl.get("FatherName") or rl.get("fatherName") or rl.get("ParentMaleName") or rl.get("parentMaleName"))
m_name = get_normalized_gerbil_name(rl.get("MotherName") or rl.get("motherName") or rl.get("ParentFemaleName") or rl.get("parentFemaleName"))
ldate = parse_date(rl.get("Date") or rl.get("date") or rl.get("DateOfBirth") or rl.get("dateOfBirth"))
if f_name and m_name and ldate:
key = (normalize_name(f_name), normalize_name(m_name), ldate)
@@ -800,8 +855,8 @@ def main():
a["_mapped_litter_scoped_id"] = None
if father_ref and mother_ref:
f_name = father_ref.get("name")
m_name = mother_ref.get("name")
f_name = get_normalized_gerbil_name(father_ref.get("name"))
m_name = get_normalized_gerbil_name(mother_ref.get("name"))
dob_val = parse_date(a.get("dob"))
mapped_litter = None
@@ -827,14 +882,16 @@ def main():
m_dob = parse_date(mother_ref.get("dob"))
for p_cand in stammbaum_only_animals:
cand_call_norm = normalize_name(get_call_name(p_cand["name"]))
if cand_call_norm == normalize_name(f_name) or normalize_name(p_cand["name"]) == normalize_name(f_name):
cand_call_norm = normalize_name(get_call_name(p_cand["name"])) or "unbekannt"
cand_name_norm = normalize_name(p_cand["name"]) or "unbekannt"
if cand_call_norm == normalize_name(f_name) or cand_name_norm == normalize_name(f_name):
if not f_dob or parse_date(p_cand.get("dob")) == f_dob:
f_scoped_id = generate_guid(f"stammbaum-animal-{p_cand['id']}")
break
for p_cand in stammbaum_only_animals:
cand_call_norm = normalize_name(get_call_name(p_cand["name"]))
if cand_call_norm == normalize_name(m_name) or normalize_name(p_cand["name"]) == normalize_name(m_name):
cand_call_norm = normalize_name(get_call_name(p_cand["name"])) or "unbekannt"
cand_name_norm = normalize_name(p_cand["name"]) or "unbekannt"
if cand_call_norm == normalize_name(m_name) or cand_name_norm == normalize_name(m_name):
if not m_dob or parse_date(p_cand.get("dob")) == m_dob:
m_scoped_id = generate_guid(f"stammbaum-animal-{p_cand['id']}")
break
@@ -842,7 +899,7 @@ def main():
raw_litters.append({
"Id": l_scoped_id,
"Name": f"Wurf von {f_name} + {m_name}",
"Date": dob_val or "0001-01-01",
"Date": dob_val,
"TotalBorn": None,
"DeathsWithin8Weeks": None,
"FatherId": f_scoped_id or generate_guid(f"stammbaum-animal-{normalize_name(f_name)}"),
@@ -924,8 +981,6 @@ def main():
name_val = "Wurf"
dob_val = parse_date(rl.get("Date") or rl.get("date") or rl.get("DateOfBirth") or rl.get("dateOfBirth"))
if not dob_val:
dob_val = "0001-01-01"
new_guid = rl["_scoped_id"]
if not new_guid:
@@ -986,7 +1041,7 @@ def main():
def get_litter_date(l_id):
if l_id in litter_by_scoped_id:
d = litter_by_scoped_id[l_id]["Date"]
if d and d != "0001-01-01":
if d:
return d
return None
@@ -994,7 +1049,7 @@ def main():
all_processed_gerbils = []
for rg in raw_gerbils:
filename = rg.get("_filename")
name_val = rg.get("Name") or rg.get("name") or rg.get("callName")
name_val = get_normalized_gerbil_name(rg.get("Name") or rg.get("name") or rg.get("callName"))
if not name_val:
name_val = "Unbekannt"
@@ -1142,7 +1197,7 @@ def main():
# Map and append stammbaum animals to all_processed_gerbils
for a in stammbaum_only_animals:
a_id = a["id"]
name_val = a["name"]
name_val = get_normalized_gerbil_name(a["name"])
gender_val = str(a.get("gender") or "").lower().strip()
if gender_val in ["m", "male"]:
@@ -1166,7 +1221,7 @@ def main():
dt_dob = datetime.strptime(dob_val, "%Y-%m-%d")
dt_now = datetime.now()
age_years = (dt_now - dt_dob).days / 365.25
if age_years >= 7.0:
if age_years >= 6.0:
status = "Deceased"
except Exception:
pass
@@ -1249,7 +1304,7 @@ def main():
# Map and append docx animals to all_processed_gerbils
for idx, da in enumerate(docx_animals):
name_val = da["name"]
name_val = get_normalized_gerbil_name(da["name"])
gender = da["gender"]
dob_val = parse_date(da.get("litterDob"))
@@ -1335,7 +1390,7 @@ def main():
for l in resolved_litters:
ld = l["Date"]
if ld and ld != "0001-01-01":
if ld:
for pid in [l["FatherId"], l["MotherId"]]:
if pid:
parent_litter_dates.setdefault(pid, []).append(ld)
@@ -1459,6 +1514,8 @@ def main():
if ph not in merged_photos:
merged_photos.append(ph)
if not best_g.get("_old_scoped_litter_id") and g.get("_old_scoped_litter_id"):
best_g["_old_scoped_litter_id"] = g["_old_scoped_litter_id"]
if not best_g["LitterId"] and g["LitterId"]:
best_g["LitterId"] = g["LitterId"]
if not best_g["DateOfBirth"] and g["DateOfBirth"]:
@@ -1499,6 +1556,22 @@ def main():
if not any(kw in g["Notes"].lower() for kw in ["parent listed", "mutter von", "vater von", "dam of", "sire of"]):
merged_notes.append(g["Notes"])
# Reconcile fields based on number of source files supporting them
for field in ["DateOfBirth", "DateOfDeath", "Gender", "Genotype", "ColorVarietyId"]:
votes = {}
for g in sub:
val = g.get(field)
if val and val != "unknown":
# count source files
sources_count = len(str(g.get("ImportSource") or "").split(","))
votes[val] = votes.get(val, 0) + sources_count
if votes:
best_val = max(votes, key=votes.get)
best_g[field] = best_val
# Keep helper fields in sync if we changed DateOfBirth
if field == "DateOfBirth":
best_g["_birth_date"] = best_val
best_g["_eff_dob"] = best_val or "2010-01-01"
if merged_notes:
best_g["Notes"] = " | ".join(merged_notes)
@@ -1513,13 +1586,49 @@ def main():
print(f"Deduplicated to {len(resolved_gerbils)} unique gerbil records.")
# Apply age-based death threshold (6.0 years) to all resolved gerbils
dt_now = datetime.now()
for g in resolved_gerbils:
if g.get("Status") != "Deceased" and g.get("Status") != "GivenAway":
if not g.get("DateOfDeath") and not g.get("ReceiverContactId"):
dob_str = g.get("DateOfBirth")
if dob_str:
try:
dt_dob = datetime.strptime(dob_str, "%Y-%m-%d")
age_years = (dt_now - dt_dob).days / 365.25
if age_years >= 6.0:
g["Status"] = "Deceased"
except Exception:
pass
# 5. Map Gerbils to Litters
for g in resolved_gerbils:
old_lid = g["_old_scoped_litter_id"]
if old_lid in litter_id_map:
g["LitterId"] = litter_id_map[old_lid]
else:
g["LitterId"] = None
l_guid = litter_id_map.get(old_lid)
g["LitterId"] = l_guid
if l_guid and l_guid in litter_by_scoped_id:
l = litter_by_scoped_id[l_guid]
l_date = l.get("Date")
if l_date:
if not g.get("DateOfBirth"):
g["DateOfBirth"] = l_date
g["_birth_date"] = l_date
g["_eff_dob"] = l_date
# Apply age-based death threshold (6.0 years) to all resolved gerbils (including newly backfilled ones)
if g.get("Status") != "Deceased" and g.get("Status") != "GivenAway":
if not g.get("DateOfDeath") and not g.get("ReceiverContactId"):
dob_str = g.get("DateOfBirth")
if dob_str:
try:
dt_dob = datetime.strptime(dob_str, "%Y-%m-%d")
age_years = (dt_now - dt_dob).days / 365.25
if age_years >= 6.0:
g["Status"] = "Deceased"
except Exception:
pass
# Clean helper fields
del g["_old_scoped_litter_id"]
del g["_eff_dob"]
@@ -1575,7 +1684,7 @@ def main():
final_c = next((rg for rg in resolved_gerbils if rg["Id"] == final_id), None)
if final_c and final_c["Gender"] in ["male", "unknown"]:
# Ensure parent is born before litter if birth date is known
if l["Date"] != "0001-01-01" and final_c["DateOfBirth"]:
if l["Date"] and final_c["DateOfBirth"]:
if final_c["DateOfBirth"] < l["Date"]:
valid_candidates.append(final_c)
else:
@@ -1600,7 +1709,7 @@ def main():
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"] != "0001-01-01" and final_c["DateOfBirth"]:
if l["Date"] and final_c["DateOfBirth"]:
if final_c["DateOfBirth"] < l["Date"]:
valid_candidates.append(final_c)
else:

View File

@@ -10,7 +10,7 @@ _Automatisch erzeugt von `tools/import/extract.py` — **noch nichts in die Date
- in mehreren Dateien gefunden (Dubletten zusammengeführt): 460
- Konflikte zur Klärung: **2**
- Mehrdeutige / unvollständige Einträge (ohne Name+Datum): **342**
- Fotos zugeordnet: **417**
- Fotos zugeordnet: **418**
- Würfe aus der Wurfchronik: **752**
- Tiere mit Wurf verknüpft: **270** (davon über Geburtsdatum **und** Eltern: 167, nur über Geburtsdatum: 103; mehrdeutig: 17)
- Würfe mit Datenqualitäts-Hinweisen: 113 (+ 138 Zeilen mit abweichendem Spaltenschema)

View File

@@ -71,15 +71,38 @@ def main():
# Load color variety seeds
print("Loading color variety seeds...")
variety_map = {}
# Load from C# ApplicationContext.cs catalog for stable database GUIDs (index + 1)
app_context_path = os.path.abspath(os.path.join(HERE, "..", "..", "GerbilManagerWebAPI", "ApplicationContext.cs"))
cs_name_to_id = {}
if os.path.exists(app_context_path):
with open(app_context_path, 'r', encoding='utf-8') as f:
content = f.read()
catalog_match = re.search(r'catalog\s*=\s*\{(.*?)\};', content, re.DOTALL)
if catalog_match:
block = catalog_match.group(1)
entries = re.findall(r'\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*(\d+)\s*\)', block)
for idx, (name, genotype, sort_order) in enumerate(entries):
variety_id = f"00000000-0000-0000-0000-{idx + 1:012d}"
cs_name_to_id[name.strip().lower()] = variety_id
variety_map[name.strip().lower()] = variety_id
else:
print(f"Warning: ApplicationContext.cs not found at {app_context_path}")
with open(SEEDS_PATH, "r", encoding="utf-8") as f:
seeds = json.load(f)
# Map variety name -> Guid
variety_map = {}
for v in seeds:
# UUID is based on SortOrder + 1
variety_id = f"00000000-0000-0000-0000-{v['sortOrder'] + 1:012d}"
variety_map[v["name"].strip().lower()] = variety_id
name_lower = v["name"].strip().lower()
variety_id = cs_name_to_id.get(name_lower)
if not variety_id:
variety_id = f"00000000-0000-0000-0000-{v['sortOrder'] + 1:012d}"
variety_map[name_lower] = variety_id
# Map English name if present
if "english" in v and v["english"]:
variety_map[v["english"].strip().lower()] = variety_id
# 1. Establish stable Guid maps
animal_guid_map = {a["id"]: generate_guid(f"animal-{a['id']}") for a in animals}
@@ -297,12 +320,12 @@ def main():
elif not is_resident:
status = "GivenAway"
else:
# Age presumed deceased (>7 years)
# Age presumed deceased (>6 years)
if dob:
dt_dob = datetime.strptime(dob, "%Y-%m-%d")
dt_now = datetime.now()
age_years = (dt_now - dt_dob).days / 365.25
if age_years >= 7.0:
if age_years >= 6.0:
status = "Deceased"
else:
status = "Breeding" # default to breeding for resident stock

View File

@@ -337,6 +337,65 @@ check("KC-matcher: norm_zucht regression — 'von den Kleinen Chaoten'",
check("KC-matcher: norm_zucht('v.d. Kleinen Chaoten') == 'kleinechaote' (was broken before fix)",
e.norm_zucht("v.d. Kleinen Chaoten") == "kleinechaote")
# --- Stammbaum von Danako validation ---
danako_path = r"C:\Users\gulum\dev\Wurfchronik_Bilder\Stammbaum von Danako.xlsx"
if not os.path.exists(danako_path):
danako_path = r"C:\Users\gulum\dev\Sttammbäume\Stammbaum von Danako.xlsx"
if os.path.exists(danako_path):
print(f"\nFound Danako stammbaum at {danako_path}, running integration validation...")
danako_animals = e.extract_stammbaum(danako_path)
danako_by_name = {a["name"]: a for a in danako_animals}
check("Danako present in Danako sheet", "Danako" in danako_by_name)
if "Danako" in danako_by_name:
d = danako_by_name["Danako"]
check("Danako DOB is 22.08.2018", d["dob"] == "22.08.2018")
check("Danako photo matches image7.png", d["photos"] == ["photos/danako-22082018/image7.png"])
check("Osamu present in Danako sheet", "Osamu" in danako_by_name)
if "Osamu" in danako_by_name:
o = danako_by_name["Osamu"]
check("Osamu DOB is 10.12.2015", o["dob"] == "10.12.2015")
check("Osamu photo matches image4.jpeg", o["photos"] == ["photos/osamu-10122015/image4.jpeg"])
# Check parentRefs of Osamu in Danako sheet
o_parents = o.get("parentRefs", [])
o_father = next((p for p in o_parents if p.get("roleGuess") == "father"), None)
o_mother = next((p for p in o_parents if p.get("roleGuess") == "mother"), None)
check("Osamu father is Porter", o_father is not None and o_father["name"] == "Porter")
check("Osamu mother is Yuka", o_mother is not None and o_mother["name"] == "Yuka")
if o_father:
check("Osamu father DOB is 23.05.2015", o_father["dob"] == "23.05.2015")
if o_mother:
check("Osamu mother DOB is 12.07.2015", o_mother["dob"] == "12.07.2015")
check("Porter present in Danako sheet", "Porter" in danako_by_name)
if "Porter" in danako_by_name:
p = danako_by_name["Porter"]
check("Porter DOB is 23.05.2015", p["dob"] == "23.05.2015")
check("Porter photo matches image6.jpeg", p["photos"] == ["photos/porter-23052015/image6.jpeg"])
check("Yuka present in Danako sheet", "Yuka" in danako_by_name)
if "Yuka" in danako_by_name:
y = danako_by_name["Yuka"]
check("Yuka DOB is 12.07.2015", y["dob"] == "12.07.2015")
check("Yuka photo matches image5.jpeg", y["photos"] == ["photos/yuka-12072015/image5.jpeg"])
check("Eddward present in Danako sheet", "Eddward" in danako_by_name)
if "Eddward" in danako_by_name:
ed = danako_by_name["Eddward"]
check("Eddward DOB is 18.11.2015", ed["dob"] == "18.11.2015")
check("Eddward photo matches image1.jpeg", ed["photos"] == ["photos/eddward-18112015/image1.jpeg"])
check("Harumi present in Danako sheet", "Harumi" in danako_by_name)
if "Harumi" in danako_by_name:
h = danako_by_name["Harumi"]
check("Harumi DOB is 21.02.2015", h["dob"] == "21.02.2015")
check("Harumi photo matches image2.jpeg", h["photos"] == ["photos/harumi-21022015/image2.jpeg"])
else:
print("\nWarning: Danako stammbaum file not found, skipping integration checks.")
if failed:
print(f"\n{failed} test(s) FAILED")
sys.exit(1)