Add 5 AL/BC patterns: document distribution (Report Selections, Document Sending Profile, Find Entries, TransferFields)

Five rules about Business Central's document distribution architecture,
verified against BCApps source and Microsoft Learn.

- custom-document-dispatch-must-not-bypass-report-selections
- document-print-and-email-actions-call-report-selections-directly
- extend-find-entries-navigate-for-new-document-types
- extend-report-selection-usage-for-new-document-types
- transferfields-mirrored-fields-must-match-type-and-length

Wired into al-data-modeling-review.md's worklist cues. Added a
disambiguation note on the TransferFields article distinguishing it from
the existing transferfields-skip-type-mismatch-can-drop-data.md
(type-mismatch skipping vs. length mismatch, which SkipFieldsNotMatchingType
does not affect).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Dieringer 2026-09-10 07:44:17 +02:00
parent 8584217c75
commit 83f041b662
16 changed files with 761 additions and 0 deletions

View file

@ -0,0 +1,28 @@
report 50102 "Sample Settlement Doc Bad"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
dataset
{
dataitem(Customer; Customer)
{
column(No_Customer; "No.") { }
}
}
}
codeunit 50102 "Sample Settlement Document Send"
{
procedure SendSettlementDocument(var Customer: Record Customer)
begin
Customer.TestField("E-Mail");
// WRONG: hardcoded report, no Report Selections row backing it.
// Works for the default case, but there is nowhere for an admin to
// change the report or layout for one specific customer - this
// document never shows up on "Document Layouts" at all, and the
// only way to change it is a code change and a new release.
Report.RunModal(Report::"Sample Settlement Doc Bad", false, false, Customer);
end;
}

View file

@ -0,0 +1,22 @@
codeunit 50102 "Sample Settlement Document Send"
{
procedure SendSettlementDocument(var Customer: Record Customer)
var
ReportSelections: Record "Report Selections";
begin
// Custom validation specific to this document stays here...
CheckReadyToSend(Customer);
// ...but dispatch goes through the registered usage, so per-account
// report/layout overrides and email attachment/body configuration
// on Report Selections all apply automatically.
ReportSelections.SendEmailToCust(
"Report Selection Usage"::"S.Invoice".AsInteger(), Customer, Customer."No.",
Customer.Name, true, Customer."No.");
end;
local procedure CheckReadyToSend(var Customer: Record Customer)
begin
Customer.TestField("E-Mail");
end;
}

View file

@ -0,0 +1,62 @@
---
bc-version: [all]
domain: data-modeling
keywords: [report-selections, document-layouts, custom-report-layout, email-attachment, bespoke-dispatch]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Custom document dispatch must not bypass Report Selections
## Description
A codeunit that hardcodes which report to run (`Report.RunModal(MyReportId, ...)`)
and builds its own email directly, instead of registering the document
through `table 77 "Report Selections"` and calling its own
Print/Email procedures, works for the one case it was written for — and
loses everything the platform's registry provides for free. `Report
Selections` carries its own attachment/email-body configuration per usage
(`"Use for Email Attachment"`, `"Use for Email Body"`, `"Email Body Layout
Code"`, `"Email Body Layout Type"`, `"Custom Report Layout Code"`), and
`table 9657 "Custom Report Selection"` (the "Document Layouts" page on the
Customer/Vendor card) lets one specific account override the report or
layout without touching code at all. None of that exists for a document
whose dispatch was hand-rolled: there is no registry row to point
"Document Layouts" at, so an admin who goes looking for where to change
this document's layout — the same place they'd look for every other
document in the system — finds nothing, because the document was never
registered there.
## Best Practice
Register the document under a `Report Selection Usage` value (see
`extend-report-selection-usage-for-new-document-types.md`) and dispatch
through `Report Selections`' own Print/Email procedures (see
`document-print-and-email-actions-call-report-selections-directly.md`),
even when the surrounding business logic — which counterparty to use,
what validation must pass before sending — is genuinely specific to the
document. Custom logic belongs around the call to `Report Selections`,
not instead of it.
See sample: `custom-document-dispatch-must-not-bypass-report-selections.good.al`.
## Anti Pattern
A codeunit that runs a hardcoded report ID and builds its own email
message directly, with no `Report Selections` row backing it. It works for
the default case, but the report/layout cannot be changed per account
without a code change and a new release, and the document is invisible to
"Document Layouts" — the standard place every other document's
distribution is configured.
See sample: `custom-document-dispatch-must-not-bypass-report-selections.bad.al`.
## Source
BCApps `ReportSelections.Table.al` (table 77 — fields 19–26 for email
attachment/body configuration; `SendEmailToCust`/`PrintWithDialogForCust`
as the registry-backed dispatch entry points) and
`CustomReportSelection.Table.al` (table 9657, the per-account override
backing the "Document Layouts" page) — both under
`src/Layers/W1/BaseApp/Foundation/Reporting/`.

