Перейти до основного вмісту

Document print forms

A document's print form is a separate Dto built from a saved document and rendered on demand — for example the printable goods-receipt sheet behind a Print button. It uses the same vocabulary as every other print form, so read Print forms first for the attributes ([PrintForm], [PrintSection], [PrintTable], [Totals], …); this page covers only what is specific to documents.

The worked example is Kandra's own GoodsReceipt: GoodsReceiptPrintForm in Forms/Documents/GoodsReceipt.cs plus GoodsReceiptBehavior.OnPrintAsync. Transfer and Waybill are the same pattern.

Use the shipped skill

Prefer the create-print-form skill over hand-rolling this. Creating a document shows a trimmed version of the same wiring in the context of a whole document; this page is the full treatment.

What you write

Three things, all on the server-and-shared planes. There is nothing to write on the client: no Refit method, no controller action and no Print button are hand-written (see What is generated).

PieceProjectWhere
The print-form DtoAcme.FormsSame file as the document's Dto
[SupportedPrintForm] + [SupportedExportFormats]Acme.FormsOn the document's Dto
OnPrintAsyncAcme.ApplicationThe document's Behavior

1. Declare the form on the document Dto

The document Dto tells the engine which print form belongs to it and which formats it offers:

[KandraDocumentForm(Name = "GoodsReceipts", ValidatorType = typeof(GoodsReceiptValidator), QueryDtoType = typeof(GoodsReceiptQueryDto))]
[SupportedExportFormats(ExportFormat.Pdf)]
[SupportedPrintForm(typeof(GoodsReceiptPrintForm))]
public class GoodsReceiptDto : DocumentDto { /* ... */ }

[SupportedPrintForm] is what makes the generated Print button appear, and [SupportedExportFormats] is the declared set of formats. Documents are declared PDF-only by default; the engine itself does not restrict them to it, so widening the declaration to ExportFormat.Pdf | ExportFormat.Csv is enough to expose CSV for that document. (The generated Print button always requests the default — PDF, default layout. A format picker is not built.)

2. Write the print-form Dto

Same file, right after the document Dto. Extend DocumentPrintHeaderData if you have adopted the shared header base (see Shared header fields) — it brings generated-at, generated-by, and the Submitted/Draft flag:

[PrintForm]
[PrintTitle("GoodsReceipt")]
[Page(Size = PageSize.A4, Orientation = PageOrientation.Portrait, MarginsMm = new[] { 15, 10, 15, 10 })]
[PrintStyle("Number", Align = TextAlign.Right)]
[PrintSection("Header", Columns = 2)]
public sealed class GoodsReceiptPrintForm : DocumentPrintHeaderData
{
[PrintSectionRef("Header")] [Caption("Code")]
public string Code { get; set; } = string.Empty;

[PrintSectionRef("Header")] [Caption("Date")] [Format("d")]
public DateTime Date { get; set; }

[PrintSectionRef("Header")] [Caption("Counterparty")]
public string CounterpartyName { get; set; } = string.Empty;

[Required] [PrintTable]
public required ICollection<GoodsReceiptPrintLine> Lines { get; set; }
}

Note the print form holds display names (CounterpartyName), not ids: it is a rendering model, not a mirror of the entity. That is why the Behavior below has a lookup step.

3. Override OnPrintAsync in the Behavior

IDocumentBehavior<TEntity> has a default OnPrintAsync that returns null, meaning "this document has no print form". Override it to build the form and render it:

public async ValueTask<PrintRenderOutput?> OnPrintAsync(GoodsReceipt entity, ExportFormat format,
string? layoutTag, ILookupRepository lookup, IPrintRendererResolver renderer,
IStringLocalizer localizer, TimeZoneInfo? timeZone, CancellationToken cancellationToken)
{
var counterparties = await lookup.GetDictionaryAsync<CounterpartyNode>([entity.CounterpartyId], cancellationToken);
var warehouses = await lookup.GetDictionaryAsync<Warehouse>([entity.ToWarehouseId], cancellationToken);
var itemIds = entity.Lines.Select(l => l.ItemId).Distinct().ToList();
var itemNames = (await lookup.GetDictionaryAsync<ItemNode>(itemIds, cancellationToken))
.ToDictionary(i => i.Id, i => i.Name);

var printForm = new GoodsReceiptPrintForm
{
GeneratedAt = DateTime.UtcNow,
GeneratedByUserName = await PrintHeaderUser.TryGetNameAsync(lookup, authorizationChecker, cancellationToken),
IsSubmitted = entity.IsActive,
Code = entity.Code,
Date = entity.Date,
CounterpartyName = counterparties.Count > 0 ? counterparties[0].Name : string.Empty,
Lines = entity.Lines.Select(line => new GoodsReceiptPrintLine
{
ItemName = itemNames.TryGetValue(line.ItemId, out var name) ? name : string.Empty,
Quantity = line.Quantity,
Price = line.Price,
Sum = line.Sum,
}).ToList(),
};

return renderer.Render(printForm, localizer, timeZone, format, layoutTag ?? "default");
}

