Files
GerbilManager/GerbilManagerWebAPI/Contracts/ContractGenerator.cs

153 lines
6.1 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
namespace GerbilManagerWebAPI.Contracts;
/// <summary>
/// FEAT-13: Erzeugt Abgabeverträge (.docx) aus der eingebetteten Vorlage
/// (<c>Contracts/Templates/Abgabevertrag.docx</c>, abgeleitet aus dem echten
/// Mustervertrag; Boilerplate-Abschnitte 48 stehen verbatim in der Vorlage).
///
/// Reiner Dienst: keine EF-/HTTP-Abhängigkeiten — Eingabe ist ein
/// <see cref="ContractData"/>, 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).
/// </summary>
public static class ContractGenerator
{
private const string TemplateResource = "GerbilManagerWebAPI.Contracts.Templates.Abgabevertrag.docx";
private static readonly CultureInfo German = CultureInfo.GetCultureInfo("de-DE");
/// <summary>Erzeugt den Vertrag mit der eingebetteten Standard-Vorlage.</summary>
public static byte[] Generate(ContractData data) => Generate(data, LoadEmbeddedTemplate());
/// <summary>Erzeugt den Vertrag mit einer expliziten Vorlage (Tests/Sonderfälle).</summary>
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();
}
/// <summary>
/// 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.
/// </summary>
private static void FillAnimalTables(Body body, IReadOnlyList<ContractAnimal> animals)
{
var templateTable = body.Descendants<Table>()
.Single(t => t.InnerText.Contains("{{TierName}}"));
var tables = new List<Table> { 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<string, string> 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<string, string> AnimalTokens(ContractAnimal animal) => new()
{
["{{TierName}}"] = animal.Name,
["{{TierGeschlecht}}"] = animal.Geschlecht,
["{{TierGeburtsdatum}}"] = animal.Geburtsdatum is { } born ? FormatDate(born) : "",
["{{TierFarbschlag}}"] = animal.Farbschlag ?? "",
};
/// <summary>z. B. 72m → „72,00 €“, 1234.5m → „1.234,50 €“.</summary>
private static string FormatPrice(decimal price) => price.ToString("N2", German) + " €";
/// <summary>TT.MM.JJJJ (deutsche Schreibweise, wie im Mustervertrag).</summary>
private static string FormatDate(DateOnly date) => date.ToString("dd.MM.yyyy", German);
/// <summary>
/// Ersetzt Tokens in allen Text-Runs unterhalb von <paramref name="root"/>.
/// Tokens liegen in der Vorlage garantiert in einzelnen Runs — kein
/// Run-übergreifendes Matching nötig.
/// </summary>
private static void ReplaceTokens(OpenXmlElement root, IReadOnlyDictionary<string, string> tokens)
{
foreach (var text in root.Descendants<Text>())
{
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();
}
}