View file

@ -0,0 +1,35 @@
page 50101 "Sample Settlement Document Card"
{
PageType = Card;
SourceTable = Customer;
ApplicationArea = All;
actions
{
area(Processing)
{
action(EmailDocument)
{
ApplicationArea = All;
Caption = 'Email';
Image = Email;
trigger OnAction()
var
DocumentSendingProfile: Record "Document Sending Profile";
begin
// WRONG: this is a plain, on-demand "Email" button, not
// part of a combined Post-and-Send action - but routing
// it through Document Sending Profile means the outcome
// now silently depends on this customer's assigned
// profile. If that profile's "E-Mail" option is No, the
// user sees nothing happen after clicking Email, with no
// indication that an unrelated setup field is why.
DocumentSendingProfile.Send(
"Report Selection Usage"::"S.Invoice".AsInteger(), Rec, Rec."No.", Rec."No.",
Rec.Name, Rec.FieldNo("No."), Rec.FieldNo("No."));
end;
}
}
}
}

View file

@ -0,0 +1,45 @@
page 50101 "Sample Settlement Document Card"
{
PageType = Card;
SourceTable = Customer;
ApplicationArea = All;
actions
{
area(Processing)
{
action(EmailDocument)
{
ApplicationArea = All;
Caption = 'Email';
Image = Email;
trigger OnAction()
var
ReportSelections: Record "Report Selections";
begin
// Calls Report Selections directly - the button's outcome
// depends only on this customer's registered report/layout,
// not on any Document Sending Profile setting.
ReportSelections.SendEmailToCust(
"Report Selection Usage"::"S.Invoice".AsInteger(), Rec, Rec."No.",
Rec.Name, true, Rec."No.");
end;
}
action(PrintDocument)
{
ApplicationArea = All;
Caption = 'Print';
Image = Print;
trigger OnAction()
var
ReportSelections: Record "Report Selections";
begin
ReportSelections.PrintWithDialogForCust(
"Report Selection Usage"::"S.Invoice", Rec, true, Rec.FieldNo("No."));
end;
}
}
}
}

View file

