FEAT-13: ContractGenerator (pure OpenXml, embedded template, per-animal table cloning) + BreederProfile/ContractData records + 10 xUnit tests incl. schema validation & PII guard

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 07:09:07 +02:00
parent e4b7558abd
commit cbe77c927a
5 changed files with 445 additions and 0 deletions

View File

@@ -0,0 +1,152 @@
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();
}
}