From ac5ab9d0baa6f42a3f37ed9d1a520663d173660a Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:33:26 +0200 Subject: [PATCH] =?UTF-8?q?FEAT-8c:=20import=20loader=20=E2=80=94=20/impor?= =?UTF-8?q?t/dry-run=20+=20/import/execute=20(gated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Additive migration: Gerbil.RawImportData + Litter.PairingCode (Zuchtnummer der Verpaarung). - ImportService consumes tools/import/output (animals.json + litters.json): litters first (authoritative), then conflict-free animals with a DOB; high-confidence (hoch) litter links set LitterId, date-only/ambiguous links quarantined; conflicts + stubs never loaded. Genotype mapped8locus -> frozen compact string; rawGenotype+unmappedTokens preserved in RawImportData; Farbschlag matched to ColorVariety; gender inferred from litter role; provenance ImportSource/ExternalRef; idempotent (ExternalRef / Name+Date). Photos copied to store. - ImportEndpoints: POST /import/dry-run (no writes, Julian-readable report) + /import/execute (gated). --- .../Endpoints/ImportEndpoints.cs | 35 + GerbilManagerWebAPI/Import/ImportModels.cs | 90 ++ GerbilManagerWebAPI/Import/ImportService.cs | 293 ++++++ ...2952_AddImportProvenanceFields.Designer.cs | 897 ++++++++++++++++++ ...0260606052952_AddImportProvenanceFields.cs | 38 + .../ApplicationContextModelSnapshot.cs | 6 + GerbilManagerWebAPI/Models/Gerbil.cs | 4 + GerbilManagerWebAPI/Models/Litter.cs | 4 + GerbilManagerWebAPI/Program.cs | 1 + 9 files changed, 1368 insertions(+) create mode 100644 GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs create mode 100644 GerbilManagerWebAPI/Import/ImportModels.cs create mode 100644 GerbilManagerWebAPI/Import/ImportService.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.Designer.cs create mode 100644 GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.cs diff --git a/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs new file mode 100644 index 0000000..cfc5b28 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ImportEndpoints.cs @@ -0,0 +1,35 @@ +using GerbilManagerWebAPI.Import; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// FEAT-8 stage 3 — spreadsheet import loader. Consumes tools/import/output. + /// POST /import/dry-run -> report what WOULD be created/linked/quarantined (no writes) + /// POST /import/execute -> load the conflict-free subset (idempotent). GATED: only run + /// against Julian's DB under god-supervision after he approves the dry-run. + /// + public static class ImportEndpoints + { + public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/import").WithTags("Import"); + + group.MapPost("/dry-run", async Task> ( + ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var report = await new ImportService(db, config, env).RunAsync(execute: false); + return TypedResults.Ok(report); + }); + + group.MapPost("/execute", async Task> ( + ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var report = await new ImportService(db, config, env).RunAsync(execute: true); + return TypedResults.Ok(report); + }); + + return app; + } + } +} diff --git a/GerbilManagerWebAPI/Import/ImportModels.cs b/GerbilManagerWebAPI/Import/ImportModels.cs new file mode 100644 index 0000000..adecdb4 --- /dev/null +++ b/GerbilManagerWebAPI/Import/ImportModels.cs @@ -0,0 +1,90 @@ +using System.Text.Json.Serialization; + +namespace GerbilManagerWebAPI.Import +{ + // ---- Source shapes (tools/import/output JSON, produced by extract.py) ---- + + public sealed class SourceAnimal + { + public string Id { get; set; } = ""; + public string Name { get; set; } = ""; + public List NameVariants { get; set; } = new(); + public string Dob { get; set; } = ""; + public string Death { get; set; } = ""; + public string? Gender { get; set; } + public string Farbschlag { get; set; } = ""; + public List FarbschlagVariants { get; set; } = new(); + public SourceGenotype Genotype { get; set; } = new(); + public string Zucht { get; set; } = ""; + public List ParentRefs { get; set; } = new(); + public List Photos { get; set; } = new(); + public List SourceFiles { get; set; } = new(); + public bool Conflict { get; set; } + public SourceLitterRef? LitterRef { get; set; } + } + + public sealed class SourceGenotype + { + public Dictionary> Mapped8locus { get; set; } = new(); + public string RawGenotype { get; set; } = ""; + public List UnmappedTokens { get; set; } = new(); + } + + public sealed class SourceParentRef + { + public string Name { get; set; } = ""; + public string Dob { get; set; } = ""; + public string RoleGuess { get; set; } = ""; + } + + public sealed class SourceLitterRef + { + public string LitterId { get; set; } = ""; + public string Method { get; set; } = ""; + public string Confidence { get; set; } = ""; // "hoch" | "niedrig" | (ambiguous => candidates) + public List? Candidates { get; set; } + } + + public sealed class SourceLitter + { + public string Id { get; set; } = ""; + public string LitterId { get; set; } = ""; + public string Date { get; set; } = ""; + public string DamName { get; set; } = ""; + public string SireName { get; set; } = ""; + public int? TotalBorn { get; set; } + public string Zuchtnummer { get; set; } = ""; + public string Note { get; set; } = ""; + public List Warnings { get; set; } = new(); + } + + // ---- Report shapes (Julian-readable: counts per category + samples) ---- + + public sealed record ImportReport( + bool Executed, + LitterSummary Litters, + AnimalSummary Animals, + PhotoSummary Photos, + IReadOnlyList Samples, + IReadOnlyList Notes); + + public sealed record LitterSummary(int InSource, int Created, int AlreadyImported); + + public sealed record AnimalSummary( + int InSource, + int Created, + int LinkedToLitter, + int FarbschlagMatched, + int FarbschlagUnmatched, + int AlreadyImported, + QuarantineSummary Quarantined); + + public sealed record QuarantineSummary( + int Conflicts, + int Stubs, + int DateOnlyLinks, + int AmbiguousLinks, + int Total); + + public sealed record PhotoSummary(int Attached, int SourceFilesMissing); +} diff --git a/GerbilManagerWebAPI/Import/ImportService.cs b/GerbilManagerWebAPI/Import/ImportService.cs new file mode 100644 index 0000000..a254009 --- /dev/null +++ b/GerbilManagerWebAPI/Import/ImportService.cs @@ -0,0 +1,293 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using GerbilManagerWebAPI.Models; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Import +{ + /// + /// FEAT-8c import loader. Consumes tools/import/output (animals.json + litters.json + /// produced by extract.py) and loads conflict-free data into the database: + /// litters first (Wurfchronik = authoritative), then animals matched onto them. + /// + /// Load policy: + /// - Litters: all created (idempotent by ExternalRef = source litter id). + /// - Animals: created when they have a DOB and are NOT in conflict. The birth-litter + /// link is set only for HIGH-confidence matches (litterRef.confidence == "hoch"); + /// date-only/ambiguous links are quarantined (animal loads with LitterId = null). + /// - QUARANTINED (never loaded): conflicts + stubs (no DOB) — await the wife's review. + /// Idempotent: re-running matches on ExternalRef and skips existing rows. + /// Execute is gated by the endpoint; this service only acts when asked. + /// + public sealed class ImportService + { + private static readonly string[] LocusOrder = { "A", "C", "D", "E", "G", "P", "Sp", "Re" }; + private static readonly JsonSerializerOptions Json = new() + { + PropertyNameCaseInsensitive = true, + }; + private const string ImportSourceTag = "FEAT-8 Stammbaum/Wurfchronik"; + + private readonly ApplicationContext _db; + private readonly string _sourceDir; + private readonly string _photoRoot; + + public ImportService(ApplicationContext db, IConfiguration config, IWebHostEnvironment env) + { + _db = db; + _sourceDir = config["Import:SourcePath"] + ?? Path.GetFullPath(Path.Combine(env.ContentRootPath, "..", "tools", "import", "output")); + _photoRoot = config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage"); + } + + public async Task RunAsync(bool execute) + { + var notes = new List(); + var samples = new List(); + + var animals = Load>("animals.json") ?? new(); + var litters = Load>("litters.json") ?? new(); + if (animals.Count == 0 && litters.Count == 0) + notes.Add($"Keine Quelldaten gefunden in {_sourceDir} (animals.json/litters.json). extract.py zuerst ausführen."); + + // ---- categorise animals ---- + var loadable = new List(); + int conflicts = 0, stubs = 0, dateOnly = 0, ambiguous = 0; + foreach (var a in animals) + { + if (a.Conflict) { conflicts++; continue; } + if (string.IsNullOrEmpty(a.Dob)) { stubs++; continue; } + loadable.Add(a); + var conf = a.LitterRef?.Confidence; + if (a.LitterRef?.Candidates is { Count: > 0 }) ambiguous++; + else if (conf == "niedrig") dateOnly++; + } + + // existing rows (idempotency). Gerbils carry ExternalRef; Litters have no such + // column, so we key litter idempotency on the stable (Name + Date) pair instead. + var existingGerbilExtRefs = await _db.Gerbils + .Where(g => g.ExternalRef != null) + .Select(g => g.ExternalRef!).ToListAsync(); + var existingGerbilSet = existingGerbilExtRefs.ToHashSet(); + + var existingLitterKeys = await _db.Litters + .Select(l => new { l.Name, l.Date }).ToListAsync(); + var existingLitterKeySet = existingLitterKeys + .Select(x => $"{x.Name}|{x.Date:yyyy-MM-dd}").ToHashSet(); + + // colour-variety name -> id (case-insensitive) + var varieties = await _db.ColorVarieties.Select(v => new { v.Id, v.Name }).ToListAsync(); + var varietyByName = varieties + .GroupBy(v => v.Name.Trim().ToLowerInvariant()) + .ToDictionary(g => g.Key, g => g.First().Id); + + // gender inference from litter roles (sire -> male, dam -> female; both -> unknown) + var sireNames = litters.Select(l => Normalize(StripZucht(l.SireName))).Where(s => s.Length > 0).ToHashSet(); + var damNames = litters.Select(l => Normalize(StripZucht(l.DamName))).Where(s => s.Length > 0).ToHashSet(); + + // ---- litters: create map source.id -> Litter (for high-confidence animal links) ---- + int littersCreated = 0, littersExisting = 0; + var litterIdMap = new Dictionary(); // source litter id -> Litter.Id + foreach (var sl in litters) + { + var date = ParseDate(sl.Date); + var name = $"Wurf {sl.LitterId}".Trim(); + var key = $"{name}|{date:yyyy-MM-dd}"; + if (existingLitterKeySet.Contains(key)) { littersExisting++; continue; } + + var id = Guid.NewGuid(); + litterIdMap[sl.Id] = id; + littersCreated++; + if (execute && date is DateOnly d) + { + _db.Litters.Add(new Litter + { + Id = id, + Name = name, + Date = d, + TotalBorn = sl.TotalBorn, + Notes = string.IsNullOrWhiteSpace(sl.Note) ? null : sl.Note, + PairingCode = string.IsNullOrWhiteSpace(sl.Zuchtnummer) ? null : sl.Zuchtnummer, + }); + } + if (samples.Count < 8 && date is not null) + samples.Add($"Wurf: {name} ({sl.Date}) — {sl.DamName} × {sl.SireName}"); + } + if (execute) await _db.SaveChangesAsync(); + + // ---- animals ---- + int animalsCreated = 0, linked = 0, fbMatched = 0, fbUnmatched = 0, animalsExisting = 0; + int photosAttached = 0, photosMissing = 0; + var createdAnimalByName = new Dictionary(); // normalized name -> gerbil id (for litter back-link) + + foreach (var a in loadable) + { + if (existingGerbilSet.Contains(a.Id)) { animalsExisting++; continue; } + animalsCreated++; + + Guid? litterId = null; + if (a.LitterRef?.Confidence == "hoch" && a.LitterRef.Candidates is not { Count: > 0 } + && litterIdMap.TryGetValue(a.LitterRef.LitterId, out var lid)) + { + litterId = lid; + linked++; + } + + Guid? colorVarietyId = null; + var fbCandidates = new[] { a.Farbschlag }.Concat(a.FarbschlagVariants) + .Where(s => !string.IsNullOrWhiteSpace(s)); + foreach (var fb in fbCandidates) + { + if (varietyByName.TryGetValue(fb.Trim().ToLowerInvariant(), out var vid)) + { colorVarietyId = vid; break; } + } + if (colorVarietyId is null) fbUnmatched++; else fbMatched++; + + var gender = InferGender(a, sireNames, damNames); + var gid = Guid.NewGuid(); + var norm = Normalize(StripZucht(a.Name)); + if (norm.Length > 0) createdAnimalByName.TryAdd(norm, gid); + + if (samples.Count < 16) + samples.Add($"Tier: {a.Name} (*{a.Dob}), Genotyp {ComposeGenotype(a.Genotype)}" + + (litterId is not null ? ", Wurf-verknüpft" : "") + + (colorVarietyId is not null ? $", Farbschlag „{a.Farbschlag}\"" : "")); + + if (execute) + { + _db.Gerbils.Add(new Gerbil + { + Id = gid, + Name = a.Name, + Gender = gender, + Status = GerbilStatus.Active, + DateOfBirth = ParseDate(a.Dob), + DateOfDeath = ParseDate(a.Death), + LitterId = litterId, + ColorVarietyId = colorVarietyId, + Genotype = ComposeGenotype(a.Genotype), + ImportSource = ImportSourceTag, + ExternalRef = a.Id, + RawImportData = JsonSerializer.Serialize(new + { + a.Genotype.RawGenotype, + a.Genotype.UnmappedTokens, + a.Zucht, + a.SourceFiles, + FarbschlagRaw = a.Farbschlag, + }), + }); + } + + // photos + foreach (var rel in a.Photos) + { + var src = Path.Combine(_sourceDir, rel.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(src)) { photosMissing++; continue; } + photosAttached++; + if (execute) + { + Directory.CreateDirectory(_photoRoot); + var fileName = $"{Guid.NewGuid():N}{Path.GetExtension(src)}"; + File.Copy(src, Path.Combine(_photoRoot, fileName), overwrite: true); + _db.GerbilPhotos.Add(new GerbilPhoto + { + Id = Guid.NewGuid(), + GerbilId = gid, + FileName = fileName, + SortOrder = 0, + CreatedAt = DateTimeOffset.UtcNow, + }); + } + } + } + if (execute) await _db.SaveChangesAsync(); + + // ---- back-link litter parents by name (best effort) ---- + if (execute) + { + foreach (var sl in litters) + { + if (!litterIdMap.TryGetValue(sl.Id, out var lid)) continue; + var litter = await _db.Litters.FirstOrDefaultAsync(l => l.Id == lid); + if (litter is null) continue; + if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.SireName)), out var fId)) + litter.FatherId = fId; + if (createdAnimalByName.TryGetValue(Normalize(StripZucht(sl.DamName)), out var mId)) + litter.MotherId = mId; + } + await _db.SaveChangesAsync(); + } + + notes.Add("Quarantäne (kein Import): Konflikte + Stubs ohne Geburtsdatum + unsichere Wurf-Zuordnungen — warten auf die Prüfung durch die Züchterin."); + if (!execute) notes.Add("DRY-RUN: nichts gespeichert. /import/execute lädt die konfliktfreien Daten."); + + return new ImportReport( + Executed: execute, + Litters: new LitterSummary(litters.Count, littersCreated, littersExisting), + Animals: new AnimalSummary( + animals.Count, animalsCreated, linked, fbMatched, fbUnmatched, animalsExisting, + new QuarantineSummary(conflicts, stubs, dateOnly, ambiguous, conflicts + stubs)), + Photos: new PhotoSummary(photosAttached, photosMissing), + Samples: samples, + Notes: notes); + } + + private T? Load(string file) + { + var path = Path.Combine(_sourceDir, file); + if (!File.Exists(path)) return default; + using var fs = File.OpenRead(path); + return JsonSerializer.Deserialize(fs, Json); + } + + internal static string ComposeGenotype(SourceGenotype g) + { + var tokens = LocusOrder.Select(locus => + { + if (g.Mapped8locus.TryGetValue(locus, out var pair) && pair.Count == 2) + return StripCaret(pair[0]) + StripCaret(pair[1]); + return "??"; + }); + return string.Join(' ', tokens); + } + + private static string StripCaret(string allele) => allele.Replace("^", ""); + + private static Gender InferGender(SourceAnimal a, HashSet sires, HashSet dams) + { + var n = Normalize(StripZucht(a.Name)); + bool isSire = sires.Contains(n), isDam = dams.Contains(n); + if (isSire && !isDam) return Gender.male; + if (isDam && !isSire) return Gender.female; + return Gender.unknown; + } + + internal static DateOnly? ParseDate(string s) + { + if (string.IsNullOrWhiteSpace(s)) return null; + var m = Regex.Match(s, @"(\d{1,2})\.(\d{1,2})\.(\d{2,4})"); + if (!m.Success) return null; + int d = int.Parse(m.Groups[1].Value), mo = int.Parse(m.Groups[2].Value); + int y = int.Parse(m.Groups[3].Value); + if (y < 100) y += 2000; + try { return new DateOnly(y, mo, d); } + catch { return null; } + } + + private static string StripZucht(string name) + { + var n = Regex.Replace(name ?? "", @"\[.*?\]", " "); + n = Regex.Replace(n, @"\s+(?:of|von\s+den|von\s+der|v\.\s?d\.|von)\s+.*$", "", RegexOptions.IgnoreCase); + return n.Trim(); + } + + private static string Normalize(string name) + { + var n = (name ?? "").ToLowerInvariant(); + n = Regex.Replace(n, @"\bgen\.\b", " "); + n = Regex.Replace(n, @"[^a-z0-9äöüß]", ""); + return n; + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.Designer.cs new file mode 100644 index 0000000..bf9b777 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.Designer.cs @@ -0,0 +1,897 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + [DbContext(typeof(ApplicationContext))] + [Migration("20260606052952_AddImportProvenanceFields")] + partial class AddImportProvenanceFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanonicalGenotype") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ColorVarieties"); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + CanonicalGenotype = "AA chch DD EE GG pp spsp rere", + Name = "Pink Eyed White (PEW)", + SortOrder = 0 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000002"), + CanonicalGenotype = "aa chch DD EE GG PP spsp rere", + Name = "Hermelin", + SortOrder = 1 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000003"), + CanonicalGenotype = "AA chch DD EE GG PP spsp rere", + Name = "Himalaya", + SortOrder = 2 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000004"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel", + SortOrder = 3 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000005"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Schwarzschimmel", + SortOrder = 4 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000006"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Rotaugenschimmel", + SortOrder = 5 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000007"), + CanonicalGenotype = "AA CC DD EE GG PP spsp rere", + Name = "Agouti", + SortOrder = 6 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000008"), + CanonicalGenotype = "aa CC DD EE GG PP spsp rere", + Name = "Schwarz", + SortOrder = 7 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000009"), + CanonicalGenotype = "AA CC DD EE gg PP spsp rere", + Name = "Silberagouti", + SortOrder = 8 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000010"), + CanonicalGenotype = "aa CC DD EE gg PP spsp rere", + Name = "Anthrazit", + SortOrder = 9 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000011"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs", + SortOrder = 10 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000012"), + CanonicalGenotype = "aa CC dd EE GG PP spsp rere", + Name = "Blau", + SortOrder = 11 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000013"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold", + SortOrder = 12 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000014"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin", + SortOrder = 13 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000015"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs", + SortOrder = 14 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000016"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs", + SortOrder = 15 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000017"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "dd Gold", + SortOrder = 16 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000018"), + CanonicalGenotype = "aa CC dd EE GG pp spsp rere", + Name = "dd Platin", + SortOrder = 17 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000019"), + CanonicalGenotype = "aa CC DD EE gg pp spsp rere", + Name = "Altweiss (REW)", + SortOrder = 18 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000020"), + CanonicalGenotype = "AA CC DD ee gg pp spsp rere", + Name = "Apricot (Blassfuchs)", + SortOrder = 19 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000021"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs", + SortOrder = 20 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000022"), + CanonicalGenotype = "aa CC DD ee gg pp spsp rere", + Name = "C-Separator", + SortOrder = 21 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000023"), + CanonicalGenotype = "AA CC DD EE gg pp spsp rere", + Name = "Elfenbein", + SortOrder = 22 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000024"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs", + SortOrder = 23 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000025"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Marder", + SortOrder = 24 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000026"), + CanonicalGenotype = "aa cchmcchm DD EE GG PP spsp rere", + Name = "Siam (Marder-Hell)", + SortOrder = 25 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000027"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs", + SortOrder = 26 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000028"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Saphir", + SortOrder = 27 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000029"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Schimmel (Orangeschimmel)", + SortOrder = 28 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000030"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Topas", + SortOrder = 29 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000031"), + CanonicalGenotype = "aa CC DD EE GG pp spsp rere", + Name = "Platin-Hell", + SortOrder = 30 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000032"), + CanonicalGenotype = "AA CC dd EE GG PP spsp rere", + Name = "Agouti dd", + SortOrder = 31 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000033"), + CanonicalGenotype = "AA CC dd EE gg PP spsp rere", + Name = "Silberagouti dd", + SortOrder = 32 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000034"), + CanonicalGenotype = "aa CC dd ee GG PP spsp rere", + Name = "Kohlfuchs dd", + SortOrder = 33 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000035"), + CanonicalGenotype = "aa CC dd EE gg PP spsp rere", + Name = "Anthrazit dd", + SortOrder = 34 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000036"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP-Hell", + SortOrder = 35 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000037"), + CanonicalGenotype = "aa cchmcchm DD ee gg PP spsp rere", + Name = "Blaufuchs CP", + SortOrder = 36 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000038"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Polarfuchsschimmel", + SortOrder = 37 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000039"), + CanonicalGenotype = "AA CC DD efef gg PP spsp rere", + Name = "Silberschimmel", + SortOrder = 38 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000040"), + CanonicalGenotype = "AA CC DD efef GG PP spsp rere", + Name = "Algierfuchsschimmel", + SortOrder = 39 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000041"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs-Hell CP", + SortOrder = 40 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000042"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel", + SortOrder = 41 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000043"), + CanonicalGenotype = "aa CC DD efef gg PP spsp rere", + Name = "Blaufuchsschimmel", + SortOrder = 42 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000044"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs, hell", + SortOrder = 43 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000045"), + CanonicalGenotype = "AA CC DD ee GG pp spsp rere", + Name = "Goldfuchs, hell", + SortOrder = 44 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000046"), + CanonicalGenotype = "AA CC DD efef GG pp spsp rere", + Name = "Goldfuchsschimmel", + SortOrder = 45 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000047"), + CanonicalGenotype = "AA CC DD EE GG pp spsp rere", + Name = "Gold-Hell", + SortOrder = 46 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000048"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Siam (Marder-Hell) dd", + SortOrder = 47 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000049"), + CanonicalGenotype = "aa cchmcchm dd EE GG PP spsp rere", + Name = "Marder dd", + SortOrder = 48 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000050"), + CanonicalGenotype = "aa cchmcchm DD EE gg PP spsp rere", + Name = "Zobel-Hell", + SortOrder = 49 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000051"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti dd CP", + SortOrder = 50 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000052"), + CanonicalGenotype = "AA cchmcchm dd EE gg PP spsp rere", + Name = "Silberagouti-Hell dd CP", + SortOrder = 51 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000053"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti dd CP", + SortOrder = 52 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000054"), + CanonicalGenotype = "AA cchmcchm dd EE GG PP spsp rere", + Name = "Agouti-Hell dd CP", + SortOrder = 53 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000055"), + CanonicalGenotype = "aa CC DD ee gg PP spsp rere", + Name = "Blaufuchs, hell", + SortOrder = 54 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000056"), + CanonicalGenotype = "aa CC DD efef GG pp spsp rere", + Name = "Rotfuchsschimmel", + SortOrder = 55 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000057"), + CanonicalGenotype = "AA CC DD ee gg PP spsp rere", + Name = "Polarfuchs, hell", + SortOrder = 56 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000058"), + CanonicalGenotype = "aa CC DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel, hell", + SortOrder = 57 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000059"), + CanonicalGenotype = "aa CC DD ee GG pp spsp rere", + Name = "Rotfuchs, hell", + SortOrder = 58 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000060"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel dd", + SortOrder = 59 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000061"), + CanonicalGenotype = "aa CC DD ee GG PP spsp rere", + Name = "Kohlfuchs-Hell", + SortOrder = 60 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000062"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs CP", + SortOrder = 61 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000063"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs CP", + SortOrder = 62 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000064"), + CanonicalGenotype = "AA cchmcchm DD EE gg PP spsp rere", + Name = "Silberagouti CP", + SortOrder = 63 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000065"), + CanonicalGenotype = "AA cchmcchm DD EE GG PP spsp rere", + Name = "Agouti CP", + SortOrder = 64 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000066"), + CanonicalGenotype = "AA cchmcchm DD ee GG PP spsp rere", + Name = "Algierfuchs-Hell CP", + SortOrder = 65 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000067"), + CanonicalGenotype = "aa cchmcchm DD ee GG PP spsp rere", + Name = "Kohlfuchs,hell CP", + SortOrder = 66 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000068"), + CanonicalGenotype = "AA cchmcchm DD ee gg PP spsp rere", + Name = "Polarfuchs CP", + SortOrder = 67 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000069"), + CanonicalGenotype = "AA CC DD ee GG PP spsp rere", + Name = "Algierfuchs, hell", + SortOrder = 68 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000070"), + CanonicalGenotype = "AA CC dd EE GG pp spsp rere", + Name = "Topas dd", + SortOrder = 69 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000071"), + CanonicalGenotype = "aa cchmcchm dd EE gg PP spsp rere", + Name = "Zobel-Hell dd", + SortOrder = 70 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000072"), + CanonicalGenotype = "aa cchmcchm DD efef GG PP spsp rere", + Name = "Kohlfuchsschimmel CP", + SortOrder = 71 + }, + new + { + Id = new Guid("00000000-0000-0000-0000-000000000073"), + CanonicalGenotype = "aa CC dd ee gg PP spsp rere", + Name = "Blaufuchs dd", + SortOrder = 72 + }); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Contact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Phone") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Enclosures"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CauseOfDeath") + .HasColumnType("text"); + + b.Property("ColorVarietyId") + .HasColumnType("uuid"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DateOfDeath") + .HasColumnType("date"); + + b.Property("EnclosureId") + .HasColumnType("uuid"); + + b.Property("ExternalRef") + .HasColumnType("text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text"); + + b.Property("Genotype") + .HasColumnType("text"); + + b.Property("GoHomeDate") + .HasColumnType("date"); + + b.Property("ImportSource") + .HasColumnType("text"); + + b.Property("LitterId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OriginContactId") + .HasColumnType("uuid"); + + b.Property("RawImportData") + .HasColumnType("text"); + + b.Property("ReceiverContactId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ColorVarietyId"); + + b.HasIndex("EnclosureId"); + + b.HasIndex("LitterId"); + + b.HasIndex("OriginContactId"); + + b.HasIndex("ReceiverContactId"); + + b.ToTable("Gerbils"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Caption") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("GerbilPhotos"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("Veterinarian") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("ExpectedGoHomeDate") + .HasColumnType("date"); + + b.Property("FatherId") + .HasColumnType("uuid"); + + b.Property("MotherId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PairingCode") + .HasColumnType("text"); + + b.Property("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("WeightGrams") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GerbilId"); + + b.ToTable("WeightRecords"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b => + { + b.HasOne("GerbilManagerWebAPI.Models.ColorVariety", "ColorVariety") + .WithMany() + .HasForeignKey("ColorVarietyId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Enclosure", "Enclosure") + .WithMany("Gerbils") + .HasForeignKey("EnclosureId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Litter", "Litter") + .WithMany() + .HasForeignKey("LitterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "OriginContact") + .WithMany() + .HasForeignKey("OriginContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Contact", "ReceiverContact") + .WithMany() + .HasForeignKey("ReceiverContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ColorVariety"); + + b.Navigation("Enclosure"); + + b.Navigation("Litter"); + + b.Navigation("OriginContact"); + + b.Navigation("ReceiverContact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.GerbilPhoto", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Father") + .WithMany() + .HasForeignKey("FatherId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Mother") + .WithMany() + .HasForeignKey("MotherId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Father"); + + b.Navigation("Mother"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b => + { + b.Navigation("Gerbils"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.cs b/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.cs new file mode 100644 index 0000000..c7c5f08 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606052952_AddImportProvenanceFields.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class AddImportProvenanceFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PairingCode", + table: "Litters", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "RawImportData", + table: "Gerbils", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PairingCode", + table: "Litters"); + + migrationBuilder.DropColumn( + name: "RawImportData", + table: "Gerbils"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 2116917..b27de05 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -650,6 +650,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("OriginContactId") .HasColumnType("uuid"); + b.Property("RawImportData") + .HasColumnType("text"); + b.Property("ReceiverContactId") .HasColumnType("uuid"); @@ -759,6 +762,9 @@ namespace GerbilManagerWebAPI.Migrations b.Property("Notes") .HasColumnType("text"); + b.Property("PairingCode") + .HasColumnType("text"); + b.Property("TotalBorn") .HasColumnType("integer"); diff --git a/GerbilManagerWebAPI/Models/Gerbil.cs b/GerbilManagerWebAPI/Models/Gerbil.cs index 0839591..5d96bce 100644 --- a/GerbilManagerWebAPI/Models/Gerbil.cs +++ b/GerbilManagerWebAPI/Models/Gerbil.cs @@ -46,5 +46,9 @@ namespace GerbilManagerWebAPI.Models // Provenance (for the FEAT-8 spreadsheet import). public string? ImportSource { get; set; } public string? ExternalRef { get; set; } + + /// Raw import payload preserved verbatim (rawGenotype + unmappedTokens like + /// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text. + public string? RawImportData { get; set; } } } diff --git a/GerbilManagerWebAPI/Models/Litter.cs b/GerbilManagerWebAPI/Models/Litter.cs index 89b68af..954c4c4 100644 --- a/GerbilManagerWebAPI/Models/Litter.cs +++ b/GerbilManagerWebAPI/Models/Litter.cs @@ -21,5 +21,9 @@ namespace GerbilManagerWebAPI.Models /// Computed ~35 days after Date by default; editable. public DateOnly? ExpectedGoHomeDate { get; set; } public string? Notes { get; set; } + + /// Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code; + /// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import. + public string? PairingCode { get; set; } } } diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 6c28261..335275e 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -67,5 +67,6 @@ app.MapWeightRecordEndpoints(); app.MapInbreedingEndpoints(); app.MapPhotoEndpoints(); app.MapSaleAdEndpoints(); +app.MapImportEndpoints(); app.Run();