Everything except the last line is ordinary data shaping. The parameters format, layoutTag, lookup, renderer, localizer and timeZone are all handed to you by the service layer; only authorizationChecker (used for the "generated by" name) is a normal constructor dependency of the Behavior — add it if the Behavior doesn't already have it.

Resolve display names through ILookupRepository

Two rules explain the lookup block above, and both are easy to get wrong:

  • Use the lookup service, not navigation properties. The CRUD service loads a document with only its [KandraTablePart] row navigations. entity.Counterparty, entity.ToWarehouse and line.Item are not loaded when OnPrintAsync runs, so read through lookup.GetDictionaryAsync<T>(ids, ct) instead.
  • A hierarchical dictionary is looked up by its root type. Counterparty and Item are leaves of a hierarchy, and the repository behind GetDictionaryAsync is only DI-registered for the hierarchy's root — so it's GetDictionaryAsync<CounterpartyNode> and GetDictionaryAsync<ItemNode>. Asking for the leaf compiles fine and then fails at request time with "No service for type …". If in doubt, check which type the generated persistence DI file registers.

GetDictionaryAsync has a second overload taking a Func<IQueryable<T>, IQueryable<T>> for filters beyond "these ids" (active-only, a name prefix).

Set the header fields

GeneratedAt should be DateTime.UtcNow — the renderer converts it to the reader's local time zone using the timeZone parameter. IsSubmitted is entity.IsActive: for a document, "active" is the submitted flag; there is no separate concept.

What is generated

The Print button and everything behind it come from the document Dto's attributes:

  • The endpointPOST print/{id} on the document's controller, with a DocumentExportRequest body carrying an optional Format and LayoutTag. A missing format defaults to PDF; the service loads the document by id, resolves the caller's time zone once, and calls your hook.
  • The client plumbing — the Refit/service Print(id) call and the button itself, on the document's Update and View pages. It is deliberately absent from Add: an unsaved document has no id to print. It opens the result in a new browser tab rather than downloading it.
  • The gating — the button is generated only when the Dto carries [SupportedPrintForm]. A document without one gets onPrint: null and no button at all. This is deliberate: a Print button wired to a document whose OnPrintAsync still returns the default null would fail silently with a 404 and no visible error.

Verify

  1. dotnet build, then open the generated GoodsReceiptPrintFormPdfRenderer under Acme.Application/Generated.Net/ and confirm every header field and column you expect is emitted.
  2. Open a saved document's Update or View page and click Print: a PDF opens in a new tab (not a download). Confirm the button is absent on the Add page.
  3. Save, don't submit, and print again: the header should say Draft; after submitting it should say Submitted.
  4. Post to the endpoint directly with {"format":"Csv"} and confirm CSV content comes back only if you widened [SupportedExportFormats] to include it; with {"format":"Excel"} (or a layoutTag that has no registered renderer) you should get a clean 404 (PrintRendererNotFoundException), not a raw 500.
  5. Watch the server log while doing all of the above — a wrong lookup type throws at request time.

Gotchas

  • The print form is not the entity. Don't map the entity onto it with AutoMapper hoping navigations will fill in; build it by hand from lookups, as above.
  • OnPrintAsync runs on any saved document, submitted or not. Print a Draft/Submitted marker if the distinction matters to the reader; don't assume submitted.
  • A missing [SupportedPrintForm] means no button, not an error. If your Print button doesn't appear, that attribute is the first thing to check.
  • Authorization is the document's View permission. Printing needs no extra permission of its own, and a soft-deleted document returns 404.
  • The lines are already loaded. The print endpoint loads the document with its child rows (includeChildren: true), so entity.Lines is populated — but nothing else is, which is why the lookups above exist.
  • DateTime columns: see the note in Print forms.

See also