@ -0,0 +1,99 @@
---
bc-version: [all]
domain: data-modeling
keywords: [report-selections, document-sending-profile, print, email, post-and-send]
technologies: [al]
countries: [w1]
application-area: [all]
---
# A document's own Print/Email actions call Report Selections directly; Document Sending Profile is scoped to Post-and-Send
## Description
`table 60 "Document Sending Profile"` is not a general gateway that every
print/email path should route through — it exists specifically for the
combined **Post and Send** action: "You can set each customer up with a
preferred method of sending sales documents, so that you do not have to
select a sending option every time you choose the Post and Send action"
(Microsoft Learn, "Set Up Document Sending Profiles"). A document's own, ordinary
Print/Email ribbon actions call `table 77 "Report Selections"` directly
and are not affected by any Document Sending Profile at all. This is the
pattern BC's own base application uses for a document's plain print/email
actions: the Sales Order's "Print Confirmation"/"Email Confirmation"
actions (`codeunit "Document-Print"`, `PrintSalesOrder`/`EmailSalesHeader`)
call `ReportSelections.PrintWithDialogForCust`/`SendEmailToCust` directly,
and the posted `Purch. Inv. Header`'s own `PrintRecords` does the same
through `ReportSelection.PrintWithDialogForVend` — no customer's or
vendor's actually assigned Document Sending Profile is consulted by
either.
The unposted `Purchase Header`'s own `PrintRecords` is a partial exception
worth naming precisely: it calls `DocumentSendingProfile.TrySendToPrinterVendor(...)`,
but only as a stateless, never-`Get`'d local record carrying print-dialog
options, never a vendor's actually configured profile — that helper still
resolves the report through `ReportSelections.PrintWithDialogForVend(...)`,
the same as everywhere else.
Only the combined Post-and-Send flow resolves through Document Sending
Profile: `Sales-Post and Send` calls `Sales Invoice Header.SendProfile`,
which calls `DocumentSendingProfile.Send(...)`, which then decides
Print/Email/Disk/Electronic based on the customer's assigned profile and
only *then* calls back into `Report Selections` (for the PDF cases) or
`Electronic Document Format` (for machine-readable cases).
Whether a document needs outbound distribution at all isn't determined by
Customer-vs-Vendor, but by whether the document is genuinely *outbound* to
its counterparty. A posted Purchase Invoice records what a vendor already
billed you — nothing to send back — and its posted `Purch. Inv. Header`
exposes only a bare `PrintRecords`, no `SendProfile`/`SendRecords`/email at
all. A Purchase *Order* is genuinely outbound before posting, which is why
the full `SendProfile`/`SendRecords`/`PrintRecords` triplet lives on the
unposted `Purchase Header` instead.
## Best Practice
For a document's own interactive Print/Email actions, call the relevant
`Report Selections` procedure directly —
`PrintForCust`/`PrintWithDialogForCust`/`SendEmailToCust` for a
customer-facing document, `PrintWithDialogForVend`/`SendEmailToVendor` for
a vendor-facing one — using the usage value registered per
`extend-report-selection-usage-for-new-document-types.md`. Wire into
`Document Sending Profile` only when specifically building a combined
Post-and-Send action for that document. Before adding any send capability
at all, confirm the document is genuinely outbound to the counterparty
it's attached to; a document that only records something already received
needs print-for-reference at most, not a send path.
See sample: `document-print-and-email-actions-call-report-selections-directly.good.al`.
## Anti Pattern
Routing a document's plain, on-demand "Email" button through
`DocumentSendingProfile.Send`/`SendVendor` instead of calling
`ReportSelections.SendEmailToCust`/`SendEmailToVendor` directly. The
button's outcome now silently depends on that customer's or vendor's
assigned Document Sending Profile — if its `"E-Mail"` option happens to be
`No`, clicking "Email" does nothing observable, with no indication to the
user that a profile setting (meant for the Post-and-Send flow) is the
reason. A second version of the same mistake: adding an email action to a
document that only receives from its counterparty and was never meant to
send anything back.
See sample: `document-print-and-email-actions-call-report-selections-directly.bad.al`.
## Source
BCApps `DocumentPrint.Codeunit.al` (`EmailSalesHeader`/`DoPrintSalesHeader`/`PrintSalesOrder`,
calling `ReportSelections.SendEmailToCust`/`PrintForCust`/`PrintWithDialogForCust`
directly), `PurchaseHeader.Table.al` (`SendProfile` at line ~6387, calling
`DocumentSendingProfile.SendVendor`), `PurchInvHeader.Table.al` (`PrintRecords`
calling `ReportSelection.PrintWithDialogForVend` directly, no send capability),
`SalesPost.Codeunit.al`
(`SendPostedDocumentRecord` at line 7660 → `SalesInvHeader.SendProfile` at
lines 7680/7699 → `DocumentSendingProfile.Send`),
`DocumentSendingProfile.Table.al` (table 60; `TrySendToPrinterVendor` at
line 552 and `SendToPrinterVendor` at line 716, called from
`PurchaseHeader.PrintRecords` at line 6357) — all under
`src/Layers/W1/BaseApp/`. Microsoft Learn, "Set Up Document Sending Profiles":
https://learn.microsoft.com/dynamics365/business-central/sales-how-setup-document-send-profiles

View file

@ -0,0 +1,19 @@
codeunit 50103 "Sample Navigate Subscribers"
{
// WRONG: registers the row, so it appears in the Find Entries result
// list with a correct table name and record count - but there is no
// OnBeforeShowRecords subscriber for this table. ShowRecords()'s own
// case statement has no branch and no else for it either, so
// selecting this row and choosing "Show records" does nothing,
// silently, with no error.
[EventSubscriber(ObjectType::Page, Page::Navigate, 'OnAfterFindRecords', '', false, false)]
local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)
var
SampleDocHeader: Record "Sample Posted Document Header";
begin
SampleDocHeader.SetFilter("No.", DocNoFilter);
SampleDocHeader.SetFilter("Posting Date", PostingDateFilter);
DocumentEntry.InsertIntoDocEntry(
Database::"Sample Posted Document Header", SampleDocHeader.TableCaption(), SampleDocHeader.Count());
end;
}

