281 lines
13 KiB
C#
281 lines
13 KiB
C#
using GerbilManagerWebAPI.Common;
|
|
using GerbilManagerWebAPI.Contracts;
|
|
using GerbilManagerWebAPI.Dtos;
|
|
using GerbilManagerWebAPI.Models;
|
|
using GerbilManagerWebAPI.Services;
|
|
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";
|
|
private const string PdfContentType = "application/pdf";
|
|
|
|
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();
|
|
var photoChoice = input.AnimalPhotos ?? [];
|
|
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,
|
|
PhotoId = photoChoice.TryGetValue(id, out var pid) ? pid : null,
|
|
}).ToList(),
|
|
};
|
|
db.SaleContracts.Add(entity);
|
|
|
|
// Abgabe-Abschluss-Semantik: in derselben SaveChanges-Transaktion.
|
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
|
foreach (var g in gerbils)
|
|
{
|
|
g.ReceiverContactId = contact.Id;
|
|
g.GoHomeDate = input.HandoverDate;
|
|
GerbilStatusService.Apply(g, today);
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
// GET /contracts/{id}/pdf — druckfertiges PDF, aus den (aktuellen) Daten regeneriert.
|
|
group.MapGet("/{id:guid}/pdf", async Task<Results<FileContentHttpResult, NotFound>> (
|
|
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
|
|
{
|
|
var c = await db.SaleContracts.AsNoTracking()
|
|
.Include(x => x.Contact)
|
|
.Include(x => x.Animals)
|
|
.FirstOrDefaultAsync(x => x.Id == id);
|
|
if (c is null || c.Contact is null) return TypedResults.NotFound();
|
|
|
|
var gerbilIds = c.Animals.Select(a => a.GerbilId).ToList();
|
|
var gerbils = await db.Gerbils.AsNoTracking()
|
|
.Include(g => g.ColorVariety)
|
|
.Where(g => gerbilIds.Contains(g.Id))
|
|
.ToListAsync();
|
|
var settings = await db.BreederSettings.AsNoTracking()
|
|
.FirstOrDefaultAsync(s => s.Id == BreederSettings.SingletonId)
|
|
?? new BreederSettings();
|
|
|
|
// Gewählte Vertragsfotos nachladen (Foto muss zum Tier gehören und die Datei
|
|
// existieren — sonst bleibt das Bild leer statt zu scheitern).
|
|
var photoByGerbil = c.Animals
|
|
.Where(a => a.PhotoId is not null)
|
|
.ToDictionary(a => a.GerbilId, a => a.PhotoId!.Value);
|
|
var photoBytes = await LoadAnimalPhotos(db, photoByGerbil, PhotoRoot(config, env));
|
|
|
|
var data = new ContractData(
|
|
Seller: ToSeller(settings),
|
|
Buyer: new ContractBuyer(c.Contact.Name, c.Contact.Address ?? "", c.Contact.Phone, c.Contact.Email),
|
|
Animals: gerbils
|
|
.Select(g => new ContractAnimal(
|
|
g.Name, GeschlechtText(g.Gender), g.DateOfBirth, g.ColorVariety?.Name,
|
|
photoBytes.GetValueOrDefault(g.Id)))
|
|
.ToList(),
|
|
Price: c.Price,
|
|
HandoverDate: c.HandoverDate,
|
|
ContractDate: c.ContractDate);
|
|
|
|
var pdf = ContractPdfGenerator.Generate(data);
|
|
var download = $"Abgabevertrag_{c.ContractDate:yyyy-MM-dd}_{Sanitize(c.Contact.Name)}.pdf";
|
|
return TypedResults.File(pdf, PdfContentType, 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");
|
|
|
|
/// <summary>Foto-Dateiroot (geteilt mit den Foto-Endpoints).</summary>
|
|
private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) =>
|
|
config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage");
|
|
|
|
/// <summary>Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes).
|
|
/// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt.</summary>
|
|
private static async Task<Dictionary<Guid, byte[]>> LoadAnimalPhotos(
|
|
ApplicationContext db, Dictionary<Guid, Guid> photoByGerbil, string photoRoot)
|
|
{
|
|
var result = new Dictionary<Guid, byte[]>();
|
|
if (photoByGerbil.Count == 0) return result;
|
|
|
|
var photoIds = photoByGerbil.Values.ToList();
|
|
var photos = await db.GerbilPhotos.AsNoTracking()
|
|
.Where(p => photoIds.Contains(p.Id))
|
|
.ToListAsync();
|
|
|
|
foreach (var (gerbilId, photoId) in photoByGerbil)
|
|
{
|
|
var photo = photos.FirstOrDefault(p => p.Id == photoId && p.GerbilId == gerbilId);
|
|
if (photo is null) continue;
|
|
var path = Path.Combine(photoRoot, photo.FileName);
|
|
if (File.Exists(path))
|
|
result[gerbilId] = await File.ReadAllBytesAsync(path);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|