diff --git a/GerbilManager.Tests/ApiFactory.cs b/GerbilManager.Tests/ApiFactory.cs new file mode 100644 index 0000000..98c1167 --- /dev/null +++ b/GerbilManager.Tests/ApiFactory.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace GerbilManager.Tests; + +/// +/// FEAT-13: In-Memory-Test-Host für Endpoint-Round-Trips. Ersetzt die +/// Aspire/Npgsql-Registrierung durch SQLite in-memory (eine offene Verbindung +/// hält die DB am Leben), Umgebung "Testing" überspringt Database.Migrate() +/// (Npgsql-Migrationen laufen nicht auf SQLite) — Schema via EnsureCreated, +/// inklusive der HasData-Seeds (73 Farbschläge + Zuchtprofil-Singleton). +/// Vertrags-Dateien landen in einem Temp-Ordner, der mit dem Host stirbt. +/// +public sealed class ApiFactory : WebApplicationFactory +{ + private readonly SqliteConnection _connection = new("DataSource=:memory:"); + + public string ContractRoot { get; } = + Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}"); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + // Dummy, damit Aspires AddNpgsqlDbContext beim Host-Aufbau zufrieden ist; + // die eigentliche Registrierung wird unten durch SQLite ersetzt. + builder.UseSetting("ConnectionStrings:gerbilmanager", + "Host=localhost;Database=test;Username=test;Password=test"); + builder.UseSetting("Contracts:RootPath", ContractRoot); + + builder.ConfigureServices(services => + { + // EF 9+: AddDbContext registriert die Provider-Konfiguration als + // IDbContextOptionsConfiguration — ohne deren Entfernung blieben + // Npgsql UND SQLite registriert (ein Provider pro ServiceProvider). + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll(); + + _connection.Open(); + services.AddDbContext(o => o.UseSqlite(_connection)); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + }); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) + { + _connection.Dispose(); + if (Directory.Exists(ContractRoot)) Directory.Delete(ContractRoot, recursive: true); + } + } +} diff --git a/GerbilManager.Tests/ContractEndpointTests.cs b/GerbilManager.Tests/ContractEndpointTests.cs new file mode 100644 index 0000000..b6ed59a --- /dev/null +++ b/GerbilManager.Tests/ContractEndpointTests.cs @@ -0,0 +1,198 @@ +using System.IO.Compression; +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace GerbilManager.Tests; + +/// +/// FEAT-13 phase B: Endpoint-Round-Trips für /contracts + /settings/breeder-profile +/// gegen den In-Memory-Host (ApiFactory). Deckt den Abgabe-Abschluss ab: +/// Vertrag erzeugen -> Tiere stehen auf Abgegeben -> .docx ist herunterladbar. +/// +public class ContractEndpointTests : IClassFixture +{ + private static readonly JsonSerializerOptions Json = CreateJsonOptions(); + + private static JsonSerializerOptions CreateJsonOptions() + { + var o = new JsonSerializerOptions(JsonSerializerDefaults.Web); + o.Converters.Add(new JsonStringEnumConverter()); + return o; + } + + private readonly ApiFactory _factory; + + public ContractEndpointTests(ApiFactory factory) => _factory = factory; + + private sealed record IdDto(Guid Id); + + private sealed record GerbilView(Guid Id, string Status, Guid? ReceiverContactId, DateOnly? GoHomeDate); + + private sealed record ContractView( + Guid Id, Guid ContactId, decimal Price, DateOnly HandoverDate, DateOnly ContractDate, + string FileName, List GerbilIds, string Url); + + private sealed record Paged(List Items, int TotalCount, int Page, int PageSize); + + private async Task CreateContactAsync(HttpClient client, string name = "Herr Max Beispiel") + { + var response = await client.PostAsJsonAsync("/contacts", new + { + name, + email = "max@example.com", + phone = "0987 654321", + address = "Beispielallee 7, 54321 Beispielstadt", + }, Json); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(Json))!.Id; + } + + private async Task CreateGerbilAsync(HttpClient client, string name) + { + var response = await client.PostAsJsonAsync("/gerbils", new + { + name, + gender = "female", + dateOfBirth = "2025-03-09", + }, Json); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(Json))!.Id; + } + + [Fact] + public async Task ZuchtprofilRoundTrip_PutDannGet() + { + var client = _factory.CreateClient(); + + var put = await client.PutAsJsonAsync("/settings/breeder-profile", new + { + zuchtName = "Zucht Testhausen", + name = "Frau Erika Muster", + address = "Musterweg 1, 12345 Testhausen", + phone = "0123 456789", + email = "zucht@example.org", + homepage = "https://zucht.example.org/", + city = "Testhausen", + }, Json); + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + + var profile = await client.GetFromJsonAsync>("/settings/breeder-profile", Json); + Assert.Equal("Zucht Testhausen", profile!["zuchtName"]); + Assert.Equal("Testhausen", profile["city"]); + } + + [Fact] + public async Task VertragErzeugen_SchließtDieAbgabeAb() + { + var client = _factory.CreateClient(); + + // Zuchtprofil füllen, damit der Verkäufer-Block im Dokument landet. + await client.PutAsJsonAsync("/settings/breeder-profile", new + { + zuchtName = "Zucht Testhausen", + name = "Frau Erika Muster", + address = "Musterweg 1, 12345 Testhausen", + phone = "0123 456789", + email = "zucht@example.org", + homepage = "https://zucht.example.org/", + city = "Testhausen", + }, Json); + + var contactId = await CreateContactAsync(client); + var krümel = await CreateGerbilAsync(client, "Krümel"); + var luna = await CreateGerbilAsync(client, "Luna"); + + // POST /contracts — der Abschluss. + var post = await client.PostAsJsonAsync("/contracts", new + { + contactId, + gerbilIds = new[] { krümel, luna }, + price = 72.0m, + handoverDate = "2026-06-05", + }, Json); + Assert.Equal(HttpStatusCode.Created, post.StatusCode); + var contract = (await post.Content.ReadFromJsonAsync(Json))!; + + Assert.Equal(contactId, contract.ContactId); + Assert.Equal(2, contract.GerbilIds.Count); + Assert.Equal(new DateOnly(2026, 6, 5), contract.ContractDate); // Standard = Übergabedatum + Assert.Equal($"/contracts/{contract.Id}/file", contract.Url); + + // Tiere stehen jetzt auf Abgegeben — mit Abnehmer und Abgabedatum. + foreach (var id in new[] { krümel, luna }) + { + var gerbil = await client.GetFromJsonAsync($"/gerbils/{id}", Json); + Assert.Equal("GivenAway", gerbil!.Status); + Assert.Equal(contactId, gerbil.ReceiverContactId); + Assert.Equal(new DateOnly(2026, 6, 5), gerbil.GoHomeDate); + } + + // Download: echtes .docx mit beiden Tieren, Käufer, de-DE-Preis. + var file = await client.GetAsync(contract.Url); + Assert.Equal(HttpStatusCode.OK, file.StatusCode); + Assert.Equal( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file.Content.Headers.ContentType!.MediaType); + Assert.Contains("Abgabevertrag_2026-06-05_Herr-Max-Beispiel.docx", + file.Content.Headers.ContentDisposition!.FileNameStar ?? file.Content.Headers.ContentDisposition.FileName); + + var text = DocumentText(await file.Content.ReadAsByteArrayAsync()); + Assert.Contains("Krümel", text); + Assert.Contains("Luna", text); + Assert.Contains("Herr Max Beispiel", text); + Assert.Contains("Kaufpreis von 72,00 €", text); + Assert.Contains("Testhausen, den 05.06.2026", text); + Assert.DoesNotContain("{{", text); + + // Liste: nach Abnehmer filterbar (Gridify). + var list = await client.GetFromJsonAsync>( + $"/contracts?filter=contactId=={contactId}", Json); + Assert.Equal(1, list!.TotalCount); + Assert.Equal(contract.Id, list.Items[0].Id); + + // DELETE räumt Zeile und Datei ab; Tier-Status bleibt unangetastet. + var delete = await client.DeleteAsync($"/contracts/{contract.Id}"); + Assert.Equal(HttpStatusCode.NoContent, delete.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(contract.Url)).StatusCode); + var still = await client.GetFromJsonAsync($"/gerbils/{krümel}", Json); + Assert.Equal("GivenAway", still!.Status); + } + + [Fact] + public async Task VertragOhneTiereOderMitUnbekanntemTier_Validierungsfehler() + { + var client = _factory.CreateClient(); + var contactId = await CreateContactAsync(client, "Frau Lisa Test"); + + var empty = await client.PostAsJsonAsync("/contracts", new + { + contactId, + gerbilIds = Array.Empty(), + price = 10m, + handoverDate = "2026-06-05", + }, Json); + Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); + + var unknown = await client.PostAsJsonAsync("/contracts", new + { + contactId, + gerbilIds = new[] { Guid.NewGuid() }, + price = 10m, + handoverDate = "2026-06-05", + }, Json); + Assert.Equal(HttpStatusCode.BadRequest, unknown.StatusCode); + } + + /// Sichtbarer Text aus word/document.xml (Tags entfernt). + private static string DocumentText(byte[] docx) + { + using var zip = new ZipArchive(new MemoryStream(docx), ZipArchiveMode.Read); + var entry = Assert.Single(zip.Entries, e => e.FullName == "word/document.xml"); + using var reader = new StreamReader(entry.Open(), Encoding.UTF8); + return Regex.Replace(reader.ReadToEnd(), "<[^>]+>", ""); + } +} diff --git a/GerbilManager.Tests/GerbilManager.Tests.csproj b/GerbilManager.Tests/GerbilManager.Tests.csproj index d59000b..4feafc8 100644 --- a/GerbilManager.Tests/GerbilManager.Tests.csproj +++ b/GerbilManager.Tests/GerbilManager.Tests.csproj @@ -9,6 +9,8 @@ + + diff --git a/GerbilManagerWebAPI/ApplicationContext.cs b/GerbilManagerWebAPI/ApplicationContext.cs index aae01c7..fad81b3 100644 --- a/GerbilManagerWebAPI/ApplicationContext.cs +++ b/GerbilManagerWebAPI/ApplicationContext.cs @@ -15,6 +15,8 @@ public class ApplicationContext : DbContext public DbSet GerbilPhotos => Set(); public DbSet HealthRecords => Set(); public DbSet WeightRecords => Set(); + public DbSet SaleContracts => Set(); + public DbSet BreederSettings => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -59,6 +61,31 @@ public class ApplicationContext : DbContext e.HasOne().WithMany() .HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade)); + // FEAT-13: Abgabeverträge + Zuchtprofil. + modelBuilder.Entity(e => + { + e.Property(c => c.Price).HasPrecision(10, 2); + // Restrict: ein Kontakt mit Verträgen ist ein Dokumentenbestand, + // kein versehentlich löschbarer Datensatz. + e.HasOne(c => c.Contact).WithMany() + .HasForeignKey(c => c.ContactId).OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(e => + { + e.HasKey(a => new { a.SaleContractId, a.GerbilId }); + e.HasOne().WithMany(c => c.Animals) + .HasForeignKey(a => a.SaleContractId).OnDelete(DeleteBehavior.Cascade); + // Cascade: wird ein Tier gelöscht, verschwindet nur die Verknüpfung — + // der Vertrag (und seine .docx als Beleg) bleibt bestehen. + e.HasOne(a => a.Gerbil).WithMany() + .HasForeignKey(a => a.GerbilId).OnDelete(DeleteBehavior.Cascade); + }); + + // Zuchtprofil: genau eine (leere) Zeile mit fixer Id. + modelBuilder.Entity() + .HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId }); + SeedColorVarieties(modelBuilder); } diff --git a/GerbilManagerWebAPI/Dtos/ContractDtos.cs b/GerbilManagerWebAPI/Dtos/ContractDtos.cs new file mode 100644 index 0000000..0d24787 --- /dev/null +++ b/GerbilManagerWebAPI/Dtos/ContractDtos.cs @@ -0,0 +1,33 @@ +namespace GerbilManagerWebAPI.Dtos +{ + // FEAT-13: Abgabeverträge + Zuchtprofil (eigene Datei, hält ApiDtos.cs konfliktfrei). + + public record SaleContractDto( + Guid Id, + Guid ContactId, + decimal Price, + DateOnly HandoverDate, + DateOnly ContractDate, + string FileName, + DateTimeOffset CreatedAt, + IReadOnlyList GerbilIds, + // Download-URL der .docx ("/contracts/{id}/file"). + string Url); + + public record SaleContractInput( + Guid ContactId, + List GerbilIds, + decimal Price, + DateOnly HandoverDate, + DateOnly? ContractDate); + + /// Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile. + public record BreederProfileDto( + string ZuchtName, + string Name, + string Address, + string Phone, + string Email, + string Homepage, + string City); +} diff --git a/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs b/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs new file mode 100644 index 0000000..8f7be57 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs @@ -0,0 +1,201 @@ +using GerbilManagerWebAPI.Common; +using GerbilManagerWebAPI.Contracts; +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// FEAT-13: Abgabeverträge. + /// POST /contracts -> erzeugt .docx (phase-A-Generator), speichert sie im + /// Vertrags-Dateiroot, legt die Vertragszeile an und stellt + /// die Tiere in DERSELBEN Transaktion auf Abgegeben + /// (ReceiverContactId, GoHomeDate, Status). + /// GET /contracts -> Gridify-paged (z. B. filter=contactId==…) + /// GET /contracts/{id} -> Metadaten + /// GET /contracts/{id}/file -> .docx-Download (deutscher Dateiname) + /// DELETE /contracts/{id} -> entfernt Zeile + Datei (Tier-Status bleibt unberührt) + /// + public static class ContractEndpoints + { + private const string DocxContentType = + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + + public static IEndpointRouteBuilder MapContractEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/contracts").WithTags("Contracts"); + + // GET /contracts (Gridify; GerbilIds via Include mitgeladen) + group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) => + TypedResults.Ok(await db.SaleContracts.AsNoTracking() + .Include(c => c.Animals) + .ToPagedResultAsync(query, ToDto))); + + // GET /contracts/{id} + group.MapGet("/{id:guid}", async Task, NotFound>> (Guid id, ApplicationContext db) => + { + var c = await db.SaleContracts.AsNoTracking() + .Include(x => x.Animals) + .FirstOrDefaultAsync(x => x.Id == id); + return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c)); + }); + + // POST /contracts — der Abgabe-Abschluss. + group.MapPost("/", async Task, ValidationProblem>> ( + SaleContractInput input, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var errors = new Dictionary(); + + var gerbilIds = (input.GerbilIds ?? []).Distinct().ToList(); + if (gerbilIds.Count == 0) + errors["gerbilIds"] = ["Mindestens ein Tier auswählen."]; + if (input.Price < 0) + errors["price"] = ["Der Kaufpreis darf nicht negativ sein."]; + + var contact = await db.Contacts.AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == input.ContactId); + if (contact is null) + errors["contactId"] = ["Der Abnehmer wurde nicht gefunden."]; + + var gerbils = await db.Gerbils + .Include(g => g.ColorVariety) + .Where(g => gerbilIds.Contains(g.Id)) + .ToListAsync(); + if (gerbils.Count != gerbilIds.Count) + errors["gerbilIds"] = ["Mindestens ein ausgewähltes Tier wurde nicht gefunden."]; + + if (errors.Count > 0) return TypedResults.ValidationProblem(errors); + + var settings = await db.BreederSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId) + ?? new BreederSettings(); + + var contractDate = input.ContractDate ?? input.HandoverDate; + var data = new ContractData( + Seller: ToSeller(settings), + Buyer: new ContractBuyer(contact!.Name, contact.Address ?? "", contact.Phone, contact.Email), + Animals: gerbils + .Select(g => new ContractAnimal(g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name)) + .ToList(), + Price: input.Price, + HandoverDate: input.HandoverDate, + ContractDate: contractDate); + + var bytes = ContractGenerator.Generate(data); + + var fileName = $"{Guid.NewGuid():N}.docx"; + var root = ContractRoot(config, env); + Directory.CreateDirectory(root); + await File.WriteAllBytesAsync(Path.Combine(root, fileName), bytes); + + var entity = new SaleContract + { + Id = Guid.NewGuid(), + ContactId = contact.Id, + Price = input.Price, + HandoverDate = input.HandoverDate, + ContractDate = contractDate, + FileName = fileName, + CreatedAt = DateTimeOffset.UtcNow, + Animals = gerbilIds.Select(id => new SaleContractAnimal { GerbilId = id }).ToList(), + }; + db.SaleContracts.Add(entity); + + // Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion. + foreach (var g in gerbils) + { + g.ReceiverContactId = contact.Id; + g.GoHomeDate = input.HandoverDate; + g.Status = GerbilStatus.GivenAway; + } + + try + { + await db.SaveChangesAsync(); + } + catch + { + // DB fehlgeschlagen -> verwaiste Datei wieder aufräumen. + var orphan = Path.Combine(root, fileName); + if (File.Exists(orphan)) File.Delete(orphan); + throw; + } + + return TypedResults.Created($"/contracts/{entity.Id}", ToDto(entity)); + }); + + // GET /contracts/{id}/file — Download mit sprechendem deutschen Dateinamen. + group.MapGet("/{id:guid}/file", async Task> ( + Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var c = await db.SaleContracts.AsNoTracking() + .Include(x => x.Contact) + .FirstOrDefaultAsync(x => x.Id == id); + if (c is null) return TypedResults.NotFound(); + + var path = Path.Combine(ContractRoot(config, env), c.FileName); + if (!File.Exists(path)) return TypedResults.NotFound(); + + var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact?.Name)}.docx"; + return TypedResults.PhysicalFile(path, DocxContentType, 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> ( + Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) => + { + var c = await db.SaleContracts.FirstOrDefaultAsync(x => x.Id == id); + if (c is null) return TypedResults.NotFound(); + + var path = Path.Combine(ContractRoot(config, env), c.FileName); + if (File.Exists(path)) File.Delete(path); + + db.SaleContracts.Remove(c); // Joins kaskadieren + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + return app; + } + + private static string ContractRoot(IConfiguration config, IWebHostEnvironment env) => + config["Contracts:RootPath"] ?? Path.Combine(env.ContentRootPath, "contract-storage"); + + private static BreederProfile ToSeller(BreederSettings s) => new() + { + ZuchtName = s.ZuchtName, + Name = s.Name, + Address = s.Address, + Phone = s.Phone, + Email = s.Email, + Homepage = s.Homepage, + City = s.City, + }; + + /// Deutscher Anzeigetext fürs Geschlecht (Vertragsdokument). + internal static string GeschlechtText(Gender gender) => gender switch + { + Gender.male => "Männlich", + Gender.female => "Weiblich", + _ => "Unbekannt", + }; + + /// Kontaktname → Dateinamens-tauglich (Umlaute bleiben, Trenner -> '-'). + private static string Sanitize(string? name) + { + if (string.IsNullOrWhiteSpace(name)) return "Abnehmer"; + var cleaned = new string(name.Trim() + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') + .ToArray()); + return cleaned.Trim('-'); + } + + internal static SaleContractDto ToDto(SaleContract c) => new( + c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt, + c.Animals.Select(a => a.GerbilId).ToList(), + $"/contracts/{c.Id}/file"); + } +} diff --git a/GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs b/GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs new file mode 100644 index 0000000..cf032a4 --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/SettingsEndpoints.cs @@ -0,0 +1,60 @@ +using GerbilManagerWebAPI.Dtos; +using GerbilManagerWebAPI.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.EntityFrameworkCore; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// FEAT-13: Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeile, + /// per Migration leer geseedet, von der Züchterin unter /einstellungen gepflegt. + /// GET /settings/breeder-profile -> BreederProfileDto + /// PUT /settings/breeder-profile -> 204 + /// + public static class SettingsEndpoints + { + public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/settings").WithTags("Settings"); + + group.MapGet("/breeder-profile", async Task> (ApplicationContext db) => + { + var s = await LoadAsync(db, track: false); + return TypedResults.Ok(ToDto(s)); + }); + + group.MapPut("/breeder-profile", async Task (BreederProfileDto input, ApplicationContext db) => + { + var s = await LoadAsync(db, track: true); + s.ZuchtName = input.ZuchtName ?? ""; + s.Name = input.Name ?? ""; + s.Address = input.Address ?? ""; + s.Phone = input.Phone ?? ""; + s.Email = input.Email ?? ""; + s.Homepage = input.Homepage ?? ""; + s.City = input.City ?? ""; + await db.SaveChangesAsync(); + return TypedResults.NoContent(); + }); + + return app; + } + + /// Die Singleton-Zeile; defensiv neu anlegen, falls sie fehlt. + private static async Task LoadAsync(ApplicationContext db, bool track) + { + var query = track ? db.BreederSettings : db.BreederSettings.AsNoTracking(); + var s = await query.FirstOrDefaultAsync(x => x.Id == BreederSettings.SingletonId); + if (s is null) + { + s = new BreederSettings { Id = BreederSettings.SingletonId }; + db.BreederSettings.Add(s); + await db.SaveChangesAsync(); + } + return s; + } + + internal static BreederProfileDto ToDto(BreederSettings s) => + new(s.ZuchtName, s.Name, s.Address, s.Phone, s.Email, s.Homepage, s.City); + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs b/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs new file mode 100644 index 0000000..c7fd2d3 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.Designer.cs @@ -0,0 +1,1024 @@ +// +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("20260606051954_SaleContractsAndBreederSettings")] + partial class SaleContractsAndBreederSettings + { + /// + 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.BreederSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Homepage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ZuchtName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BreederSettings"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-000000000001"), + Address = "", + City = "", + Email = "", + Homepage = "", + Name = "", + Phone = "", + ZuchtName = "" + }); + }); + + 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("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("TotalBorn") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FatherId"); + + b.HasIndex("MotherId"); + + b.ToTable("Litters"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + + 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.SaleContract", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil") + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null) + .WithMany("Animals") + .HasForeignKey("SaleContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gerbil"); + }); + + 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"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Navigation("Animals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.cs b/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.cs new file mode 100644 index 0000000..2467b32 --- /dev/null +++ b/GerbilManagerWebAPI/Migrations/20260606051954_SaleContractsAndBreederSettings.cs @@ -0,0 +1,108 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GerbilManagerWebAPI.Migrations +{ + /// + public partial class SaleContractsAndBreederSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BreederSettings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ZuchtName = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Address = table.Column(type: "text", nullable: false), + Phone = table.Column(type: "text", nullable: false), + Email = table.Column(type: "text", nullable: false), + Homepage = table.Column(type: "text", nullable: false), + City = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BreederSettings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "SaleContracts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ContactId = table.Column(type: "uuid", nullable: false), + Price = table.Column(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false), + HandoverDate = table.Column(type: "date", nullable: false), + ContractDate = table.Column(type: "date", nullable: false), + FileName = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SaleContracts", x => x.Id); + table.ForeignKey( + name: "FK_SaleContracts_Contacts_ContactId", + column: x => x.ContactId, + principalTable: "Contacts", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "SaleContractAnimal", + columns: table => new + { + SaleContractId = table.Column(type: "uuid", nullable: false), + GerbilId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SaleContractAnimal", x => new { x.SaleContractId, x.GerbilId }); + table.ForeignKey( + name: "FK_SaleContractAnimal_Gerbils_GerbilId", + column: x => x.GerbilId, + principalTable: "Gerbils", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SaleContractAnimal_SaleContracts_SaleContractId", + column: x => x.SaleContractId, + principalTable: "SaleContracts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "BreederSettings", + columns: new[] { "Id", "Address", "City", "Email", "Homepage", "Name", "Phone", "ZuchtName" }, + values: new object[] { new Guid("11111111-1111-1111-1111-000000000001"), "", "", "", "", "", "", "" }); + + migrationBuilder.CreateIndex( + name: "IX_SaleContractAnimal_GerbilId", + table: "SaleContractAnimal", + column: "GerbilId"); + + migrationBuilder.CreateIndex( + name: "IX_SaleContracts_ContactId", + table: "SaleContracts", + column: "ContactId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BreederSettings"); + + migrationBuilder.DropTable( + name: "SaleContractAnimal"); + + migrationBuilder.DropTable( + name: "SaleContracts"); + } + } +} diff --git a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs index 2116917..4a23362 100644 --- a/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs +++ b/GerbilManagerWebAPI/Migrations/ApplicationContextModelSnapshot.cs @@ -21,6 +21,58 @@ namespace GerbilManagerWebAPI.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("City") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Homepage") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("ZuchtName") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("BreederSettings"); + + b.HasData( + new + { + Id = new Guid("11111111-1111-1111-1111-000000000001"), + Address = "", + City = "", + Email = "", + Homepage = "", + Name = "", + Phone = "", + ZuchtName = "" + }); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.ColorVariety", b => { b.Property("Id") @@ -771,6 +823,54 @@ namespace GerbilManagerWebAPI.Migrations b.ToTable("Litters"); }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContactId") + .HasColumnType("uuid"); + + b.Property("ContractDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("text"); + + b.Property("HandoverDate") + .HasColumnType("date"); + + b.Property("Price") + .HasPrecision(10, 2) + .HasColumnType("numeric(10,2)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("SaleContracts"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.Property("SaleContractId") + .HasColumnType("uuid"); + + b.Property("GerbilId") + .HasColumnType("uuid"); + + b.HasKey("SaleContractId", "GerbilId"); + + b.HasIndex("GerbilId"); + + b.ToTable("SaleContractAnimal"); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => { b.Property("Id") @@ -869,6 +969,34 @@ namespace GerbilManagerWebAPI.Migrations b.Navigation("Mother"); }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Contact", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContractAnimal", b => + { + b.HasOne("GerbilManagerWebAPI.Models.Gerbil", "Gerbil") + .WithMany() + .HasForeignKey("GerbilId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GerbilManagerWebAPI.Models.SaleContract", null) + .WithMany("Animals") + .HasForeignKey("SaleContractId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Gerbil"); + }); + modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b => { b.HasOne("GerbilManagerWebAPI.Models.Gerbil", null) @@ -882,6 +1010,11 @@ namespace GerbilManagerWebAPI.Migrations { b.Navigation("Gerbils"); }); + + modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b => + { + b.Navigation("Animals"); + }); #pragma warning restore 612, 618 } } diff --git a/GerbilManagerWebAPI/Models/BreederSettings.cs b/GerbilManagerWebAPI/Models/BreederSettings.cs new file mode 100644 index 0000000..ab56ac9 --- /dev/null +++ b/GerbilManagerWebAPI/Models/BreederSettings.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// Zuchtprofil (Verkäufer-Block der Abgabeverträge) — Einzelzeilen-Entity + /// (Singleton, fixe Id, leer geseedet). Die Züchterin pflegt ihre Daten in + /// der App unter /einstellungen statt in einer JSON-Datei; FEAT-13 phase B + /// ersetzt damit die appsettings-Variante aus phase A. + /// + public class BreederSettings + { + /// Fixe Id der einzigen Zeile (per HasData geseedet). + public static readonly Guid SingletonId = new("11111111-1111-1111-1111-000000000001"); + + [Key] + public Guid Id { get; set; } + + public string ZuchtName { get; set; } = ""; + + /// Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“. + public string Name { get; set; } = ""; + + /// Anschrift einzeilig: „Straße Nr, PLZ Ort“. + public string Address { get; set; } = ""; + + public string Phone { get; set; } = ""; + + public string Email { get; set; } = ""; + + public string Homepage { get; set; } = ""; + + /// Ort für die Unterschriftszeile („{Ort}, den {Datum}“). + public string City { get; set; } = ""; + } +} diff --git a/GerbilManagerWebAPI/Models/SaleContract.cs b/GerbilManagerWebAPI/Models/SaleContract.cs new file mode 100644 index 0000000..497179c --- /dev/null +++ b/GerbilManagerWebAPI/Models/SaleContract.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; + +namespace GerbilManagerWebAPI.Models +{ + /// + /// Ein erzeugter Abgabevertrag (FEAT-13). Die .docx liegt wie die Fotos im + /// Datei-Root (Contracts:RootPath bzw. contract-storage/), in der DB steht + /// nur der Dateiname. Tiere hängen über + /// (Join-Entity statt uuid[]-Spalte: referenzielle Integrität, „Verträge + /// eines Tieres“ bleibt abfragbar, und pro Tier-Zeile ist später Platz für + /// Zusatzdaten wie den Einzelpreis). + /// + public class SaleContract + { + [Key] + public Guid Id { get; set; } + + /// Abnehmer (Käufer-Block des Vertrags). + public Guid ContactId { get; set; } + public Contact? Contact { get; set; } + + /// Kaufpreis in Euro (gesamt). + public decimal Price { get; set; } + + public DateOnly HandoverDate { get; set; } + + /// Datum der Unterschriftszeile (Standard: Übergabedatum). + public DateOnly ContractDate { get; set; } + + /// Dateiname der erzeugten .docx im Vertrags-Dateiroot. + public required string FileName { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public List Animals { get; set; } = []; + } + + /// Join-Zeile Vertrag ↔ Tier (zusammengesetzter Schlüssel). + public class SaleContractAnimal + { + public Guid SaleContractId { get; set; } + public Guid GerbilId { get; set; } + public Gerbil? Gerbil { get; set; } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 6c28261..8c78b15 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -51,8 +51,13 @@ app.MapOpenApi(); app.MapScalarApiReference(); // Apply EF migrations at startup (no-op if schema is current; safe for single-instance deploy). -using (var scope = app.Services.CreateScope()) +// Unter "Testing" übersprungen: die Endpoint-Tests laufen auf SQLite in-memory +// (EnsureCreated) — die Npgsql-Migrationen sind dort nicht anwendbar. +if (!app.Environment.IsEnvironment("Testing")) +{ + using var scope = app.Services.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.Migrate(); +} app.UseCors(LanCorsPolicy); @@ -67,5 +72,10 @@ app.MapWeightRecordEndpoints(); app.MapInbreedingEndpoints(); app.MapPhotoEndpoints(); app.MapSaleAdEndpoints(); +app.MapContractEndpoints(); +app.MapSettingsEndpoints(); app.Run(); + +// Sichtbarer Programmtyp für WebApplicationFactory (Endpoint-Tests). +public partial class Program { }