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"; 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, 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(); 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> ( 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> ( 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> ( 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"); /// Foto-Dateiroot (geteilt mit den Foto-Endpoints). private static string PhotoRoot(IConfiguration config, IWebHostEnvironment env) => config["Photos:RootPath"] ?? Path.Combine(env.ContentRootPath, "photo-storage"); /// Lädt die gewählten Vertragsfotos als Rohbytes (GerbilId -> Bytes). /// Überspringt Fotos, die nicht zum Tier gehören oder deren Datei fehlt. private static async Task> LoadAnimalPhotos( ApplicationContext db, Dictionary photoByGerbil, string photoRoot) { var result = new Dictionary(); 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, }; /// 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"); } }