Merge feature/feat-13: Abgabevertrag phase B — SaleContract+wizard, /einstellungen Zuchtprofil, contract endpoints w/ transactional Abgabe, Kontakt structured fields [god-QA: 40/40+47/47+e2e 56/56]

# Conflicts:
#	GerbilManager.Tests/GerbilManager.Tests.csproj
#	GerbilManagerWebAPI/Program.cs
This commit is contained in:
2026-06-06 08:00:55 +02:00
31 changed files with 3018 additions and 21 deletions

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);
}
}