using System.Globalization; using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; namespace GerbilManagerWebAPI.Contracts; /// /// FEAT-13: Erzeugt Abgabeverträge (.docx) aus der eingebetteten Vorlage /// (Contracts/Templates/Abgabevertrag.docx, abgeleitet aus dem echten /// Mustervertrag; Boilerplate-Abschnitte 4–8 stehen verbatim in der Vorlage). /// /// Reiner Dienst: keine EF-/HTTP-Abhängigkeiten — Eingabe ist ein /// , Ausgabe sind die fertigen .docx-Bytes. /// Die Vorlage enthält pro Wert genau EINEN {{Token}}-Run (beim Vorlagenbau /// zusammengeführt), darum genügt einfache Textersetzung; die Tier-Tabelle /// wird pro Tier geklont (variable Anzahl 1..n). /// public static class ContractGenerator { private const string TemplateResource = "GerbilManagerWebAPI.Contracts.Templates.Abgabevertrag.docx"; private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE"); /// Erzeugt den Vertrag mit der eingebetteten Standard-Vorlage. public static byte[] Generate(ContractData data) => Generate(data, LoadEmbeddedTemplate()); /// Erzeugt den Vertrag mit einer expliziten Vorlage (Tests/Sonderfälle). public static byte[] Generate(ContractData data, byte[] template) { ArgumentNullException.ThrowIfNull(data); if (data.Animals.Count == 0) { throw new ArgumentException("Ein Abgabevertrag braucht mindestens ein Tier.", nameof(data)); } using var stream = new MemoryStream(); stream.Write(template); using (var document = WordprocessingDocument.Open(stream, isEditable: true)) { var body = document.MainDocumentPart?.Document.Body ?? throw new InvalidOperationException("Vorlage ohne Dokumentrumpf."); FillAnimalTables(body, data.Animals); ReplaceTokens(body, GlobalTokens(data)); document.MainDocumentPart!.Document.Save(); } return stream.ToArray(); } /// /// Die Vorlage enthält genau eine Tier-Tabelle (mit {{TierName}}). Für /// jedes weitere Tier wird sie samt Abstands-Absatz geklont; danach wird /// jede Tabelle mit den Werten „ihres“ Tieres gefüllt. /// private static void FillAnimalTables(Body body, IReadOnlyList animals) { var templateTable = body.Descendants() .Single(t => t.InnerText.Contains("{{TierName}}")); var tables = new List
{ templateTable }; // Abstands-Absatz hinter der Tabelle (Optik wie im Original-Mehrtier-Vertrag). OpenXmlElement anchor = templateTable.NextSibling() is Paragraph spacer ? spacer : templateTable; for (var i = 1; i < animals.Count; i++) { var clone = (Table)templateTable.CloneNode(deep: true); anchor = anchor.InsertAfterSelf(clone); anchor = anchor.InsertAfterSelf(new Paragraph()); tables.Add(clone); } for (var i = 0; i < animals.Count; i++) { ReplaceTokens(tables[i], AnimalTokens(animals[i])); } } private static Dictionary GlobalTokens(ContractData data) => new() { ["{{ZuchtName}}"] = data.Seller.ZuchtName, ["{{VerkaeuferName}}"] = data.Seller.Name, ["{{VerkaeuferAdresse}}"] = data.Seller.Address, ["{{VerkaeuferTelefon}}"] = data.Seller.Phone, ["{{VerkaeuferEmail}}"] = data.Seller.Email, ["{{VerkaeuferHomepage}}"] = data.Seller.Homepage, ["{{KaeuferName}}"] = data.Buyer.Name, ["{{KaeuferAdresse}}"] = data.Buyer.Address, ["{{KaeuferTelefon}}"] = data.Buyer.Phone ?? "", ["{{KaeuferEmail}}"] = data.Buyer.Email ?? "", ["{{Kaufpreis}}"] = FormatPrice(data.Price), ["{{Uebergabedatum}}"] = FormatDate(data.HandoverDate), ["{{VertragsOrt}}"] = data.Seller.City, ["{{VertragsDatum}}"] = FormatDate(data.ContractDate ?? data.HandoverDate), }; private static Dictionary AnimalTokens(ContractAnimal animal) => new() { ["{{TierName}}"] = animal.Name, ["{{TierGeschlecht}}"] = animal.Geschlecht, ["{{TierGeburtsdatum}}"] = animal.Geburtsdatum is { } born ? FormatDate(born) : "", ["{{TierFarbschlag}}"] = animal.Farbschlag ?? "", }; /// z. B. 72m → „72,00 €“, 1234.5m → „1.234,50 €“. private static string FormatPrice(decimal price) => price.ToString("N2", German) + " €"; /// TT.MM.JJJJ (deutsche Schreibweise, wie im Mustervertrag). private static string FormatDate(DateOnly date) => date.ToString("dd.MM.yyyy", German); /// /// Ersetzt Tokens in allen Text-Runs unterhalb von . /// Tokens liegen in der Vorlage garantiert in einzelnen Runs — kein /// Run-übergreifendes Matching nötig. /// private static void ReplaceTokens(OpenXmlElement root, IReadOnlyDictionary tokens) { foreach (var text in root.Descendants()) { if (!text.Text.Contains("{{")) { continue; } foreach (var (token, value) in tokens) { if (text.Text.Contains(token)) { text.Text = text.Text.Replace(token, value); } } if (text.Text.Length > 0 && (char.IsWhiteSpace(text.Text[0]) || char.IsWhiteSpace(text.Text[^1]))) { text.Space = SpaceProcessingModeValues.Preserve; } } } private static byte[] LoadEmbeddedTemplate() { using var resource = typeof(ContractGenerator).Assembly.GetManifestResourceStream(TemplateResource) ?? throw new InvalidOperationException($"Eingebettete Vorlage fehlt: {TemplateResource}"); using var buffer = new MemoryStream(); resource.CopyTo(buffer); return buffer.ToArray(); } }