View file

@ -0,0 +1,35 @@
codeunit 50103 "Sample Navigate Subscribers"
{
[EventSubscriber(ObjectType::Page, Page::Navigate, 'OnAfterFindRecords', '', false, false)]
local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)
var
SampleDocHeader: Record "Sample Posted Document Header";
begin
SampleDocHeader.SetFilter("No.", DocNoFilter);
SampleDocHeader.SetFilter("Posting Date", PostingDateFilter);
DocumentEntry.InsertIntoDocEntry(
Database::"Sample Posted Document Header", SampleDocHeader.TableCaption(), SampleDocHeader.Count());
end;
// Without this second subscriber, the row added above shows up in the
// Find Entries result list with a correct count, but "Show records"
// has nothing to open it with - see the .bad.al sample.
[EventSubscriber(ObjectType::Page, Page::Navigate, 'OnBeforeShowRecords', '', false, false)]
local procedure OnBeforeShowRecords(var TempDocumentEntry: Record "Document Entry" temporary; DocNoFilter: Text; PostingDateFilter: Text; ItemTrackingSearch: Boolean; ContactNo: Code[250]; ExtDocNo: Code[250]; var IsHandled: Boolean)
var
SampleDocHeader: Record "Sample Posted Document Header";
begin
if TempDocumentEntry."Table ID" <> Database::"Sample Posted Document Header" then
exit;
SampleDocHeader.SetFilter("No.", DocNoFilter);
SampleDocHeader.SetFilter("Posting Date", PostingDateFilter);
if TempDocumentEntry."No. of Records" = 1 then begin
SampleDocHeader.FindFirst();
Page.Run(Page::"Sample Posted Document", SampleDocHeader);
end else
Page.Run(0, SampleDocHeader);
IsHandled := true;
end;
}

View file

