Tutorial - 25 min

Generuj faktury PDF

Ten tutorial dodaje fakturę PDF do druku dla każdego Deala w mini-CRM, który zbudowałeś w Zbuduj swoją pierwszą aplikację. Pod koniec będziesz mieć szablon faktury, który pobiera firmę deala, pozycje i sumy, renderowany na żywo w panelu podglądu edytora i wywoływalny z zewnętrznych systemów przez REST.

Całkowity czas: ~25 minut. Dotkniesz PDF Templates i rozszerzysz schemę o tabelę pozycji.

Ograniczenie w trial: renderowanie PDF jest wyłączone w środowisku trial, aby chronić współdzieloną pulę headless-Chromium przed przeciążeniem. Zarówno panel podglądu na żywo edytora (POST /api/v1/pdf-templates/{id}/preview), jak i publiczny endpoint POST /api/v1/pdf-templates/generate/<name> zwracają 403, dopóki nie będziesz na płatnym środowisku. Nadal możesz autorować szablon (data script, HTML, settings, parameters) i zapisać go, panel podglądu będzie pokazywać 403, dopóki renderowanie nie zostanie ponownie włączone. Zaplanuj weryfikację wyrenderowanego wyjścia na nietrialowym środowisku, zanim na nim polegasz.

Wymagania wstępne

Powinieneś mieć wdrożony mini-CRM z Zbuduj swoją pierwszą aplikację: tabele company, contact, deal; ich Business Entities; trzy strony. Jeśli pominąłeś tamten tutorial, reszta tej strony nie będzie miała większego sensu.

Krok 1 - Dodaj pozycje do schemy (5 min)

Prawdziwa faktura ma pozycje, a nie tylko jedną kwotę. Dodaj tabelę deal_line.

  1. Otwórz Schema Designer -> Add Table -> nazwij ją deal_line.
  2. W karcie Columns prawego panelu dodaj:
    • id - SERIAL, PK (auto)
    • deal_id - INTEGER, required
    • description - VARCHAR(300), required
    • quantity - DECIMAL, required, default 1
    • unit_price - DECIMAL, required, default 0
    • line_total - DECIMAL, required, default 0 - wyliczana, utrzymywana w synchronizacji poniżej
  3. Przełącz się na kartę Relations. Dodaj relację: FK column deal_id, Relation type One-to-Many, References table deal, References column id. Kliknij Add Relationship.
  4. Kliknij Deploy, przejrzyj Generated SQL, kliknij Deploy w prawym górnym rogu.

Podłącz BE, stronę i mały event

  1. Business Entities -> Create. Entity Name deal_line, Master Table deal_line, Label Column description. Zapisz.
  2. Otwórz istniejącą stronę Deals w Page Editor. W formularzu szczegółów kliknij Add Tab, nazwij kartę Lines i dodaj sekcję RelatedGrid powiązaną z deal_line z filtrem join deal.id = id aktualnego rekordu. Zapisz i opublikuj ponownie.
  3. Business Events -> Create. Rule Name Calc deal_line.line_total. Enabled ON. Business Entity deal_line. Triggers: Before Create + Before Update. Bez warunków. Akcja: Execute Script z ciałem:
    Entity.line_total = (decimal)(Entity.quantity ?? 0) * (decimal)(Entity.unit_price ?? 0);
    Zapisz.

Kliknij w Deala, przełącz się na nową kartę Lines i dodaj dwie lub trzy pozycje. Kolumna line_total powinna wypełniać się automatycznie za każdym razem, gdy zapiszesz pozycję.

Krok 2 - Napisz skrypt danych PDF (5 min)

Otwórz PDF Templates -> kliknij + Create. Edytor otwiera się z polem Name u góry (wpisz DealInvoice), polem Description i pięcioma kartami: HTML Template, Data Script, Settings, Params, JSON. Panel PDF Preview jest zawsze widoczny po prawej i ponownie renderuje się przy zapisie.

Przełącz się na kartę Params -> kliknij + Add. Name deal_id, Type int, Required zaznaczone. (Etykieta karty aktualizuje się do Params (1), gdy już jeden zdefiniujesz.)

Przełącz się na kartę Data Script, edytor C# Monaco otwiera się po lewej, z podglądem PDF nadal po prawej. Wklej:

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
};

Skrypt zwraca anonimowy obiekt. Cokolwiek zwrócisz, staje się kontekstem danych dla szablonu HTML, każda właściwość jest dostępna po nazwie w placeholderach Scriban.

Dlaczego wszystkie te casty? Db.GetAsync i .ToListAsync() zwracają dynamic, co Scriban czasem może źle obsłużyć, gdy wartość jest null lub jej typ runtime nie jest tym, czego szablon się spodziewa. Castowanie do konkretnego typu na granicy (gdzie budujesz zwracany obiekt) daje przewidywalne zachowanie.

Krok 3 - Napisz szablon HTML (10 min)

