Merge feature/feat-13: Abgabevertrag generator phase A (template + pure OpenXml generator + 10 tests) [god-QA: 18/18]
This commit is contained in:
29
GerbilManagerWebAPI/Contracts/BreederProfile.cs
Normal file
29
GerbilManagerWebAPI/Contracts/BreederProfile.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Zuchtprofil — der parameterisierte Verkäufer-Block des Abgabevertrags.
|
||||
/// Phase A: wird aus der appsettings-Sektion <c>"BreederProfile"</c> gebunden
|
||||
/// (z. B. <c>builder.Configuration.GetSection("BreederProfile").Get<BreederProfile>()</c>);
|
||||
/// eine Settings-UI/Entity folgt in Phase B. Die Vorlage selbst enthält KEINE
|
||||
/// echten Daten (nur {{Platzhalter}}) — Werte kommen ausschließlich von hier.
|
||||
/// </summary>
|
||||
public sealed class BreederProfile
|
||||
{
|
||||
/// <summary>Name der Zucht, z. B. „Zucht der Kleinen Chaoten“.</summary>
|
||||
public string ZuchtName { get; set; } = "";
|
||||
|
||||
/// <summary>Vor- und Nachname inkl. Anrede, z. B. „Frau Erika Muster“.</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>Anschrift einzeilig: „Straße Nr, PLZ Ort“.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
public string Phone { get; set; } = "";
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
public string Homepage { get; set; } = "";
|
||||
|
||||
/// <summary>Ort für die Unterschriftszeile („{Ort}, den {Datum}“).</summary>
|
||||
public string City { get; set; } = "";
|
||||
}
|
||||
34
GerbilManagerWebAPI/Contracts/ContractData.cs
Normal file
34
GerbilManagerWebAPI/Contracts/ContractData.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace GerbilManagerWebAPI.Contracts;
|
||||
|
||||
/// <summary>Käufer/Abnehmer-Block des Abgabevertrags.</summary>
|
||||
/// <param name="Name">Vor- und Nachname inkl. Anrede, z. B. „Herr Max Muster“.</param>
|
||||
/// <param name="Address">Anschrift einzeilig: „Straße Nr, PLZ Ort“.</param>
|
||||
public sealed record ContractBuyer(
|
||||
string Name,
|
||||
string Address,
|
||||
string? Phone = null,
|
||||
string? Email = null);
|
||||
|
||||
/// <summary>Ein abgegebenes Tier (eine Tabelle im Vertrag pro Tier).</summary>
|
||||
/// <param name="Geschlecht">Deutscher Anzeigetext („Weiblich“/„Männlich“) — die
|
||||
/// Abbildung vom <c>Gender</c>-Enum passiert im Aufrufer (Phase B), der
|
||||
/// Generator bleibt frei von Modell-Abhängigkeiten.</param>
|
||||
public sealed record ContractAnimal(
|
||||
string Name,
|
||||
string Geschlecht,
|
||||
DateOnly? Geburtsdatum = null,
|
||||
string? Farbschlag = null);
|
||||
|
||||
/// <summary>
|
||||
/// Alle Eingaben des Vertragsgenerators. Reines Daten-Objekt, keine EF-Typen.
|
||||
/// </summary>
|
||||
/// <param name="Price">Kaufpreis in Euro; gerendert als de-DE, z. B. „72,00 €“.</param>
|
||||
/// <param name="HandoverDate">Übergabedatum (Abschnitt 3), Format TT.MM.JJJJ.</param>
|
||||
/// <param name="ContractDate">Datum der Unterschriftszeile; Standard = Übergabedatum.</param>
|
||||
public sealed record ContractData(
|
||||
BreederProfile Seller,
|
||||
ContractBuyer Buyer,
|
||||
IReadOnlyList<ContractAnimal> Animals,
|
||||
decimal Price,
|
||||
DateOnly HandoverDate,
|
||||
DateOnly? ContractDate = null);
|
||||
152
GerbilManagerWebAPI/Contracts/ContractGenerator.cs
Normal file
152
GerbilManagerWebAPI/Contracts/ContractGenerator.cs
Normal 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 4–8 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();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.4.2" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
|
||||
@@ -24,4 +25,9 @@
|
||||
<ProjectReference Include="..\GerbilManager.ServiceDefaults\GerbilManager.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- FEAT-13: Abgabevertrag-Vorlage wird als eingebettete Ressource ausgeliefert. -->
|
||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user