@ -0,0 +1,93 @@
---
bc-version: [all]
domain: data-modeling
keywords: [navigate, find-entries, document-entry, integration-event, drill-down]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Extend Find Entries (Navigate) for new document or transaction tables
## Description
`page 344 Navigate` (caption "Find entries") lets a user enter a document
number and posting date and see, across every document and ledger entry
table BC knows about, how many matching records exist — then drill into
any of those rows. It works over a temporary `table "Document Entry"`
that gets populated, one row per source table, by dozens of separate
lookups hardcoded into the page (`Rec.InsertIntoDocEntry(Database::"Sales
Invoice Header", ...)` and similar, one per table). A new custom document
or transaction table is invisible to Find Entries by default — nobody
searching by document number will ever see it in the result list — until
it registers itself.
Registration is a two-sided integration event, and only implementing one
side produces a page that is worse than not participating at all. The
`OnAfterFindRecords` event lets a subscriber add a row to the result list
for a custom table. But the subsequent "show records" action, `procedure
ShowRecords`, resolves which page to open through its own hardcoded `case
Rec."Table ID" of` — the same shape as the row-population code, and just
as unaware of any table added by an extension. That `case` statement has
no `else` branch. A custom table's row can appear in the result list,
with a correct count, and be entirely un-clickable: the user selects it,
chooses "Show records", and nothing happens, silently.
## Best Practice
Subscribe to both `Navigate::OnAfterFindRecords` and
`Navigate::OnBeforeShowRecords` together, as one unit of work, for any
custom table that should be searchable by document number:
- In `OnAfterFindRecords`, filter the custom table by the given
`DocNoFilter`/`PostingDateFilter` and call
`DocumentEntry.InsertIntoDocEntry(Database::"My Table", TableCaption,
Count)` to add it to the result list.
- In `OnBeforeShowRecords`, check whether
`TempDocumentEntry."Table ID" = Database::"My Table"`; if so, re-apply
the same filters, open the appropriate card or list page, and set
`IsHandled := true` so the page's own unrelated `case` statement is
never reached for this table.
- If `OnAfterFindRecords` filters the custom table by a field that is not
already that table's own unique key — for example an external
reference number received from a counterparty, rather than the
table's own `No.` — add a key combining that field with `Posting Date`,
the same way BCApps does for `Purch. Inv. Header`'s `"Vendor Invoice
No."` (see Source). This does not apply when filtering the table's own
primary key, which is already unique on its own: `Sales Invoice
Header` filters `"No."` and `"Posting Date"` through two separate,
uncombined keys, with no compound key between them, because `"No."`
alone is already sufficient.
See sample: `extend-find-entries-navigate-for-new-document-types.good.al`.
## Anti Pattern
Subscribing only to `OnAfterFindRecords` (or only to
`OnBeforeShowRecords`). Registering the row without handling its
drill-down produces a search result that looks complete — the table name
and a correct record count both show up — but leads nowhere when
selected, with no error and no indication to the user that anything is
wrong.
See sample: `extend-find-entries-navigate-for-new-document-types.bad.al`.
## Source
BCApps `Navigate.Page.al` (page 344, `src/Layers/W1/BaseApp/Foundation/Navigate/`):
- `[IntegrationEvent(true, false)] local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)`
- `[IntegrationEvent(true, false)] local procedure OnBeforeShowRecords(var TempDocumentEntry: Record "Document Entry" temporary; DocNoFilter: Text; PostingDateFilter: Text; ItemTrackingSearch: Boolean; ContactNo: Code[250]; ExtDocNo: Code[250]; var IsHandled: Boolean)`
- `procedure ShowRecords()`'s `case Rec."Table ID" of ... end;` has no `else` branch — confirmed by reading the full case block, which ends directly with `end;` followed by `OnAfterShowRecords(...)`.
BCApps `DocumentEntry.Table.al` (table backing page 344):
`procedure InsertIntoDocEntry(DocTableID: Integer; DocTableName: Text; DocNoOfRecords: Integer)` — the registration entry point called from `OnAfterFindRecords` subscribers.
BCApps `SalesInvoiceHeader.Table.al` (`src/Layers/W1/BaseApp/Sales/History/`):
`key(Key1; "No.")` (`Clustered = true`) and `key(Key9; "Posting Date")` are
two separate, uncombined keys — no compound key exists between them.
BCApps `PurchInvHeader.Table.al` (`src/Layers/W1/BaseApp/Purchases/History/`):
`key(Key4; "Vendor Invoice No.", "Posting Date")` — a compound key
combining a non-unique, externally-supplied reference number with
`Posting Date`, distinct from `key(Key1; "No.")`, its own unique primary
key.

View file

@ -0,0 +1,38 @@
enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selection Usage"
{
value(50100; "Sample.SettlementDoc")
{
Caption = 'Sample Settlement Document';
}
}
report 50100 "Sample Settlement Document"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
dataset
{
dataitem(Customer; Customer)
{
column(No_Customer; "No.") { }
}
}
}
codeunit 50100 "Sample Report Selection Install"
{
procedure InstallDefaultReportSelection()
var
ReportSelections: Record "Report Selections";
begin
ReportSelections.InsertRecord(
"Report Selection Usage"::"Sample.SettlementDoc", '1', Report::"Sample Settlement Document");
// Registration ends here. No subscriber added to
// OnAfterFilterCustomerUsageReportSelections / OnAfterFilterVendorUsageReportSelections
// - the tenant-wide default works, but "Copy from Report Selection"
// on the Document Layouts page never lists this usage value, so a
// per-account override can only be entered by hand, if a user even
// knows to look for it.
end;
}

View file

