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 { /// /// 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. 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> ( 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"); } }