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:
201
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal file
201
GerbilManagerWebAPI/Endpoints/ContractEndpoints.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user