@ -0,0 +1,56 @@
enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selection Usage"
{
value(50100; "Sample.SettlementDoc")
{
Caption = 'Sample Settlement Document';
}
}
report 50100 "Sample Settlement Document"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
dataset
{
dataitem(Customer; Customer)
{
column(No_Customer; "No.") { }
}
}
}
codeunit 50100 "Sample Report Selection Install"
{
procedure InstallDefaultReportSelection()
var
ReportSelections: Record "Report Selections";
begin
ReportSelections.InsertRecord(
"Report Selection Usage"::"Sample.SettlementDoc", '1', Report::"Sample Settlement Document");
end;
}
codeunit 50101 "Sample Report Selection Subscribers"
{
// Appends to whatever the standard filter already contains, following
// the real BCApps pattern in ReportSelectionHandlerCZC.Codeunit.al.
[EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnAfterFilterCustomerUsageReportSelections', '', false, false)]
local procedure AddSampleUsageOnAfterFilterCustomerUsageReportSelections(var ReportSelections: Record "Report Selections")
begin
ReportSelections.SetFilter(Usage, GetUsageFilter(ReportSelections));
end;
[EventSubscriber(ObjectType::Page, Page::"Vendor Report Selections", 'OnAfterFilterVendorUsageReportSelections', '', false, false)]
local procedure AddSampleUsageOnAfterFilterVendorUsageReportSelections(var ReportSelections: Record "Report Selections")
begin
ReportSelections.SetFilter(Usage, GetUsageFilter(ReportSelections));
end;
local procedure GetUsageFilter(var ReportSelections: Record "Report Selections") UsageFilter: Text
begin
UsageFilter := Format("Report Selection Usage"::"Sample.SettlementDoc");
if ReportSelections.GetFilter(Usage) <> '' then
UsageFilter := StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage), UsageFilter);
end;
}

View file

