PDF-Rechnungen erzeugen
Dieses Tutorial fügt eine druckbare PDF-Rechnung für jeden Deal in dem Mini-CRM hinzu, das du in Baue deine erste App gebaut hast. Am Ende wirst du eine Rechnungsvorlage haben, die Company, Positionen und Summen eines Deals zieht, live in der Vorschau-Pane des Editors gerendert und über REST aus externen Systemen aufrufbar.
Gesamtdauer: ~25 Minuten. Du wirst PDF Templates berühren und das Schema um eine Positions-Tabelle erweitern.
Trial-Einschränkung: PDF-Rendering ist innerhalb der Trial-Umgebung deaktiviert, um den geteilten Headless-Chromium-Pool vor Überlastung zu schützen. Sowohl die Live-Vorschau-Pane des Editors (POST /api/v1/pdf-templates/{id}/preview) als auch der öffentlichePOST /api/v1/pdf-templates/generate/<name>-Endpunkt geben 403 zurück, bis du in einer bezahlten Umgebung bist. Du kannst das Template trotzdem bearbeiten (Data Script, HTML, Settings, Parameter) und speichern, die Vorschau-Pane wird den 403 zeigen, bis das Rendering wieder aktiviert ist. Plane, die gerenderte Ausgabe in einer Nicht-Trial-Umgebung zu verifizieren, bevor du dich darauf verlässt.
Voraussetzungen
Du solltest das Mini-CRM aus Baue deine erste App
deployt haben: company-, contact-, deal-Tabellen; ihre
Business Entities; die drei Pages. Wenn du dieses Tutorial übersprungen hast, wird der Rest
dieser Seite wenig Sinn ergeben.
Schritt 1 - Positionen zum Schema hinzufügen (5 Min)
Eine echte Rechnung hat Positionen, nicht nur einen einzigen Betrag. Füge eine
deal_line-Tabelle hinzu.
- Öffne Schema Designer → Add Table → benenne sie
deal_line. -
Im Columns-Tab des rechten Panels füge hinzu:
id· SERIAL, PK (auto)deal_id· INTEGER, erforderlichdescription· VARCHAR(300), erforderlichquantity· DECIMAL, erforderlich, Default1unit_price· DECIMAL, erforderlich, Default0line_total· DECIMAL, erforderlich, Default0- abgeleitet, wird unten synchron gehalten
-
Wechsle zum Relations-Tab. Füge eine Beziehung hinzu: FK-Spalte
deal_id, Relation type One-to-Many, References tabledeal, References columnid. Klicke auf Add Relationship. - Klicke auf Deploy, prüfe das Generated SQL, klicke oben rechts auf Deploy.
BE, Page und ein winziges Event verdrahten
- Business Entities → Create. Entity Name
deal_line, Master Tabledeal_line, Label Columndescription. Speichern. -
Öffne die bestehende Deals-Page im Page Editor. Im Detailformular klicke auf
Add Tab, benenne den Tab Lines und füge eine RelatedGrid-Sektion
hinzu, gebunden an
deal_linemit dem Join-Filter deal.id = current record's id. Speichern und neu veröffentlichen. - Business Events → Create. Rule Name
Calc deal_line.line_total. Enabled AN. Business Entitydeal_line. Triggers: Before Create + Before Update. Keine Conditions. Action: Execute Script mit Body:
Speichern.Entity.line_total = (decimal)(Entity.quantity ?? 0) * (decimal)(Entity.unit_price ?? 0);
Klicke in einen Deal, wechsle zum neuen Lines-Tab und füge zwei oder drei Positionen hinzu.
Die line_total-Spalte sollte sich jedes Mal automatisch füllen, wenn du eine
Position speicherst.
Schritt 2 - Das PDF-Data-Script schreiben (5 Min)
Öffne PDF Templates → klicke auf + Create. Der Editor öffnet
sich mit einem Name-Feld oben (tippe DealInvoice), einem Description-Feld und
fünf Tabs: HTML Template, Data Script,
Settings, Params, JSON. Die
PDF Preview-Pane ist immer rechts sichtbar und rendert neu, wenn du speicherst.
Wechsle zum Params-Tab → klicke auf + Add. Name
deal_id, Type int, Required angekreuzt. (Das Tab-Label aktualisiert
sich zu Params (1), sobald du einen definiert hast.)
Wechsle zum Data Script-Tab, der Monaco C#-Editor öffnet sich auf der linken Seite, mit der PDF-Vorschau weiterhin rechts. Füge ein:
var deal = await Db.GetAsync("deal", deal_id);
if (deal == null) throw new Exception($"Deal {deal_id} not found");
var company = await Db.GetAsync("company", deal.company_id);
var lines = await Db.From("deal_line")
.Where("deal_id", "=", deal_id)
.OrderBy("id")
.ToListAsync();
decimal subtotal = 0;
foreach (var l in lines) subtotal += (decimal)(l.line_total ?? 0);
var vatRate = 0.21m; // Belgian standard VAT, adjust per customer
var vat = Math.Round(subtotal * vatRate, 2);
var total = subtotal + vat;
return new {
InvoiceNumber = $"INV-{deal.id:D6}",
InvoiceDate = DateTime.UtcNow.ToString("yyyy-MM-dd"),
Deal = new { Title = (string)deal.title, Stage = (string)deal.stage },
Company = new { Name = (string)company.name, Industry = (string?)company.industry },
Lines = lines.Select(l => new {
Description = (string)l.description,
Quantity = l.quantity,
UnitPrice = l.unit_price,
LineTotal = l.line_total
}),
Subtotal = subtotal,
VatRate = (int)(vatRate * 100),
Vat = vat,
Total = total
}; Das Skript gibt ein anonymes Objekt zurück. Was immer du zurückgibst, wird zum Datenkontext für das HTML-Template, jede Property ist per Name in Scriban-Platzhaltern zugreifbar.
Warum all die Casts?Db.GetAsyncund.ToListAsync()gebendynamiczurück, was Scriban manchmal falsch handhabt, wenn ein Wert null ist oder sein Laufzeittyp nicht dem entspricht, was das Template erwartet. Casts auf einen konkreten Typ an der Grenze (wo du das Rückgabeobjekt baust) gibt dir vorhersehbares Verhalten.
Schritt 3 - Das HTML-Template schreiben (10 Min)
Füge im HTML Template-Editor das Folgende ein (das Styling macht den Großteil der Bytes aus):
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{{ InvoiceNumber }}</title>
<style>
@page { size: A4; margin: 24mm 18mm; }
body { font: 11pt/1.5 'Inter', sans-serif; color: #1e2838; }
h1 { font-size: 24pt; margin: 0 0 4pt; letter-spacing: -0.02em; }
.muted { color: #566277; font-size: 10pt; }
.row { display: flex; justify-content: space-between; margin-bottom: 24pt; }
.right { text-align: right; }
table { width: 100%; border-collapse: collapse; margin: 16pt 0; }
th { text-align: left; padding: 6pt 4pt; border-bottom: 2px solid #1e2838; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.08em; }
td { padding: 8pt 4pt; border-bottom: 1px solid #e4e7ed; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
.totals { margin-left: auto; width: 60mm; }
.totals td { padding: 4pt 4pt; border: none; }
.totals .grand { border-top: 2px solid #1e2838; font-weight: 700; font-size: 13pt; }
footer { margin-top: 32pt; font-size: 9pt; color: #8c97aa; text-align: center; }
</style>
</head>
<body>
<div class="row">
<div>
<h1>Invoice</h1>
<div class="muted">{{ InvoiceNumber }} · {{ InvoiceDate }}</div>
</div>
<div class="right">
<strong>Bill to</strong><br>
{{ Company.Name }}<br>
<span class="muted">{{ Company.Industry }}</span>
</div>
</div>
<div><strong>{{ Deal.Title }}</strong> <span class="muted">· {{ Deal.Stage }}</span></div>
<table>
<thead>
<tr>
<th>Description</th>
<th class="num">Qty</th>
<th class="num">Unit price</th>
<th class="num">Line total</th>
</tr>
</thead>
<tbody>
{{ for line in Lines }}
<tr>
<td>{{ line.Description }}</td>
<td class="num">{{ line.Quantity }}</td>
<td class="num">€ {{ line.UnitPrice }}</td>
<td class="num">€ {{ line.LineTotal }}</td>
</tr>
{{ end }}
</tbody>
</table>
<table class="totals">
<tr>
<td>Subtotal</td>
<td class="num">€ {{ Subtotal }}</td>
</tr>
<tr>
<td>VAT ({{ VatRate }}%)</td>
<td class="num">€ {{ Vat }}</td>
</tr>
<tr class="grand">
<td>Total</td>
<td class="num">€ {{ Total }}</td>
</tr>
</table>
<footer>Thank you for your business.</footer>
</body>
</html> Das Ergebnis sehen
Klicke oben rechts auf den grünen Update-Button (oder Create bei einem neuen Template), um zu speichern. Die PDF Preview-Pane rechts rendert neu. Eine Thumbnail-Spalte am linken Rand der Vorschau zeigt Seite 1, Seite 2, etc.; die Hauptpane zeigt das volle Rendering mit einer eingebauten PDF-Viewer-Symbolleiste (Zoom, Drehen, Download, Druck, mehr). Iteriere, indem du den HTML- oder Data Script-Tab bearbeitest und erneut speicherst.
Die Icon-Buttons oben rechts im Editor (neben Cancel) lassen dich die Sichtbarkeit der Vorschau-Pane umschalten und in den Vollbild-Bearbeitungsmodus wechseln. Das Hilfe-Symbol (?) öffnet einen inline Spickzettel für Scriban-Syntax.
Dinge, die oft einer Anpassung bedürfen:
- Zahlenformatierung. Dezimale kommen als rohe Zahlen zurück; wenn du feste 2-Dezimal-Anzeige willst, formatiere im Data-Script (
l.line_total.ToString("0.00")) und gib Strings zurück.- Währungssymbol. Oben hartcodiert als €, lagere zu einem Parameter oder einer Branding-Einstellung aus, wenn du mehrere Währungen bedienst.
- Seitenumbrüche. Für lange Positionslisten füge
page-break-inside: avoidzutrim CSS hinzu, damit eine Zeile nicht über Seiten gespalten wird.- Schriftarten. Der Renderer hat die Familien Liberation, DejaVu und Noto. Wenn du Inter referenzierst (wie oben), fällt es auf eine System-Sans zurück. Um eine Markenschrift einzubetten, base64-kodiere eine
.woff2und inline sie über@font-face.
Schritt 4 - Das PDF von außerhalb der App generieren (5 Min)
Das Template ist jetzt per Name von jedem HTTP-Client aufrufbar. Drei Endpunkte, alle unter
/api/v1/pdf-templates:
-
POST /api/v1/pdf-templates/{id}/preview- rendert das Template nach seiner Datenbank-ID und gibtapplication/pdfzurück. Wird von der Vorschau-Pane des Editors genutzt. -
POST /api/v1/pdf-templates/generate/{name}- rendert nach Name und gibtapplication/pdfzurück (Browser-Download). -
POST /api/v1/pdf-templates/generate/{name}/base64- gleich, gibt aber{ "data": "<base64>" }zurück. Nützlich, wenn der Aufrufer das PDF in eine andere Antwort-Payload einbetten will.
Aus einer Shell, mit einem Bearer-Token aus der Admin-App:
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"deal_id": 1}' \
https://demo1.archestack.eu/api/v1/pdf-templates/generate/DealInvoice \
--output invoice.pdf Aus einem externen System (einer Zapier-Integration, dem Auftragsbestätigungs-Flow eines Partners) rufst du denselben Endpunkt mit den Parametern im JSON-Body auf. Die Plattform führt das Data-Script mit diesen Parametern aus, rendert das HTML und streamt das PDF zurück.
Trial-Modus: Sowohl dergenerate-Endpunkt als auch der{id}/preview-Endpunkt geben in der Trial-Umgebung 403 zurück, sodass weder der externecurl-Aufruf noch die Vorschau-Pane des Editors ein PDF zurückgeben werden, bis du in einer bezahlten Umgebung bist. Die Template-Metadaten (Data Script, HTML, Settings) speichern weiterhin normal, das Rendering ist das, was gesperrt ist.
Recap
- Du hast das Schema um eine Kindtabelle (
deal_line) erweitert und einen Before-Trigger genutzt, um ein abgeleitetes Feld synchron zu halten. - Du hast ein PDF-Template gebaut, ein Data-Script, das die richtigen Zeilen zieht, ein HTML-Body, der sie mit Scriban-Platzhaltern rendert.
- Du hast die REST-Endpunkte gelernt, um das Template von überall aufzurufen, inklusive des Preview-Endpunkts, der den Editor selbst antreibt.
Wie es weitergeht
- PDF Templates Reference - deckt die volle Scriban-Oberfläche ab, Schriftarten-Handling, Seitenumbruch-Tricks und den Settings-Tab des Editors (Ränder, Papierformat, Orientierung).
- Scheduled Events - rufe den PDF-Generate-Endpunkt aus einem Script Module auf und maile das Ergebnis monatlich an den Kunden.