W edytorze HTML Template wklej to (styling stanowi większość bajtów):

<!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>

Zobacz wynik

Kliknij zielony przycisk Update (lub Create na nowym szablonie) w prawym górnym rogu, aby zapisać. Panel PDF Preview po prawej renderuje się ponownie. Kolumna miniatur na lewej krawędzi podglądu pokazuje stronę 1, stronę 2 itd.; główny panel pokazuje pełny render z wbudowanym paskiem narzędzi viewera PDF (zoom, obrót, download, print, więcej). Iteruj, edytując kartę HTML lub Data Script i zapisując ponownie.

Ikony-przyciski w prawym górnym rogu edytora (obok Cancel) pozwalają przełączać widoczność panelu podglądu i przejść do trybu edycji pełnoekranowej. Ikona pomocy (?) otwiera inline cheat-sheet dla składni Scriban.

Edytor PDF Templates: pola Name i Description u góry, pięć kart (HTML Template, Data Script, Settings, Params, JSON), edytor Monaco po lewej, zawsze włączony panel PDF Preview po prawej z kolumną miniatur i wyrenderowaną fakturą.
Edytor PDF Templates pokazujący prawdziwy szablon faktury. Góra: pola Name + Description, plus ikony przełączania układu / pomoc / Cancel / zielony Update po prawej. Karty: HTML Template (aktywna tutaj) / Data Script / Settings / Params (z badgem licznika) / JSON. Prawy panel: zawsze włączony PDF Preview z kolumną miniatur (strona 1 wybrana, strona 2 poniżej), wbudowany pasek narzędzi viewera (zoom 47%, obrót, download, print itd.) i wyrenderowane wyjście.
Rzeczy, które często wymagają poprawki:
  • Formatowanie liczb. Decymale wracają jako surowe liczby; jeśli chcesz wyświetlać z 2 miejscami dziesiętnymi, formatuj w skrypcie danych (l.line_total.ToString("0.00")) i zwracaj stringi.
  • Symbol waluty. Zakodowany jako € powyżej, wyciągnij do parametru lub ustawienia Branding, jeśli obsługujesz wiele walut.
  • Łamania stron. Dla długich list pozycji dodaj page-break-inside: avoid do tr w CSS, aby wiersz nie rozdzielił się między stronami.
  • Czcionki. Renderer ma rodziny Liberation, DejaVu i Noto. Jeśli odwołujesz się do Inter (jak powyżej), spada do systemowej sans. Aby osadzić czcionkę brandową, zakoduj .woff2 w base64 i wstaw inline przez @font-face.

Krok 4 - Generuj PDF z zewnątrz aplikacji (5 min)

Szablon jest teraz wywoływalny po nazwie z dowolnego klienta HTTP. Trzy endpointy, wszystkie pod /api/v1/pdf-templates:

  • POST /api/v1/pdf-templates/{id}/preview - renderuje szablon po jego ID w bazie i zwraca application/pdf. Używany przez panel podglądu edytora.
  • POST /api/v1/pdf-templates/generate/{name} - renderuje po nazwie i zwraca application/pdf (pobieranie w przeglądarce).
  • POST /api/v1/pdf-templates/generate/{name}/base64 - to samo, ale zwraca { "data": "<base64>" }. Przydatne, gdy wywołujący chce osadzić PDF w innym payloadzie odpowiedzi.

Z powłoki, z Bearer tokenem z aplikacji administracyjnej:

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

Z zewnętrznego systemu (integracja Zapier, przepływ potwierdzenia zamówienia partnera) wywołaj ten sam endpoint z parametrami w ciele JSON. Platforma uruchamia skrypt danych z tymi parametrami, renderuje HTML i streamuje PDF z powrotem.

Tryb trial: zarówno endpoint generate, jak i endpoint {id}/preview zwracają 403 w środowisku trial, więc ani zewnętrzne wywołanie curl, ani panel podglądu edytora nie zwrócą PDF, dopóki nie będziesz na płatnym środowisku. Metadane szablonu (data script, HTML, settings) nadal zapisują się normalnie, renderowanie jest tym, co jest zablokowane.

Podsumowanie

  • Rozszerzyłeś schemę o tabelę dziecka (deal_line) i użyłeś triggera Before, aby utrzymać wyliczane pole w synchronizacji.
  • Zbudowałeś szablon PDF, skrypt danych pobierający właściwe wiersze, ciało HTML, które renderuje je przez placeholdery Scriban.
  • Nauczyłeś się endpointów REST do wywoływania szablonu z dowolnego miejsca, włącznie z endpointem podglądu, który zasila sam edytor.

Dokąd dalej

  • Referencja PDF Templates - pokrywa pełną powierzchnię Scriban, obsługę czcionek, sztuczki z łamaniem stron i kartę Settings edytora (marginesy, format papieru, orientacja).
  • Scheduled Events - wywołaj endpoint generate PDF ze Script Module i wyślij wynik klientowi mailem na miesięcznym harmonogramie.