@ -0,0 +1,79 @@
---
bc-version: [all]
domain: data-modeling
keywords: [report-selections, report-selection-usage, enumextension, document-layouts, custom-report-selection]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Register a new document type through Report Selections, and extend the Document Layouts filter
## Description
A custom document that needs to be printed or emailed should be registered
through `table 77 "Report Selections"`, not given its own bespoke
report/layout lookup. `enum 77 "Report Selection Usage"` is
`Extensible = true` specifically so a new document type can add its own
usage value via an `enumextension`, then register a default report for it
with `ReportSelections.InsertRecord(Usage, Sequence, ReportID)` — the same
mechanism every standard Sales/Purchase/Service document uses.
Registering through table 77 also brings per-account customization for
free: `table 9657 "Custom Report Selection"` (surfaced as the "Document
Layouts" action on the Customer and Vendor cards) lets one specific
account override both the report and the layout, and the platform's
lookup checks that table first before falling back to the tenant-wide
default. But the "Copy from Report Selection" action on the Document
Layouts pages — the convenience button a user actually uses to seed a
per-account override — filters to a **hardcoded** list of usage values
(`FilterCustomerUsageReportSelections`/`FilterVendorUsageReportSelections`
on `page 9657 "Customer Report Selections"`/`page 9658 "Vendor Report
Selections"`). A new custom usage value is not included automatically. Both
pages publish `OnAfterFilterCustomerUsageReportSelections(var
ReportSelections: Record "Report Selections")` /
`OnAfterFilterVendorUsageReportSelections(...)` for exactly this reason —
real BCApps localization apps (e.g. the Czech Compensation localization,
`ReportSelectionHandlerCZC.Codeunit.al`) subscribe to both events and
extend the filter with `StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage),
UsageFilter)`, appending to whatever filter already exists rather than
replacing it.
## Best Practice
Add the new usage value via `enumextension ... extends "Report Selection
Usage"`, register a tenant-wide default row with
`ReportSelections.InsertRecord(...)`, and subscribe to both
`OnAfterFilterCustomerUsageReportSelections` and
`OnAfterFilterVendorUsageReportSelections` — even if the document only
ever applies to one counterparty side — appending to the existing filter
rather than overwriting it. Treat the registration and the filter
subscription as one inseparable step: shipping one without the other
leaves per-account layout customization silently unreachable through the
standard UI.
See sample: `extend-report-selection-usage-for-new-document-types.good.al`.
## Anti Pattern
Adding a new `Report Selection Usage` value and registering a default
report, but never subscribing to the filter events. The tenant-wide
default works, so the gap isn't visible in testing — but a user who opens
"Document Layouts" on a specific customer or vendor and clicks "Copy from
Report Selection" to start a per-account override will never see the new
document type in the list, with no error and no visible sign that
anything is missing.
See sample: `extend-report-selection-usage-for-new-document-types.bad.al`.
## Source
BCApps `ReportSelections.Table.al` (table 77, `InsertRecord` at line 344),
`ReportSelectionUsage.Enum.al` (enum 77, `Extensible = true`),
`CustomReportSelection.Table.al` (table 9657), `CustomerReportSelections.Page.al`
(page 9657, `FilterCustomerUsageReportSelections` and
`OnAfterFilterCustomerUsageReportSelections` at line 335),
`VendorReportSelections.Page.al` (page 9658, `OnAfterFilterVendorUsageReportSelections`
at line 296) — all under `src/Layers/W1/BaseApp/`. Real subscriber
precedent: `src/Apps/CZ/CompensationLocalization/app/Src/Codeunits/ReportSelectionHandlerCZC.Codeunit.al`,
`GetUsageFilter` (line 104) and both event subscribers (lines 38, 66).

View file

@ -0,0 +1,30 @@
tableextension 50100 "Sample Sales Header Ext" extends "Sales Header"
{
fields
{
field(50000; "Reference No."; Code[20])
{
Caption = 'Reference No.';
DataClassification = CustomerContent;
}
}
}
tableextension 50101 "Sample Sales Invoice Header Ext" extends "Sales Invoice Header"
{
fields
{
// WRONG: same field number 50000, but a shorter length than the
// Sales Header extension above. This compiles fine and posts
// fine for every "Reference No." of 10 characters or less -
// SalesInvHeader.TransferFields(SalesHeader) in
// SalesPost.Codeunit.al only throws once an actual value longer
// than 10 characters reaches posting, which typical test data
// never triggers.
field(50000; "Reference No."; Code[10])
{
Caption = 'Reference No.';
DataClassification = CustomerContent;
}
}
}

View file

@ -0,0 +1,28 @@
tableextension 50100 "Sample Sales Header Ext" extends "Sales Header"
{
fields
{
field(50000; "Reference No."; Code[20])
{
Caption = 'Reference No.';
DataClassification = CustomerContent;
}
}
}
tableextension 50101 "Sample Sales Invoice Header Ext" extends "Sales Invoice Header"
{
fields
{
// Same field number, same type, same length as the Sales Header
// extension above. SalesInvHeader.TransferFields(SalesHeader) in
// SalesPost.Codeunit.al only bridges two fields that agree on all
// three - matching all three here is what makes this value
// survive posting for every possible "Reference No." value.
field(50000; "Reference No."; Code[20])
{
Caption = 'Reference No.';
DataClassification = CustomerContent;
}
}
}

View file

@ -0,0 +1,87 @@
---
bc-version: [all]
domain: data-modeling
keywords: [transferfields, field-number, posting-cascade, schema-design, custom-field]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Mirrored TransferFields cascade fields must match type and length exactly
## Description
Most custom fields genuinely belong to only one table — a status used
only before posting, a note relevant only afterwards, whatever the case
may be. That is the ordinary, unremarkable default, and it needs no
justification: `TransferFields` never touches a field that doesn't exist
on the destination. Per Microsoft's own documentation, a source field's
contents are copied "if such a field exists" on the destination with a
matching field number — a field defined on only one side of a posting
cascade is simply outside `TransferFields`' reach, not a gap to fix.
The narrower case this rule addresses is when a field **is** deliberately
mirrored across a known cascade — the same field number reused on
another table specifically so the value survives posting, for example a
field added to both `Sales Header` (36) and `Sales Invoice Header` (112),
which `SalesPost.Codeunit.al` connects via
`SalesInvHeader.TransferFields(SalesHeader)`. The two definitions have to
agree on type and, less obviously, on length. A field defined `Text[100]`
on `Sales Header` and `Text[50]` on `Sales Invoice Header` compiles
cleanly on both sides, and the `TransferFields` call runs without error
for every value up to 50 characters. Per Microsoft's documentation, a
runtime error only occurs when there isn't "room for the actual length
of the contents of the field to be copied" — so nothing fails while test
data, or early production data, stays short. The error surfaces only the
day an actual value finally exceeds the shorter definition, on a document
type that may have been posting cleanly for months.
See also `transferfields-skip-type-mismatch-can-drop-data.md`, which
covers `SkipFieldsNotMatchingType = true` silently skipping a *type*
mismatch between same-extension fields. That parameter has no effect on
length: two fields of the same type but different length still raise the
runtime error described above regardless of how `SkipFieldsNotMatchingType`
is set, which is the distinct failure mode this article addresses.
## Best Practice
When mirroring a field across a `TransferFields` cascade, define it with
the exact same field number, data type, and length on every table in
that cascade, at creation time. A field intentionally left local to one
table is unaffected by this and needs no mirroring at all — this is a
consistency requirement between definitions that are already meant to be
linked, not a mandate to check every field against every table on the
cascade.
See sample: `transferfields-mirrored-fields-must-match-type-and-length.good.al`.
## Anti Pattern
The same field number added to two tables that `TransferFields` connects
in a posting cascade (e.g. `Sales Header` (36) and `Sales Invoice Header`
(112), linked by `SalesPost.Codeunit.al`), with a shorter length — or an
incompatible data type — on one side. Both definitions compile without
error; nothing fails until an actual value exceeds the shorter one, which
typical test data never does.
See sample: `transferfields-mirrored-fields-must-match-type-and-length.bad.al`.
## Source
Microsoft Learn, `Record.TransferFields(var Record [, Boolean])`:
"The `TransferFields` method copies fields based on the field number on
the fields. For each field in `Record` (the destination), the contents
of the field that has the same field number in `FromRecord` (the source)
will be copied, **if such a field exists**." And: "The fields must have
the *same data type* for the copying to succeed... There must be room
for the actual length of the contents of the field to be copied in the
field to which it is to be copied. If any one of these conditions aren't
fulfilled, a runtime error will occur."
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-transferfields-table-boolean-method)
BCApps `SalesPost.Codeunit.al` (`src/Layers/W1/BaseApp/Sales/Posting/`):
`SalesShptHeader.TransferFields(SalesHeader);` (line 7104),
`ReturnRcptHeader.TransferFields(SalesHeader);` (line 7166),
`SalesInvHeader.TransferFields(SalesHeader);` (line 7220),
`SalesCrMemoHeader.TransferFields(SalesHeader);` (line 7275) — the real
cascade a mirrored field on `Sales Header` (36) is checked against.

