From 83f041b6624b965c061e05bc36cbdc1926399be2 Mon Sep 17 00:00:00 2001 From: Michael Dieringer Date: Thu, 10 Sep 2026 07:44:17 +0200 Subject: [PATCH] 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 --- ...h-must-not-bypass-report-selections.bad.al | 28 ++++++ ...-must-not-bypass-report-selections.good.al | 22 +++++ ...patch-must-not-bypass-report-selections.md | 62 ++++++++++++ ...ons-call-report-selections-directly.bad.al | 35 +++++++ ...ns-call-report-selections-directly.good.al | 45 +++++++++ ...actions-call-report-selections-directly.md | 99 +++++++++++++++++++ ...ies-navigate-for-new-document-types.bad.al | 19 ++++ ...es-navigate-for-new-document-types.good.al | 35 +++++++ ...entries-navigate-for-new-document-types.md | 93 +++++++++++++++++ ...ection-usage-for-new-document-types.bad.al | 38 +++++++ ...ction-usage-for-new-document-types.good.al | 56 +++++++++++ ...-selection-usage-for-new-document-types.md | 79 +++++++++++++++ ...d-fields-must-match-type-and-length.bad.al | 30 ++++++ ...-fields-must-match-type-and-length.good.al | 28 ++++++ ...rored-fields-must-match-type-and-length.md | 87 ++++++++++++++++ .../skills/review/al-data-modeling-review.md | 5 + 16 files changed, 761 insertions(+) create mode 100644 microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al create mode 100644 microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al create mode 100644 microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md create mode 100644 microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al create mode 100644 microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al create mode 100644 microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md create mode 100644 microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al create mode 100644 microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al create mode 100644 microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md create mode 100644 microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al create mode 100644 microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al create mode 100644 microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md create mode 100644 microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al create mode 100644 microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al create mode 100644 microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md diff --git a/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al new file mode 100644 index 00000000..ff560e90 --- /dev/null +++ b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al new file mode 100644 index 00000000..bb210f25 --- /dev/null +++ b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md new file mode 100644 index 00000000..00fafd76 --- /dev/null +++ b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md @@ -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/`. diff --git a/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al new file mode 100644 index 00000000..b37ee9d0 --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al @@ -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; + } + } + } +} diff --git a/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al new file mode 100644 index 00000000..a681dac6 --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al @@ -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; + } + } + } +} diff --git a/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md new file mode 100644 index 00000000..99990959 --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md @@ -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 diff --git a/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al new file mode 100644 index 00000000..92834394 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al new file mode 100644 index 00000000..843935ca --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md new file mode 100644 index 00000000..52c95fce --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md @@ -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. diff --git a/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al new file mode 100644 index 00000000..046777ea --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al new file mode 100644 index 00000000..f2f32441 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md new file mode 100644 index 00000000..1f46b9f4 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md @@ -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). diff --git a/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al new file mode 100644 index 00000000..b91dba88 --- /dev/null +++ b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al @@ -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; + } + } +} diff --git a/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al new file mode 100644 index 00000000..9f6bcc39 --- /dev/null +++ b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al @@ -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; + } + } +} diff --git a/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md new file mode 100644 index 00000000..43b7b6af --- /dev/null +++ b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md @@ -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. diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 2386613a..9e11323d 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -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`.