FEAT-8c: import loader — /import/dry-run + /import/execute (gated)

- 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).
This commit is contained in:
2026-06-06 07:33:26 +02:00
parent e431c50347
commit ac5ab9d0ba
9 changed files with 1368 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
using GerbilManagerWebAPI.Import;
using Microsoft.AspNetCore.Http.HttpResults;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// 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.
/// </summary>
public static class ImportEndpoints
{
public static IEndpointRouteBuilder MapImportEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/import").WithTags("Import");
group.MapPost("/dry-run", async Task<Ok<ImportReport>> (
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<Ok<ImportReport>> (
ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var report = await new ImportService(db, config, env).RunAsync(execute: true);
return TypedResults.Ok(report);
});
return app;
}
}
}

View File

@@ -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<string> 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<string> FarbschlagVariants { get; set; } = new();
public SourceGenotype Genotype { get; set; } = new();
public string Zucht { get; set; } = "";
public List<SourceParentRef> ParentRefs { get; set; } = new();
public List<string> Photos { get; set; } = new();
public List<string> SourceFiles { get; set; } = new();
public bool Conflict { get; set; }
public SourceLitterRef? LitterRef { get; set; }
}
public sealed class SourceGenotype
{
public Dictionary<string, List<string>> Mapped8locus { get; set; } = new();
public string RawGenotype { get; set; } = "";
public List<string> 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<string>? 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<string> 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<string> Samples,
IReadOnlyList<string> 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);
}

View File

@@ -0,0 +1,293 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using GerbilManagerWebAPI.Models;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Import
{
/// <summary>
/// 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.
/// </summary>
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<ImportReport> RunAsync(bool execute)
{
var notes = new List<string>();
var samples = new List<string>();
var animals = Load<List<SourceAnimal>>("animals.json") ?? new();
var litters = Load<List<SourceLitter>>("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<SourceAnimal>();
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<string, Guid>(); // 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<string, Guid>(); // 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<T>(string file)
{
var path = Path.Combine(_sourceDir, file);
if (!File.Exists(path)) return default;
using var fs = File.OpenRead(path);
return JsonSerializer.Deserialize<T>(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<string> sires, HashSet<string> 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;
}
}
}

View File

@@ -0,0 +1,897 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CanonicalGenotype")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.HasColumnType("text");
b.Property<string>("Email")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("Phone")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Contacts");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Enclosure", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Enclosures");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Gerbil", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("CauseOfDeath")
.HasColumnType("text");
b.Property<Guid?>("ColorVarietyId")
.HasColumnType("uuid");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date");
b.Property<DateOnly?>("DateOfDeath")
.HasColumnType("date");
b.Property<Guid?>("EnclosureId")
.HasColumnType("uuid");
b.Property<string>("ExternalRef")
.HasColumnType("text");
b.Property<string>("Gender")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Genotype")
.HasColumnType("text");
b.Property<DateOnly?>("GoHomeDate")
.HasColumnType("date");
b.Property<string>("ImportSource")
.HasColumnType("text");
b.Property<Guid?>("LitterId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
b.Property<string>("RawImportData")
.HasColumnType("text");
b.Property<Guid?>("ReceiverContactId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Caption")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("GerbilId");
b.ToTable("GerbilPhotos");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.HealthRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Veterinarian")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("GerbilId");
b.ToTable("HealthRecords");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.Litter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<DateOnly?>("ExpectedGoHomeDate")
.HasColumnType("date");
b.Property<Guid?>("FatherId")
.HasColumnType("uuid");
b.Property<Guid?>("MotherId")
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("PairingCode")
.HasColumnType("text");
b.Property<int?>("TotalBorn")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FatherId");
b.HasIndex("MotherId");
b.ToTable("Litters");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateOnly>("Date")
.HasColumnType("date");
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<int>("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
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddImportProvenanceFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PairingCode",
table: "Litters",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "RawImportData",
table: "Gerbils",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PairingCode",
table: "Litters");
migrationBuilder.DropColumn(
name: "RawImportData",
table: "Gerbils");
}
}
}

View File

@@ -650,6 +650,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");
b.Property<string>("RawImportData")
.HasColumnType("text");
b.Property<Guid?>("ReceiverContactId")
.HasColumnType("uuid");
@@ -759,6 +762,9 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("PairingCode")
.HasColumnType("text");
b.Property<int?>("TotalBorn")
.HasColumnType("integer");

View File

@@ -46,5 +46,9 @@ namespace GerbilManagerWebAPI.Models
// Provenance (for the FEAT-8 spreadsheet import).
public string? ImportSource { get; set; }
public string? ExternalRef { get; set; }
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
public string? RawImportData { get; set; }
}
}

View File

@@ -21,5 +21,9 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Computed ~35 days after Date by default; editable.</summary>
public DateOnly? ExpectedGoHomeDate { get; set; }
public string? Notes { get; set; }
/// <summary>Zuchtnummer der Verpaarung (Wurfchronik col H) — pairing-level code;
/// litters sharing it are the same Zuchtpaar. Set by the FEAT-8 import.</summary>
public string? PairingCode { get; set; }
}
}

View File

@@ -67,5 +67,6 @@ app.MapWeightRecordEndpoints();
app.MapInbreedingEndpoints();
app.MapPhotoEndpoints();
app.MapSaleAdEndpoints();
app.MapImportEndpoints();
app.Run();