View file

@ -52,6 +52,11 @@ The following targeted checks cover every current `data-modeling` article. Treat
- A master table adds or changes `Last Date Modified`, `OnModify`, or `OnRename`, but the non-editable field is not assigned `Today()` in both triggers — `set-last-date-modified-in-onmodify-and-onrename`.
- A `tableextension` appends a conditional `TableRelation` as if it overrides an earlier unconditional relation, or relation branches are otherwise designed without accounting for additive top-down evaluation — `table-relation-extensions-are-additive-and-top-down`.
- A `Media` or `MediaSet` field is assigned directly between different table types or different field IDs instead of registering each shared item with `MediaSet.Insert` — `share-mediaset-items-with-insert-not-field-assignment`.
- A codeunit dispatches a document by calling `Report.Run`/`Report.RunModal` with a hardcoded report ID and building its own email directly, with no accompanying `Report Selections` registration for that document — `custom-document-dispatch-must-not-bypass-report-selections`. A call that already goes through `Report Selections`' own Print/Email procedures is not this anti-pattern.
- A document's own interactive Print/Email action routes through `Document Sending Profile` (`DocumentSendingProfile.Send`/`SendVendor`) instead of calling `Report Selections` (`PrintForCust`/`PrintWithDialogForCust`/`SendEmailToCust`/`PrintWithDialogForVend`/`SendEmailToVendor`) directly — `document-print-and-email-actions-call-report-selections-directly`. Do not flag `Document Sending Profile` usage that is genuinely part of a combined Post-and-Send action.
- An `EventSubscriber` is added for `Navigate::OnAfterFindRecords` (registering a custom table in Find Entries) without a matching `Navigate::OnBeforeShowRecords` subscriber for the same table, or vice versa — `extend-find-entries-navigate-for-new-document-types`. Both subscribers must be added together for the same table.
- An `enumextension` extends `"Report Selection Usage"` and registers a report via `ReportSelections.InsertRecord`, but no subscriber is added for `OnAfterFilterCustomerUsageReportSelections`/`OnAfterFilterVendorUsageReportSelections` on `page 9657`/`page 9658` — `extend-report-selection-usage-for-new-document-types`. Registration and the filter-event subscription are one inseparable unit of work.
- The same field number is added as a new field on two or more tables connected by a `TransferFields` call in a posting cascade (e.g. a header table and the posted-document table `SalesPost.Codeunit.al`/`PurchPost.Codeunit.al` transfer into), with a different data type or length on one side — `transferfields-mirrored-fields-must-match-type-and-length`. A field defined on only one side of the cascade is out of scope; this cues only on a field deliberately mirrored across the cascade with a type or length mismatch.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.