FEAT-13: SaleContract + BreederSettings entities (additive migration, join table justified in code docs), /contracts + /settings/breeder-profile Minimal-API endpoints w/ Abgabe-completion transaction + SQLite-backed endpoint round-trip tests (21/21)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 07:23:06 +02:00
parent ce1e578bf2
commit 0c05a5acf1
13 changed files with 1939 additions and 1 deletions

View File

@@ -15,6 +15,8 @@ public class ApplicationContext : DbContext
public DbSet<GerbilPhoto> GerbilPhotos => Set<GerbilPhoto>();
public DbSet<HealthRecord> HealthRecords => Set<HealthRecord>();
public DbSet<WeightRecord> WeightRecords => Set<WeightRecord>();
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -59,6 +61,31 @@ public class ApplicationContext : DbContext
e.HasOne<Gerbil>().WithMany()
.HasForeignKey(p => p.GerbilId).OnDelete(DeleteBehavior.Cascade));
// FEAT-13: Abgabeverträge + Zuchtprofil.
modelBuilder.Entity<SaleContract>(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<SaleContractAnimal>(e =>
{
e.HasKey(a => new { a.SaleContractId, a.GerbilId });
e.HasOne<SaleContract>().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<BreederSettings>()
.HasData(new BreederSettings { Id = GerbilManagerWebAPI.Models.BreederSettings.SingletonId });
SeedColorVarieties(modelBuilder);
}

View File

@@ -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<Guid> GerbilIds,
// Download-URL der .docx ("/contracts/{id}/file").
string Url);
public record SaleContractInput(
Guid ContactId,
List<Guid> GerbilIds,
decimal Price,
DateOnly HandoverDate,
DateOnly? ContractDate);
/// <summary>Zuchtprofil — Antwort UND Request-Body von /settings/breeder-profile.</summary>
public record BreederProfileDto(
string ZuchtName,
string Name,
string Address,
string Phone,
string Email,
string Homepage,
string City);
}

View File

@@ -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
{
/// <summary>
/// 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)
/// </summary>
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<Results<Ok<SaleContractDto>, 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<Results<Created<SaleContractDto>, ValidationProblem>> (
SaleContractInput input, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var errors = new Dictionary<string, string[]>();
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<Results<PhysicalFileHttpResult, NotFound>> (
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<Results<NoContent, NotFound>> (
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,
};
/// <summary>Deutscher Anzeigetext fürs Geschlecht (Vertragsdokument).</summary>
internal static string GeschlechtText(Gender gender) => gender switch
{
Gender.male => "Männlich",
Gender.female => "Weiblich",
_ => "Unbekannt",
};
/// <summary>Kontaktname → Dateinamens-tauglich (Umlaute bleiben, Trenner -> '-').</summary>
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");
}
}

View File

@@ -0,0 +1,60 @@
using GerbilManagerWebAPI.Dtos;
using GerbilManagerWebAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// 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
/// </summary>
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/settings").WithTags("Settings");
group.MapGet("/breeder-profile", async Task<Ok<BreederProfileDto>> (ApplicationContext db) =>
{
var s = await LoadAsync(db, track: false);
return TypedResults.Ok(ToDto(s));
});
group.MapPut("/breeder-profile", async Task<NoContent> (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;
}
/// <summary>Die Singleton-Zeile; defensiv neu anlegen, falls sie fehlt.</summary>
private static async Task<BreederSettings> 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class SaleContractsAndBreederSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BreederSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ZuchtName = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Address = table.Column<string>(type: "text", nullable: false),
Phone = table.Column<string>(type: "text", nullable: false),
Email = table.Column<string>(type: "text", nullable: false),
Homepage = table.Column<string>(type: "text", nullable: false),
City = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BreederSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SaleContracts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ContactId = table.Column<Guid>(type: "uuid", nullable: false),
Price = table.Column<decimal>(type: "numeric(10,2)", precision: 10, scale: 2, nullable: false),
HandoverDate = table.Column<DateOnly>(type: "date", nullable: false),
ContractDate = table.Column<DateOnly>(type: "date", nullable: false),
FileName = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
GerbilId = table.Column<Guid>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BreederSettings");
migrationBuilder.DropTable(
name: "SaleContractAnimal");
migrationBuilder.DropTable(
name: "SaleContracts");
}
}
}

View File

@@ -21,6 +21,58 @@ namespace GerbilManagerWebAPI.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("GerbilManagerWebAPI.Models.BreederSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.IsRequired()
.HasColumnType("text");
b.Property<string>("City")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Homepage")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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<Guid>("Id")
@@ -771,6 +823,54 @@ namespace GerbilManagerWebAPI.Migrations
b.ToTable("Litters");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.SaleContract", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ContactId")
.HasColumnType("uuid");
b.Property<DateOnly>("ContractDate")
.HasColumnType("date");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("text");
b.Property<DateOnly>("HandoverDate")
.HasColumnType("date");
b.Property<decimal>("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<Guid>("SaleContractId")
.HasColumnType("uuid");
b.Property<Guid>("GerbilId")
.HasColumnType("uuid");
b.HasKey("SaleContractId", "GerbilId");
b.HasIndex("GerbilId");
b.ToTable("SaleContractAnimal");
});
modelBuilder.Entity("GerbilManagerWebAPI.Models.WeightRecord", b =>
{
b.Property<Guid>("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
}
}

View File

@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// 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.
/// </summary>
public class BreederSettings
{
/// <summary>Fixe Id der einzigen Zeile (per HasData geseedet).</summary>
public static readonly Guid SingletonId = new("11111111-1111-1111-1111-000000000001");
[Key]
public Guid Id { get; set; }
public string ZuchtName { get; set; } = "";
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
public string Name { get; set; } = "";
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
public string Address { get; set; } = "";
public string Phone { get; set; } = "";
public string Email { get; set; } = "";
public string Homepage { get; set; } = "";
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
public string City { get; set; } = "";
}
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
namespace GerbilManagerWebAPI.Models
{
/// <summary>
/// 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 <see cref="SaleContractAnimal"/>
/// (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).
/// </summary>
public class SaleContract
{
[Key]
public Guid Id { get; set; }
/// <summary>Abnehmer (Käufer-Block des Vertrags).</summary>
public Guid ContactId { get; set; }
public Contact? Contact { get; set; }
/// <summary>Kaufpreis in Euro (gesamt).</summary>
public decimal Price { get; set; }
public DateOnly HandoverDate { get; set; }
/// <summary>Datum der Unterschriftszeile (Standard: Übergabedatum).</summary>
public DateOnly ContractDate { get; set; }
/// <summary>Dateiname der erzeugten .docx im Vertrags-Dateiroot.</summary>
public required string FileName { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public List<SaleContractAnimal> Animals { get; set; } = [];
}
/// <summary>Join-Zeile Vertrag ↔ Tier (zusammengesetzter Schlüssel).</summary>
public class SaleContractAnimal
{
public Guid SaleContractId { get; set; }
public Guid GerbilId { get; set; }
public Gerbil? Gerbil { get; set; }
}
}

View File

@@ -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<ApplicationContext>().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<Program> (Endpoint-Tests).
public partial class Program { }