From 83f041b6624b965c061e05bc36cbdc1926399be2 Mon Sep 17 00:00:00 2001 From: Michael Dieringer Date: Thu, 10 Sep 2026 07:44:17 +0200 Subject: [PATCH 1/7] 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`. From 31206f6616ae9b7183da6af077a51c82bdbc21e4 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:33:04 +0200 Subject: [PATCH 2/7] Fix four merge-critical blockers from Jesper's review; add 4 more patterns Addresses microsoft/BCQuality#175 review feedback: - Extend al-data-modeling-review's entry gate/relevance scope and token list to recognize document actions, Navigate subscribers, Report Selection registration, price-calculation/price-source extensibility, TransferFields posting-cascade mirroring, and barcode font-provider usage - previously excluded before any worklist cue could run. - Fix document-print-and-email-actions-call-report-selections-directly: permit the legitimate stateless DocumentSendingProfile.TrySendToPrinter/ TrySendToEMail path; rework the bad fixture to load a configured profile instead of demonstrating a trivial blank-record no-op. - Fix extend-report-selection-usage-for-new-document-types: scope to the applicable single counterparty (ReportSelectionHandlerCZZ partitions strictly; only genuinely two-sided usages like Compensation need both), and add the page-facing usage-enum map/validate events alongside the filter-event subscription for full Document Layouts support. - Fix a stale field-citation in custom-document-dispatch-must-not-bypass- report-selections (Custom Report Layout Code is field 7, not part of the 19-26 email-configuration range). - Add deterministic positive/clean evaluation coverage (review-fixtures.json additionalArticles + Test-ReviewFixtures.ps1 support) so all 9 new good/bad pairs are actually exercised, not just present. - Add 4 new patterns: activate-new-price-calculation-handler-via- onfindsupportedsetup, extend-price-source-type-must-sync-document- subset-enum, new-price-source-must-add-candidate-and-trigger- recalculation, report-barcodes-must-use-barcode-module-and-production- font-name. All claims verified against live microsoft/BCApps source and Microsoft Learn. Validators: frontmatter 0/0, review-fixtures 52 cases/17 domains PASSED, knowledge-index 309 articles PASSED. Co-Authored-By: Claude Sonnet 5 --- evaluation/README.md | 2 +- evaluation/review-fixtures.json | 14 ++ ...on-handler-via-onfindsupportedsetup.bad.al | 15 ++ ...n-handler-via-onfindsupportedsetup.good.al | 25 ++++ ...lation-handler-via-onfindsupportedsetup.md | 82 ++++++++++ ...patch-must-not-bypass-report-selections.md | 10 +- ...ons-call-report-selections-directly.bad.al | 16 +- ...ns-call-report-selections-directly.good.al | 5 +- ...actions-call-report-selections-directly.md | 141 +++++++++--------- ...type-must-sync-document-subset-enum.bad.al | 14 ++ ...ype-must-sync-document-subset-enum.good.al | 19 +++ ...rce-type-must-sync-document-subset-enum.md | 68 +++++++++ ...ection-usage-for-new-document-types.bad.al | 21 ++- ...ction-usage-for-new-document-types.good.al | 45 +++++- ...-selection-usage-for-new-document-types.md | 130 +++++++++------- ...candidate-and-trigger-recalculation.bad.al | 25 ++++ ...andidate-and-trigger-recalculation.good.al | 29 ++++ ...add-candidate-and-trigger-recalculation.md | 74 +++++++++ ...ode-module-and-production-font-name.bad.al | 29 ++++ ...de-module-and-production-font-name.good.al | 40 +++++ ...barcode-module-and-production-font-name.md | 96 ++++++++++++ .../skills/review/al-data-modeling-review.md | 16 +- tools/Test-ReviewFixtures.ps1 | 41 +++++ 23 files changed, 801 insertions(+), 156 deletions(-) create mode 100644 microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al create mode 100644 microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al create mode 100644 microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md create mode 100644 microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al create mode 100644 microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al create mode 100644 microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md create mode 100644 microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al create mode 100644 microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al create mode 100644 microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md create mode 100644 microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al create mode 100644 microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al create mode 100644 microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md diff --git a/evaluation/README.md b/evaluation/README.md index 7063bafa..e29b428c 100644 --- a/evaluation/README.md +++ b/evaluation/README.md @@ -2,7 +2,7 @@ The evaluation is convention-driven. The harness discovers every `/skills/review/al--review.md` leaf across the enabled `microsoft`, `community`, and `custom` layers. Duplicate domains resolve with `custom > community > microsoft` precedence. For each selected leaf, the harness finds paired knowledge across the same layers, applies the same precedence to duplicate article slugs, selects the first article (by filename) with both `.bad.al` and `.good.al` companions, and derives the expected positive and clean control automatically. Adding a conforming leaf requires no scoring-contract edit. -`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different article or add context when the generic convention cannot express a scenario. It should remain empty in the normal case. +`review-fixtures.json` contains only global thresholds and optional exceptional overrides. An override may select a different article or add context when the generic convention cannot express a scenario. It should remain empty in the normal case. An override may also list `additionalArticles` — other same-domain slugs (each with a `.good.al`/`.bad.al` pair) that get their own deterministic positive/clean case pair alongside the convention-selected one. Use this when a single leaf's worklist covers several distinct, newly-added rules and each one needs its own proof of reachability rather than riding on whichever article the generic convention happens to select. Model-facing preparation hashes case IDs, neutralizes `Good`/`Bad` object-name tokens, and removes full-line sample comments so neither the article slug, domain, nor expected outcome reveals the answer. diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index f5c70461..88b24147 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -13,6 +13,20 @@ "breaking-changes": { "article": "do-not-expose-sensitive-data-through-public-api" }, + "data-modeling": { + "article": "check-blocked-in-referencing-code-not-in-master", + "additionalArticles": [ + "extend-report-selection-usage-for-new-document-types", + "document-print-and-email-actions-call-report-selections-directly", + "custom-document-dispatch-must-not-bypass-report-selections", + "transferfields-mirrored-fields-must-match-type-and-length", + "extend-find-entries-navigate-for-new-document-types", + "activate-new-price-calculation-handler-via-onfindsupportedsetup", + "extend-price-source-type-must-sync-document-subset-enum", + "new-price-source-must-add-candidate-and-trigger-recalculation", + "report-barcodes-must-use-barcode-module-and-production-font-name" + ] + }, "events": { "article": "reset-ishandled-only-when-the-value-can-carry-over" }, diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al new file mode 100644 index 00000000..a3f7c0f0 --- /dev/null +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al @@ -0,0 +1,15 @@ +enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation Handler" +{ + value(50102; "Sample Special Price") + { + Caption = 'Sample Special Price'; + Implementation = "Price Calculation" = "Sample Price Calc - Special"; + } +} + +// WRONG: no subscriber to Price Calculation Mgt.'s OnFindSupportedSetup. +// "Sample Special Price" is a real, working implementation of the Price +// Calculation interface - it simply has no Price Calculation Setup row +// naming it, so Price Calculation Mgt. never selects it for any sale, +// purchase, or job line. It ships invisible until someone notices and +// configures a setup row for it by hand. diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al new file mode 100644 index 00000000..c36928b7 --- /dev/null +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al @@ -0,0 +1,25 @@ +enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation Handler" +{ + value(50102; "Sample Special Price") + { + Caption = 'Sample Special Price'; + Implementation = "Price Calculation" = "Sample Price Calc - Special"; + } +} + +codeunit 50104 "Sample Price Calc Setup Install" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Price Calculation Mgt.", 'OnFindSupportedSetup', '', false, false)] + local procedure AddSampleSpecialPriceSetup(var TempPriceCalculationSetup: Record "Price Calculation Setup" temporary) + begin + TempPriceCalculationSetup.Init(); + TempPriceCalculationSetup.Code := 'SAMPLE-SPECIAL'; + TempPriceCalculationSetup.Method := TempPriceCalculationSetup.Method::"Lowest Price"; + TempPriceCalculationSetup.Type := TempPriceCalculationSetup.Type::Sale; + TempPriceCalculationSetup."Asset Type" := TempPriceCalculationSetup."Asset Type"::" "; + TempPriceCalculationSetup.Implementation := TempPriceCalculationSetup.Implementation::"Sample Special Price"; + TempPriceCalculationSetup.Enabled := true; + TempPriceCalculationSetup.Default := true; + TempPriceCalculationSetup.Insert(); + end; +} diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md new file mode 100644 index 00000000..f95ad4d5 --- /dev/null +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md @@ -0,0 +1,82 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [price-calculation, price-calculation-handler, price-calculation-setup, integration-event, pricing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Activate a new Price Calculation Handler through OnFindSupportedSetup, not just by implementing it + +## Description + +`enum 7011 "Price Calculation Handler"` (`implements "Price +Calculation"`) is how a new pricing engine plugs into Business Central — +extend the enum with a value pointing at a codeunit that implements the +`Price Calculation` interface. That alone does not make the new handler +usable on any document. `codeunit 7001 "Price Calculation Mgt."` decides +which handler applies to a given line by looking up `table 7006 "Price +Calculation Setup"`, a table of `(Code, Method, Type, Asset Type, +Implementation, Enabled, Default)` rows populated at startup by its own +`OnFindSupportedSetup` event — every implementation codeunit is expected +to subscribe to that event and insert its own setup row(s). A handler +enum value with no matching setup row is real and selectable in the enum +itself, but never chosen for any actual sale, purchase, or job line, +because `Price Calculation Mgt.` has no setup row that names it. A setup +row that exists but doesn't match is just as invisible: `FindSetup` +filters candidates with `SetRange(Default, true)` and `SetRange(Method, +DtldPriceCalcSetup.Method)` (a document's blank Method is normalized to +`"Lowest Price"` before that filter runs), so a row inserted without +`Default := true`, or with a `Method` that doesn't match, is never +selected either — same symptom, different cause. + +## Best Practice + +Ship a new `Price Calculation Handler` value together with an +`OnFindSupportedSetup` subscriber that inserts at least one `Price +Calculation Setup` record naming it as the `Implementation`, for the +relevant `Method` (e.g. `"Lowest Price"`), `Type` (`Sale`/`Purchase`), and +`Asset Type` — with `Default := true`, since `FindSetup` only considers +rows where `Default` is set when resolving a handler for a line. + +See sample: `activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al`. + +## Anti Pattern + +Extending `Price Calculation Handler` and implementing the `Price +Calculation` interface, without subscribing to `OnFindSupportedSetup` to +insert a setup record. The new handler exists, compiles, and can even be +selected manually if a user creates their own `Price Calculation Setup` +row through the UI — but ships with no default row, so it's never active +for anyone until someone notices it's missing and configures it by hand. + +See sample: `activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al`. + +## Source + +BCApps (`src/Layers/W1/BaseApp/Pricing/Calculation/`): +`PriceCalculationHandler.Enum.al` (`enum 7011 "Price Calculation Handler" +implements "Price Calculation"`); `PriceCalculationMgt.Codeunit.al` +(`local procedure OnFindSupportedSetup(var TempPriceCalculationSetup: +Record "Price Calculation Setup" temporary)`, called during setup +resolution, and `procedure FindSetup(...)`, which requires +`SetRange(Default, true)` and a matching `SetRange(Method, +DtldPriceCalcSetup.Method)` before a row can be selected); +`PriceCalculationSetup.Table.al` (`table 7006 "Price Calculation Setup"`: +`Code` (Code[100]), `Method` (Enum "Price Calculation Method"), `Type` +(Enum "Price Type"), `"Asset Type"` (Enum "Price Asset Type"), +`Implementation` (Enum "Price Calculation Handler"), `Enabled` (Boolean), +`Default` (Boolean)). + +BCApps (`src/Layers/W1/BaseApp/Pricing/PriceList/`): `PriceType.Enum.al` +(`enum 7009 "Price Type"`: `Any`(0)/`Sale`(1)/`Purchase`(2)). + +Microsoft Learn, "Extending Price Calculations": "For the new codeunit, +you must extend the Price Calculation Handler enum that implements Price +Calculation interface... Afterwards you can insert a record in the Price +Calculation Setup table... Each codeunit that implements the Price +Calculation interface must subscribe to the OnFindSupportedSetup() event +of the Price Calculation Mgt codeunit to fill the price calculation setup +table with new options." +(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations) 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 index 00fafd76..6a96a3a0 100644 --- 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 @@ -18,7 +18,8 @@ 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 +Code"`, `"Email Body Layout Type"`), plus a separate per-usage layout +override, `"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 @@ -54,9 +55,10 @@ 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 +BCApps `ReportSelections.Table.al` (table 77 — field 7, +`"Custom Report Layout Code"`; 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 index b37ee9d0..49c5d45f 100644 --- 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 @@ -19,12 +19,16 @@ page 50101 "Sample Settlement Document Card" 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. + // part of a combined Post-and-Send action - but this + // loads the customer's ACTUAL assigned profile (or the + // tenant default, if none is assigned - the same lookup + // Sales-Post and Send performs) and calls Send on it, so + // the outcome now silently depends on that profile. A + // profile set up for Post-and-Send printing only (say, + // Printer = Yes, "E-Mail" = No) turns this button into a + // silent no-op, with no indication an unrelated setup + // field is why. + DocumentSendingProfile.GetDefaultForCustomer(Rec."No.", DocumentSendingProfile); DocumentSendingProfile.Send( "Report Selection Usage"::"S.Invoice".AsInteger(), Rec, Rec."No.", Rec."No.", Rec.Name, Rec.FieldNo("No."), Rec.FieldNo("No.")); 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 index a681dac6..533057c3 100644 --- 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 @@ -20,7 +20,10 @@ page 50101 "Sample Settlement Document Card" 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. + // not on any Document Sending Profile setting. Calling + // DocumentSendingProfile.TrySendToEMail(...) instead would + // be equally correct: it never Get's the customer's + // actually assigned profile, only a local, hardcoded one. ReportSelections.SendEmailToCust( "Report Selection Usage"::"S.Invoice".AsInteger(), Rec, Rec."No.", Rec.Name, true, Rec."No."); 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 index 99990959..337c0962 100644 --- 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 @@ -11,89 +11,90 @@ application-area: [all] ## 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. +`table 60 "Document Sending Profile"` is not a general gateway for every +print/email path — 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 actions are unaffected by any *configured* profile either +way: the unposted Sales Order's "Print Confirmation"/"Email +Confirmation" (`codeunit "Document-Print"`, +`PrintSalesOrder`/`EmailSalesHeader`) and the posted `Purch. Inv. +Header`'s `PrintRecords` call `Report Selections` literally directly +(`PrintWithDialogForCust`/`SendEmailToCust`/`PrintWithDialogForVend`), +while the posted `Sales Invoice Header`'s `PrintRecords`/`EmailRecords` +and the unposted `Purchase Header`'s `PrintRecords` go through +`DocumentSendingProfile.TrySendToPrinter`/`TrySendToEMail`/ +`TrySendToPrinterVendor` instead. Those three helpers each declare a +fresh, local, never-`Get`'d profile record, hardcode its +`Printer`/`"E-Mail"` field to a "Yes" option themselves, and feed it into +`SendToPrinter`/`SendToEMailGroupedMultipleSelection` — which resolve +into Report Selections just like the direct route. The table is a +throwaway options carrier here, not the counterparty's configuration. -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 a genuinely configured profile changes the outcome, and that only +happens for the combined Post-and-Send flow: `Sales-Post and Send` loads +the customer's assigned profile (`Get(Customer."Document Sending +Profile")`, or the tenant default) before `Sales Invoice +Header.SendProfile` → `DocumentSendingProfile.Send`, which gates +`SendToPrinter`/`SendToEMail`/`SendToDisk` on whatever that record holds. -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. +Whether a document needs outbound distribution isn't determined by +Customer vs. Vendor, but by whether it's genuinely *outbound* to that +party: a posted Purchase Invoice records what a vendor already billed, +so the posted `Purch. Inv. Header` has only a bare `PrintRecords`; a +Purchase *Order* is still outbound before posting, so the rich +`SendProfile`/`SendRecords`/`PrintRecords` triplet lives there instead. ## Best Practice -For a document's own interactive Print/Email actions, call the relevant -`Report Selections` procedure directly — +For a document's own interactive Print/Email actions, either 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. +customer-facing document, `PrintWithDialogForVend`/`SendEmailToVendor` +for a vendor-facing one — or call one of `Document Sending Profile`'s +stateless `TrySendToPrinter`/`TrySendToEMail`/`TrySendToPrinterVendor` +helpers, using the usage value registered per +`extend-report-selection-usage-for-new-document-types.md`. Both are +equally correct; neither reads the counterparty's assigned profile. +Reserve a genuine `Get`/`GetDefaultForCustomer`/`GetDefaultForVendor` +lookup and `Send`/`SendVendor` for Post-and-Send. 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. +Loading the counterparty's *actually assigned* `Document Sending +Profile` (or the tenant default, via `Get`/`GetDefaultForCustomer`/ +`GetDefaultForVendor` — the same lookup `Sales-Post and Send` performs) +and calling `Send`/`SendVendor` on it from a plain, on-demand "Email" +button, instead of `ReportSelections.SendEmailToCust`/`SendEmailToVendor` +directly. The button's outcome now silently depends on a profile +configured for Post-and-Send — if its `"E-Mail"` option is `No`, +clicking "Email" does nothing observable. A second version of the same +mistake: an email action on 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 +BCApps `DocumentPrint.Codeunit.al` (`EmailSalesHeader`/`DoPrintSalesHeader`/ +`PrintSalesOrder` → `ReportSelections.SendEmailToCust`/`PrintForCust`/ +`PrintWithDialogForCust` directly), `SalesInvoiceHeader.Table.al` +(`PrintRecords`/`EmailRecords`, lines 1453/1528 → `TrySendToPrinter`/ +`TrySendToEMail`, lines 1462/1541, on a local never-`Get`'d record), +`PurchaseHeader.Table.al` (`PrintRecords` line 6357 → +`TrySendToPrinterVendor` line 6374; `SendProfile` line 6387 → +`SendVendor` line 6403), `PurchInvHeader.Table.al` (`PrintRecords` → +`ReportSelection.PrintWithDialogForVend` directly, no send capability), +`SalesPostandSend.Codeunit.al`/`SalesPost.Codeunit.al` +(`ConfirmPostAndSend` loads `Get(Customer."Document Sending +Profile")`/`GetDefault`; `SendPostedDocumentRecord` line 7660 → +`SalesInvHeader.SendProfile` lines 7680/7699 → +`DocumentSendingProfile.Send`), `DocumentSendingProfile.Table.al` (table +60; `TrySendToPrinter`/`TrySendToEMail` lines 536/562, +`TrySendToPrinterVendor` line 552, `GetDefaultForCustomer` line 195, +`Send`/`SendVendor` lines 482/506) — 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-price-source-type-must-sync-document-subset-enum.bad.al b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al new file mode 100644 index 00000000..04359929 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al @@ -0,0 +1,14 @@ +enumextension 50100 "Sample Price Source Ext" extends "Price Source Type" +{ + value(50100; "Sample.LoyaltyTier") + { + Caption = 'Loyalty Tier'; + Implementation = "Price Source" = "Price Source - Customer", "Price Source Group" = "Price Source Group - Customer"; + } +} + +// WRONG: no matching value was added to "Sales Price Source Type" (or the +// purchase/job equivalents). "Sample.LoyaltyTier" compiles, installs, and +// is a real value on "Price Source Type" - it just never appears as an +// Applies-to Type option on the Sales Price List page, because that page +// is driven by the separate subset enum, not the base one. diff --git a/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al new file mode 100644 index 00000000..8acd6c62 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al @@ -0,0 +1,19 @@ +enumextension 50100 "Sample Price Source Ext" extends "Price Source Type" +{ + value(50100; "Sample.LoyaltyTier") + { + Caption = 'Loyalty Tier'; + Implementation = "Price Source" = "Price Source - Customer", "Price Source Group" = "Price Source Group - Customer"; + } +} + +enumextension 50101 "Sample Sales Price Source Ext" extends "Sales Price Source Type" +{ + // Same numeric ID (50100) as the Price Source Type value above. That + // match is what makes "Sample.LoyaltyTier" show up as a selectable + // Applies-to Type on an actual sales price list. + value(50100; "Sample.LoyaltyTier") + { + Caption = 'Loyalty Tier'; + } +} diff --git a/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md new file mode 100644 index 00000000..68f2c470 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md @@ -0,0 +1,68 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [price-calculation, price-source, price-source-type, enumextension, pricing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Extend Price Source Type and its matching document subset enum together, with the same ID + +## Description + +`enum 7003 "Price Source Type"` (`implements "Price Source", "Price Source +Group"`) is the base list of who a price can apply to — Customer, Vendor, +Customer Price Group, Campaign, and so on. It is not, by itself, what +drives the "Applies-to Type" field on an actual sales, purchase, or job +price list. Each document area has its own subset enum — +`enum 7006 "Sales Price Source Type"`, the equivalent purchase and job +enums — and these are what the price list pages actually expose. Every +value the two enums share today uses the identical numeric ID: `All +Customers`/`Customer`/`Customer Price Group`/`Customer Disc. +Group`/`Campaign`/`Contact` are 10/11/12/13/50/51 in both `Price Source +Type` and `Sales Price Source Type`. + +Adding a new value to `Price Source Type` alone does nothing for a sales +price list: the base enum and the document subset enum are two separate +extensible enums, linked only by convention, not by any platform +mechanism that keeps their IDs in sync. Give the new value a different ID +in each enum, or extend only the base enum, and the source is real and +selectable in some contexts (the base enum is used elsewhere, such as +the generic `Price Source` table) but absent from the specific document +price list a developer actually tested against. + +## Best Practice + +When a new price source should be usable in a sales, purchase, or job +price list, extend `Price Source Type` and the matching document subset +enum (`Sales Price Source Type`, `Purchase Price Source Type`, `Job Price +Source Type`) together, using the identical numeric ID in both. + +See sample: `extend-price-source-type-must-sync-document-subset-enum.good.al`. + +## Anti Pattern + +Extending `Price Source Type` with a new value intended for sales price +lists, without extending `Sales Price Source Type` with a value of the +same ID — or giving it a different ID. Either way, the new source is +absent from the "Applies-to Type" options on an actual sales price list, +with no error anywhere: the base enum extension compiles and installs +cleanly on its own. + +See sample: `extend-price-source-type-must-sync-document-subset-enum.bad.al`. + +## Source + +BCApps (`src/Layers/W1/BaseApp/`): `Pricing/Source/PriceSourceType.Enum.al` +(`enum 7003 "Price Source Type"`, values `10/11/12/13/50/51` for `All +Customers`/`Customer`/`Customer Price Group`/`Customer Disc. +Group`/`Campaign`/`Contact`) and `Sales/Pricing/SalesPriceSourceType.Enum.al` +(`enum 7006 "Sales Price Source Type"`, the same six values at the same +six IDs). Microsoft Learn, "Extending Price Calculations": "The Price +Source Type enum implements the Applies-to Type field in the header of +the price list. Additionally, the Sales Price Source Type, Purchase Price +Source Type, and Job Price Source Type are subsets of the Price Source +Type enum... For compatibility, the new value must have the same ID in +both enums." +(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations) 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 index 046777ea..d09d34a0 100644 --- 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 @@ -28,11 +28,20 @@ codeunit 50100 "Sample Report Selection Install" 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. + // Registration ends here. No enumextension was added to + // "Custom Report Selection Sales" (or "Report Selection Usage + // Vendor"), and no subscriber was added to + // OnAfterOnMapTableUsageValueToPageValue, OnValidateUsage2OnCaseElse, + // or OnAfterFilterCustomerUsageReportSelections / + // OnAfterFilterVendorUsageReportSelections. + // + // The tenant-wide default works, so the gap isn't visible in + // testing - but on the Document Layouts page for a specific + // customer or vendor: an existing row for this usage shows blank in + // the Usage column (no map event), a user cannot pick this usage + // from the Usage dropdown at all (no validate event and no + // page-facing enum value to pick), and "Copy from Report Selection" + // never lists it either (no filter event). No error, no visible + // sign that anything is missing. 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 index f2f32441..065e802f 100644 --- 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 @@ -6,6 +6,19 @@ enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selectio } } +// This document is only ever issued to a customer, so only the customer-side +// page-facing enum is extended - not the vendor-side one too. This mirrors +// BCApps' ReportSelectionHandlerCZZ, which extends "Custom Report Selection +// Sales" for its customer-only usages and "Report Selection Usage Vendor" +// for its vendor-only usages, never both for the same one-sided value. +enumextension 50101 "Sample Cust. Rep. Sel. Sales Ext" extends "Custom Report Selection Sales" +{ + value(50100; "Sample.SettlementDoc") + { + Caption = 'Sample Settlement Document'; + } +} + report 50100 "Sample Settlement Document" { UsageCategory = ReportsAndAnalysis; @@ -33,16 +46,34 @@ codeunit 50100 "Sample Report Selection Install" 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") + // Customer-only document: all three subscribers below are on + // "Customer Report Selections" only. There are no matching subscribers + // on "Vendor Report Selections" - subscribing there too would be the + // overbroad mistake this sample avoids (see the .bad.al companion and + // the article's Anti Pattern #2). + + // 1) Map: lets an existing row display in the Usage column instead of + // showing blank. + [EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnAfterOnMapTableUsageValueToPageValue', '', false, false)] + local procedure AddSampleUsageOnAfterOnMapTableUsageValueToPageValue(var Usage2: Enum "Custom Report Selection Sales"; CustomReportSelection: Record "Custom Report Selection") begin - ReportSelections.SetFilter(Usage, GetUsageFilter(ReportSelections)); + if CustomReportSelection.Usage = "Report Selection Usage"::"Sample.SettlementDoc" then + Usage2 := "Custom Report Selection Sales"::"Sample.SettlementDoc"; + end; + + // 2) Validate: lets a user pick the new value from the Usage dropdown. + [EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnValidateUsage2OnCaseElse', '', false, false)] + local procedure AddSampleUsageOnValidateUsage2OnCaseElse(var CustomReportSelection: Record "Custom Report Selection"; ReportUsage: Option) + begin + if ReportUsage = "Custom Report Selection Sales"::"Sample.SettlementDoc".AsInteger() then + CustomReportSelection.Usage := "Report Selection Usage"::"Sample.SettlementDoc"; end; - [EventSubscriber(ObjectType::Page, Page::"Vendor Report Selections", 'OnAfterFilterVendorUsageReportSelections', '', false, false)] - local procedure AddSampleUsageOnAfterFilterVendorUsageReportSelections(var ReportSelections: Record "Report Selections") + // 3) Filter: wires "Copy from Report Selection" - the piece most + // guidance stops at, appending to whatever filter already exists rather + // than replacing it. + [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; 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 index 1f46b9f4..62c7839c 100644 --- 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 @@ -7,73 +7,93 @@ countries: [w1] application-area: [all] --- -# Register a new document type through Report Selections, and extend the Document Layouts filter +# Register a new document type through Report Selections, and wire it into Document Layouts correctly ## 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. +A custom document that needs printing/emailing should be registered +through `table 77 "Report Selections"`. `enum 77 "Report Selection Usage"` +is `Extensible = true` for exactly this: add a value via `enumextension`, +then `ReportSelections.InsertRecord(Usage, Sequence, ReportID)` for a +tenant-wide default — the mechanism every standard 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. +That alone does not make the value usable in "Document Layouts" +(`page 9657 "Customer Report Selections"` / `page 9658 "Vendor Report +Selections"`, table 9657 "Custom Report Selection"). Both pages hide +`enum 77` behind their own page-facing enum — `enum 9657 "Custom Report +Selection Sales"` (customer) / `enum 9658 "Report Selection Usage Vendor"` +(vendor) — in a field named `Usage2`. A new value stays invisible there +until that page enum is extended too and three events are handled: +`OnAfterOnMapTableUsageValueToPageValue` / `OnMapTableUsageValueToPage +ValueOnCaseElse` (Usage column display), `OnValidateUsage2OnCaseElse` +(picking it from the dropdown), and `OnAfterFilterCustomerUsageReport +Selections` / `OnAfterFilterVendorUsageReportSelections` (the **"Copy from +Report Selection"** action only — a hardcoded-list filter, nothing more). + +Which side(s) need this depends on the counterparty the document actually +applies to — not "always both." BCApps' `ReportSelectionHandlerCZZ` +(Advance Payments) partitions strictly: `"Sales Advance..."` usages get +only the customer-side triad, `"Purchase Advance..."` only the vendor-side +triad. `ReportSelectionHandlerCZC` (Compensation) subscribes both sides — +legitimately, since that document posts to both ledgers, not by default. ## 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. +1. Add the usage value (`enumextension ... extends "Report Selection + Usage"`) and register the tenant-wide default. +2. Decide which counterparty(ies) apply — customer, vendor, or both. +3. For each applicable side, extend the matching page enum + (`"Custom Report Selection Sales"` / `"Report Selection Usage Vendor"`) + and subscribe to that page's map, validate, and filter events — + appending with `StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage), + UsageFilter)`, never overwriting. +4. Do not subscribe the other side for a one-sided document: skip the + triad and the value is unreachable in Document Layouts; wire both sides + needlessly and the picker is cluttered with a value that never applies. -See sample: `extend-report-selection-usage-for-new-document-types.good.al`. +See sample: `extend-report-selection-usage-for-new-document-types.good.al` +(customer-only document — only the customer-side enum and triad added). ## 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`. +1. Register the usage value but add no page-enum extension and no + subscribers. Works via the tenant-wide default, so it's invisible in + testing — but Document Layouts shows the value's rows blank, can't offer + it in the Usage dropdown, and "Copy from Report Selection" never lists + it. See sample: `extend-report-selection-usage-for-new-document-types.bad.al`. +2. Subscribe both counterparties' triads for a one-sided document. This is + the overbroad default Jesper Schulz-Wedde's review caught: it + contradicts how `ReportSelectionHandlerCZZ` actually partitions its + usages, and clutters the other counterparty's picker with a value that + will never resolve a report there. ## Source -BCApps `ReportSelections.Table.al` (table 77, `InsertRecord` at line 344), +`ReportSelections.Table.al` (table 77, `InsertRecord` 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). +`CustomReportSelection.Table.al` (table 9657) — all under +`src/Layers/W1/BaseApp/Foundation/Reporting/`. + +`CustomerReportSelections.Page.al` (page 9657, `.../Sales/Setup/`): +`FilterCustomerUsageReportSelections` (307), +`OnAfterFilterCustomerUsageReportSelections` (335), +`OnAfterOnMapTableUsageValueToPageValue` (325), +`OnValidateUsage2OnCaseElse` (330); enum `CustomReportSelectionSales.Enum.al` +(9657, same folder). `VendorReportSelections.Page.al` (page 9658, +`.../Purchases/Setup/`): `FilterVendorUsageReportSelections` (281), +`OnAfterFilterVendorUsageReportSelections` (296), +`OnMapTableUsageValueToPageValueOnCaseElse` (301), +`OnValidateUsage2OnCaseElse` (306); enum `ReportSelectionUsageVendor.Enum.al` +(9658, same folder). + +Partitioning precedent: `.../AdvancePaymentsLocalization/app/Src/Codeunits/ +ReportSelectionHandlerCZZ.Codeunit.al` (codeunit 31420) — customer-only +triad (47, 58, 69) for `"Sales Advance..."`, vendor-only triad (80, 91, +102) for `"Purchase Advance..."`, never both for one usage. Enum +extensions: `CustomReportSelSalesCZZ.EnumExt.al` (31008), `ReportSelUsage +VendorCZZ.EnumExt.al` (11708). + +Contrast (two-sided): `.../CompensationLocalization/app/Src/Codeunits/ +ReportSelectionHandlerCZC.Codeunit.al` (codeunit 11765) subscribes both +triads (16/27/38, 44/55/66) for `"Compensation CZC"`, which posts to both +a customer and a vendor ledger. (Lines as of `main`; may shift by version.) diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al new file mode 100644 index 00000000..56659fe9 --- /dev/null +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al @@ -0,0 +1,25 @@ +tableextension 50105 "Sample Sales Line Ext" extends "Sales Line" +{ + fields + { + // WRONG: no OnValidate trigger. The field is registered as a + // price source below via OnAfterAddSources, so new lines price + // correctly - but changing this field on an existing line never + // calls UpdateUnitPriceByField, so the unit price silently keeps + // its old value. + field(50100; "Sample Loyalty Customer No."; Code[20]) + { + Caption = 'Sample Loyalty Customer No.'; + TableRelation = Customer; + } + } +} + +codeunit 50106 "Sample Sales Line Price Sources" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales Line - Price", 'OnAfterAddSources', '', false, false)] + local procedure AddLoyaltyCustomerSource(SalesHeader: Record "Sales Header"; SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit "Price Source List") + begin + PriceSourceList.Add(Enum::"Price Source Type"::Customer, SalesLine."Sample Loyalty Customer No."); + end; +} diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al new file mode 100644 index 00000000..27f5d1c6 --- /dev/null +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al @@ -0,0 +1,29 @@ +tableextension 50105 "Sample Sales Line Ext" extends "Sales Line" +{ + fields + { + field(50100; "Sample Loyalty Customer No."; Code[20]) + { + Caption = 'Sample Loyalty Customer No.'; + TableRelation = Customer; + + trigger OnValidate() + begin + // Second half of the wiring: without this call, changing + // the field on an existing line never re-runs price + // calculation, even though the source is already a known + // candidate via OnAfterAddSources below. + UpdateUnitPriceByField(FieldNo("Sample Loyalty Customer No.")); + end; + } + } +} + +codeunit 50106 "Sample Sales Line Price Sources" +{ + [EventSubscriber(ObjectType::Codeunit, Codeunit::"Sales Line - Price", 'OnAfterAddSources', '', false, false)] + local procedure AddLoyaltyCustomerSource(SalesHeader: Record "Sales Header"; SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit "Price Source List") + begin + PriceSourceList.Add(Enum::"Price Source Type"::Customer, SalesLine."Sample Loyalty Customer No."); + end; +} diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md new file mode 100644 index 00000000..52bf9e23 --- /dev/null +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md @@ -0,0 +1,74 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [price-calculation, price-source, onafteraddsources, recalculation, pricing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# A new price source needs both a calculation candidate and a recalculation trigger + +## Description + +Making a custom field usable as a price source on a sales line is two +separate, independent pieces of wiring, and doing only one produces a +line that looks like it's using the new source without ever actually +being priced by it. `codeunit "Sales Line - Price"` publishes +`OnAfterAddSources(SalesHeader: Record "Sales Header"; SalesLine: Record +"Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit +"Price Source List")` — subscribing here and calling +`PriceSourceList.Add(SourceType, SourceNo)` makes the source a candidate +the calculation considers. But nothing about that subscription causes +the price to be *recalculated* when the source field's value changes on +an existing line. That's the second, separate piece: `Sales Line`'s own +`procedure UpdateUnitPriceByField(CalledByFieldNo: Integer)` must be +called from the source field's own trigger — the same way Microsoft's +own Location example is wired from a `Sales Line` validation event, not +from the price source registration itself. + +Add the source without wiring recalculation, and the failure hides +easily: a *new* line still prices correctly, because the field already +holds its value when calculation first runs on insert. The gap only +shows up when someone *changes* the source field's value on an existing +line — the price silently keeps its old value until something unrelated +happens to trigger recalculation. + +## Best Practice + +Wire both halves together whenever a field becomes a price source: an +`OnAfterAddSources` subscriber that adds it via `PriceSourceList.Add`, and +a trigger on the field itself (its own `OnValidate`, or a matching +`OnAfterValidate` integration event) that calls +`SalesLine.UpdateUnitPriceByField(SalesLine.FieldNo())`. + +See sample: `new-price-source-must-add-candidate-and-trigger-recalculation.good.al`. + +## Anti Pattern + +Subscribing to `OnAfterAddSources` to register a custom field as a price +source, without also triggering recalculation from that field's own +validation. The field is a genuine, working calculation candidate — new +lines price correctly — but editing the field on an existing line leaves +the unit price stale, with nothing to indicate why. + +See sample: `new-price-source-must-add-candidate-and-trigger-recalculation.bad.al`. + +## Source + +BCApps (`src/Layers/W1/BaseApp/`): `Sales/Pricing/SalesLinePrice.Codeunit.al` +(`local procedure OnAfterAddSources(SalesHeader: Record "Sales Header"; +SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var +PriceSourceList: Codeunit "Price Source List")`); `Pricing/Source/PriceSourceList.Codeunit.al` +(`procedure Add(SourceType: Enum "Price Source Type"; SourceNo: Code[20])`); +`Sales/Document/SalesLine.Table.al` (`procedure +UpdateUnitPriceByField(CalledByFieldNo: Integer)`). + +Microsoft Learn, "Extending Price Calculations" (Location example): "To +recalculate the price, we can subscribe to events that pass the sales +line by reference... We'll call the UpdateUnitPriceByLocationCode() +method, which is a simplified version of the UpdateUnitPriceByField() +method... To add the location in the source list for price calculations, +we'll subscribe to the OnAfterAddSources event of Codeunit 'Sales Line - +Price,' and add the Location Code as a source." +(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations) diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al new file mode 100644 index 00000000..1aae8d4b --- /dev/null +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al @@ -0,0 +1,29 @@ +report 50110 "Sample Item Barcode Label" +{ + UsageCategory = Tasks; + ApplicationArea = All; + Caption = 'Sample Item Barcode Label'; + + dataset + { + dataitem(Item; Item) + { + column(No_; "No.") { } + column(Barcode; BarcodeText) { } + + trigger OnAfterGetRecord() + begin + // WRONG: hand-rolled "encoding" instead of the Barcode + // module's provider/encoder API. This produces a string + // that looks like a Code 39 barcode (asterisk delimiters) + // but carries none of the platform's actual character-set + // or checksum handling - wrong regardless of which font + // is applied to it in the layout. + BarcodeText := '*' + "No." + '*'; + end; + } + } + + var + BarcodeText: Text; +} diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al new file mode 100644 index 00000000..cc3dcc60 --- /dev/null +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al @@ -0,0 +1,40 @@ +report 50110 "Sample Item Barcode Label" +{ + UsageCategory = Tasks; + ApplicationArea = All; + Caption = 'Sample Item Barcode Label'; + + dataset + { + dataitem(Item; Item) + { + column(No_; "No.") { } + column(Barcode; BarcodeText) { } + + trigger OnAfterGetRecord() + var + BarcodeFontProvider: Interface "Barcode Font Provider"; + begin + BarcodeFontProvider := Enum::"Barcode Font Provider"::IDAutomation1D; + BarcodeFontProvider.ValidateInput("No.", BarcodeSymbology); + BarcodeText := BarcodeFontProvider.EncodeFont("No.", BarcodeSymbology); + end; + } + } + + var + BarcodeSymbology: Enum "Barcode Symbology"; + BarcodeText: Text; + + trigger OnInitReport() + begin + BarcodeSymbology := Enum::"Barcode Symbology"::Code39; + end; + + // Layout requirement (can't be enforced in AL, so it's stated here): + // the Barcode column's text box must use the real, purchased font + // name - IDAutomationHC39M for Code 39 - never an evaluation name + // like "IDAutomationSHC39M Demo". Per Microsoft Learn, using the + // evaluation name in a Business Central online production + // environment means "the barcode won't render" at all. +} diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md new file mode 100644 index 00000000..01509472 --- /dev/null +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md @@ -0,0 +1,96 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [barcode, qr-code, barcode-font-provider, report-layout, saas, idautomation] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Generate report barcodes through the Barcode module, with the production font name + +## Description + +Business Central's barcode support lives in the System Application's +`Barcode` module (`src/System Application/App/Barcode`), not in a +project's own code: `interface "Barcode Font Provider"` / +`"Barcode Font Provider 2D"`, `enum "Barcode Symbology"` / +`"Barcode Symbology 2D"` (Code39, Code128, EAN-13, QR-Code, Data Matrix, +and more), and built-in implementations +(`codeunit 9215 "IDAutomation 1D Provider"`, +`codeunit 9221 "IDAutomation 2D Provider"`). A report encodes a data +string into a barcode string via this API; the layout then displays that +string using a barcode *font*. + +On Business Central online, this is available with no setup at all: +"With Business Central online, the IDAutomation fonts are automatically +available as part of the service. So you can start adding barcodes to +reports right away." (Microsoft Learn, "Adding Barcodes to Reports") — +unlike on-premises, where the fonts must be purchased and installed on +the server. + +That ease hides a SaaS-specific trap in the one manual step the API +doesn't cover: naming the actual font in the report layout. IDAutomation +ships both a purchased font and a same-looking evaluation font per +version (Code 39: `IDAutomationHC39M` purchased vs. +`IDAutomationSHC39M Demo` evaluation). Per Microsoft Learn ("Barcode +Fonts with Business Central Online"): "When you're applying barcode font +in the report layout for a Business Central online production +environment, be sure to use the purchased font name; not the evaluation +font name. If you use the evaluation font name, the barcode won't +render." Getting the font name wrong doesn't distort the barcode — it +produces nothing, in a step that lives in the layout file, not in AL, so +no compiler or reviewer catches it by reading the report object. Nothing +in the cited documentation says what an evaluation font name does outside +a production environment — the claim here is scoped exactly as +Microsoft states it: wrong in production, full stop. + +## Best Practice + +Encode through the real API — declare the provider via its interface and +enum, then call `ValidateInput`/`EncodeFont` — and treat naming the +production font in the layout as an equally required part of the same +task, not an afterthought left to whoever happens to touch the `.docx`/ +`.rdl` file. For a two-dimensional symbology other than Maxicode, the +font name to specify is literally `IDAutomation2D` (Maxicode itself uses +`IDAutomation2D MaxiCode`); for a one-dimensional symbology, use the +purchased version name for that specific font (e.g. `IDAutomationHC39M` +for Code 39), never a name containing `Demo`. + +See sample: `report-barcodes-must-use-barcode-module-and-production-font-name.good.al`. + +## Anti Pattern + +Constructing a barcode string by hand — string concatenation, manual +delimiters — instead of going through the Barcode module's provider +interface. It can look right (asterisks around a value, resembling +Code 39) while carrying none of the platform's actual character-set +handling or checksum logic, so it's wrong regardless of which font is +later applied to it. + +A second version of the same underlying mistake: encoding correctly +through the real API, but naming the evaluation font instead of the +purchased one in the layout. Both produce a report that looks complete +in review and testing and fails silently — the first because the encoded +data was never a real barcode, the second because Business Central +online refuses to render it at all. + +See sample: `report-barcodes-must-use-barcode-module-and-production-font-name.bad.al`. + +## Source + +BCApps System Application (`src/System Application/App/Barcode/src/`): +`Barcode Provider/Font/BarcodeFontProvider.Interface.al` +(`ValidateInput(InputText: Text; BarcodeSymbology: Enum "Barcode Symbology")`, +`EncodeFont(InputText: Text; BarcodeSymbology: Enum "Barcode Symbology"): Text`), +`Barcode Provider/Font/BarcodeFontProvider.Enum.al` +(`value(0; IDAutomation1D)`), `Barcode Provider/BarcodeSymbology.Enum.al` +(`value(100; Code39)`). Real BaseApp usage: +`src/Layers/W1/BaseApp/Inventory/Item/ItemGTINLabel.Report.al` +(`report 6625 "Item GTIN Label"`, lines 44-64). + +Microsoft Learn: "Adding Barcodes to Reports" +(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-add-barcodes) +and "Barcode Fonts with Business Central Online" +(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-barcode-fonts) +— both quoted verbatim above. diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 9e11323d..fff276d4 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Data-modeling findings are narrow by design — they apply when the diff touches setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, or audit fields. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with either a `pr-diff` (the standard PR-review entry point) or a `file-path` (single-file review). Data-modeling findings are narrow by design — they apply when the diff touches setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, document print/email/Post-and-Send actions, `Navigate` page subscribers, Report Selection registration or dispatch, price-calculation/price-source extensibility, `TransferFields`-based posting-cascade field mirroring, or barcode/report-layout font-provider usage. The skill returns `not-applicable` when none of those apply. ## Source @@ -37,9 +37,9 @@ Discard files that are not applicable. Retain conditionally applicable files (an Narrow the relevant files to the subset that applies to the changes under review. For each relevant file, compute overlap against: -- The changed AL object names and types — especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, and document or journal lines that reference a master. -- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, and posting validation. -- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`). +- The changed AL object names and types — especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, document or journal lines that reference a master, document pages/codeunits exposing print/email/Post-and-Send actions, codeunits subscribing to `Navigate`, enumextensions to `"Report Selection Usage"`/`"Price Calculation Handler"`/`"Price Source Type"`, and report objects that render barcodes. +- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, posting validation, and posting-cascade `TransferFields` calls. +- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPriceByField`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -55,8 +55,12 @@ The following targeted checks cover every current `data-modeling` article. Treat - 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. +- An `enumextension` extends `"Report Selection Usage"` and registers a report via `ReportSelections.InsertRecord`, without subscribing to the matching *single* counterparty's full triad — the filter event (`OnAfterFilterCustomerUsageReportSelections` on `page 9657` for a sales usage, `OnAfterFilterVendorUsageReportSelections` on `page 9658` for a purchase usage) AND the page-facing usage-enum map/validate events (`enumextension` on `"Custom Report Selection Sales"`/`"Report Selection Usage Vendor"` plus the matching map/validate subscribers) — `extend-report-selection-usage-for-new-document-types`. Requiring or wiring *both* counterparties by default for a one-sided document is also the anti-pattern (`ReportSelectionHandlerCZZ` partitions strictly by counterparty); only a genuinely two-sided usage (as `ReportSelectionHandlerCZC` demonstrates for Compensation) needs both. - 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. +- An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Type`/`Asset Type`, with `Method` and `Default := true` also set — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. +- An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`. +- A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that calls `SalesLine.UpdateUnitPriceByField` to recalculate — `new-price-source-must-add-candidate-and-trigger-recalculation`. +- A report builds a barcode string by manual concatenation/delimiters instead of the Barcode module's `"Barcode Font Provider"`/`"Barcode Font Provider 2D"` interface (`ValidateInput`/`EncodeFont`), or an otherwise correctly encoded barcode's report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. 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`. @@ -86,7 +90,7 @@ Outcome selection: - `completed` — the skill evaluated every worklist item. - `no-knowledge` — no applicable data-modeling knowledge survived filtering. -- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface. +- `not-applicable` — the diff touches no setup/master table, page, key, numbering, block-check, or audit-field surface, and no document print/email/Post-and-Send action, `Navigate` subscriber, Report Selection registration/dispatch, price-calculation/price-source extensibility point, posting-cascade `TransferFields` mirroring, or barcode/report-font-provider usage. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index a9912b70..e0fc8cf2 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -245,6 +245,47 @@ foreach ($domain in $leafDomains) { } $caseList.Add($case) | Out-Null } + + if ($override -and ($override.PSObject.Properties.Name -contains 'additionalArticles')) { + foreach ($additionalArticleName in @($override.additionalArticles)) { + $additionalName = [string]$additionalArticleName + if ($additionalName.EndsWith('.md')) { + $additionalName = [System.IO.Path]::GetFileNameWithoutExtension($additionalName) + } + $additionalArticle = $articles | Where-Object BaseName -eq $additionalName | Select-Object -First 1 + if (-not $additionalArticle) { + $articleExists = @( + foreach ($layer in $layers) { + $articleFile = Join-Path $Root "$($layer.Name)/knowledge/$domain/$additionalName.md" + if (Test-Path -LiteralPath $articleFile -PathType Leaf) { + $articleFile + } + } + ).Count -gt 0 + if ($articleExists) { + $problems.Add("${domain}: additionalArticles entry does not have both .good.al and .bad.al companion samples: $additionalName.md") | Out-Null + } else { + $problems.Add("${domain}: additionalArticles entry does not exist: $additionalName.md") | Out-Null + } + continue + } + if ($additionalArticle.BaseName -eq $selectedArticle.BaseName) { + $problems.Add("${domain}: additionalArticles entry duplicates the selected article: $additionalName") | Out-Null + continue + } + $additionalArticlePath = [string]$additionalArticle.ArticlePath + $additionalSampleDirectory = (Split-Path -Parent $additionalArticlePath).Replace('\', '/') + foreach ($kind in 'bad', 'good') { + $additionalCase = [pscustomobject]@{ + id = "$domain-$kind-$($additionalArticle.BaseName)" + domain = $domain + input = "$additionalSampleDirectory/$($additionalArticle.BaseName).$kind.al" + expected = if ($kind -eq 'bad') { @($additionalArticlePath) } else { @() } + } + $caseList.Add($additionalCase) | Out-Null + } + } + } } $cases = @($caseList) From eb9cae0af41ae792286a674ecf566b665671fed9 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:10:19 +0200 Subject: [PATCH 3/7] Fix 5 merge-critical issues from Jesper's 2026-09-24 review round - activate-new-price-calculation-handler-via-onfindsupportedsetup: Default := true is required only for the fallback branch of PriceCalculationMgt's two-stage FindSetup - a handler reachable via a specific Dtld. Price Calculation Setup row needs no Default. Softened the article and its worklist cue accordingly. Also fixed an undefined "Sample Price Calc - Special" codeunit referenced but never declared in the eval fixtures - added a real implementation of interface "Price Calculation" with stub methods. - new-price-source-must-add-candidate-and-trigger-recalculation: the good fixture called UpdateUnitPriceByField directly, which is a silent no-op without a prior PlanPriceCalcByField call (FieldCausedPriceCalculation gating, verified against SalesLine.Table.al). Switched to the public UpdateUnitPrice wrapper, matching real BCApps usage in ItemReferenceManagement.Codeunit.al. - report-barcodes-must-use-barcode-module-and-production-font-name: split the 1D (ValidateInput + EncodeFont) and 2D (EncodeFont only) Barcode Font Provider interfaces, which the article previously conflated. Reframed the Code 39 anti-pattern around demonstrable encoding/checksum mismatch (verified against IDA1DCode39Encoder.Codeunit.al's real '(value)' output) rather than rejecting all manual delimiter use, since '*' is a legitimate Code 39 start/stop character. Also fixed extend-find-entries-navigate- for-new-document-types' eval fixtures, which referenced an undefined "Sample Posted Document Header" table/page - declared both. All claims re-verified against live microsoft/BCApps source. Validators: frontmatter 0/0, review-fixtures 126/20 domains PASSED, knowledge-index 342/575 PASSED, skill-index 19 leaves PASSED. Co-Authored-By: Claude Sonnet 5 --- ...on-handler-via-onfindsupportedsetup.bad.al | 63 +++++++- ...n-handler-via-onfindsupportedsetup.good.al | 65 ++++++++ ...lation-handler-via-onfindsupportedsetup.md | 74 +++++---- ...ies-navigate-for-new-document-types.bad.al | 16 ++ ...es-navigate-for-new-document-types.good.al | 33 ++++ ...candidate-and-trigger-recalculation.bad.al | 4 +- ...andidate-and-trigger-recalculation.good.al | 11 +- ...add-candidate-and-trigger-recalculation.md | 45 ++++-- ...ode-module-and-production-font-name.bad.al | 22 ++- ...de-module-and-production-font-name.good.al | 20 ++- ...barcode-module-and-production-font-name.md | 142 +++++++++--------- .../skills/review/al-data-modeling-review.md | 2 +- 12 files changed, 374 insertions(+), 123 deletions(-) diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al index a3f7c0f0..6a023788 100644 --- a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al @@ -7,9 +7,68 @@ enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation H } } +// Demonstration-only AL: every method below is stubbed. This article is +// about activating a handler through OnFindSupportedSetup, not about the +// "Price Calculation" interface's own pricing logic. +codeunit 50103 "Sample Price Calc - Special" implements "Price Calculation" +{ + procedure Init(LineWithPrice: Interface "Line With Price"; PriceCalculationSetup: Record "Price Calculation Setup") + begin + end; + + procedure GetLine(var Line: Variant) + begin + end; + + procedure ApplyDiscount() + begin + end; + + procedure ApplyPrice(CalledByFieldNo: Integer) + begin + end; + + procedure CountDiscount(ShowAll: Boolean) Result: Integer + begin + end; + + procedure CountPrice(ShowAll: Boolean) Result: Integer + begin + end; + + procedure FindDiscount(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean + begin + end; + + procedure FindPrice(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean + begin + end; + + procedure IsDiscountExists(ShowAll: Boolean) Result: Boolean + begin + end; + + procedure IsPriceExists(ShowAll: Boolean) Result: Boolean + begin + end; + + procedure PickDiscount() + begin + end; + + procedure PickPrice() + begin + end; + + procedure ShowPrices(var TempPriceListLine: Record "Price List Line") + begin + end; +} + // WRONG: no subscriber to Price Calculation Mgt.'s OnFindSupportedSetup. // "Sample Special Price" is a real, working implementation of the Price // Calculation interface - it simply has no Price Calculation Setup row // naming it, so Price Calculation Mgt. never selects it for any sale, -// purchase, or job line. It ships invisible until someone notices and -// configures a setup row for it by hand. +// purchase, or job line, whether through the Default fallback or through +// a "Dtld. Price Calculation Setup" row. It ships invisible until someone +// notices and configures a setup row for it by hand. diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al index c36928b7..8f27beb1 100644 --- a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al @@ -7,6 +7,64 @@ enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation H } } +// Demonstration-only AL: every method below is stubbed. This article is +// about activating a handler through OnFindSupportedSetup, not about the +// "Price Calculation" interface's own pricing logic. +codeunit 50103 "Sample Price Calc - Special" implements "Price Calculation" +{ + procedure Init(LineWithPrice: Interface "Line With Price"; PriceCalculationSetup: Record "Price Calculation Setup") + begin + end; + + procedure GetLine(var Line: Variant) + begin + end; + + procedure ApplyDiscount() + begin + end; + + procedure ApplyPrice(CalledByFieldNo: Integer) + begin + end; + + procedure CountDiscount(ShowAll: Boolean) Result: Integer + begin + end; + + procedure CountPrice(ShowAll: Boolean) Result: Integer + begin + end; + + procedure FindDiscount(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean + begin + end; + + procedure FindPrice(var TempPriceListLine: Record "Price List Line"; ShowAll: Boolean) Found: Boolean + begin + end; + + procedure IsDiscountExists(ShowAll: Boolean) Result: Boolean + begin + end; + + procedure IsPriceExists(ShowAll: Boolean) Result: Boolean + begin + end; + + procedure PickDiscount() + begin + end; + + procedure PickPrice() + begin + end; + + procedure ShowPrices(var TempPriceListLine: Record "Price List Line") + begin + end; +} + codeunit 50104 "Sample Price Calc Setup Install" { [EventSubscriber(ObjectType::Codeunit, Codeunit::"Price Calculation Mgt.", 'OnFindSupportedSetup', '', false, false)] @@ -19,6 +77,13 @@ codeunit 50104 "Sample Price Calc Setup Install" TempPriceCalculationSetup."Asset Type" := TempPriceCalculationSetup."Asset Type"::" "; TempPriceCalculationSetup.Implementation := TempPriceCalculationSetup.Implementation::"Sample Special Price"; TempPriceCalculationSetup.Enabled := true; + // Default := true here because this row is meant as the fallback + // for Method = Lowest Price / Type = Sale / Asset Type = " " (all) + // - the combination Price Calculation Mgt.'s FindSetup selects via + // its own SetRange(Default, true) branch when no "Dtld. Price + // Calculation Setup" row names a more specific match. A handler + // meant to be picked only through such a specific, explicit + // detailed-setup row would not need Default := true at all. TempPriceCalculationSetup.Default := true; TempPriceCalculationSetup.Insert(); end; diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md index a76b6445..3654468a 100644 --- a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md @@ -23,13 +23,21 @@ Implementation, Enabled, Default)` rows populated at startup by its own to subscribe to that event and insert its own setup row(s). A handler enum value with no matching setup row is real and selectable in the enum itself, but never chosen for any actual sale, purchase, or job line, -because `Price Calculation Mgt.` has no setup row that names it. A setup -row that exists but doesn't match is just as invisible: `FindSetup` -filters candidates with `SetRange(Default, true)` and `SetRange(Method, -DtldPriceCalcSetup.Method)` (a document's blank Method is normalized to -`"Lowest Price"` before that filter runs), so a row inserted without -`Default := true`, or with a `Method` that doesn't match, is never -selected either — same symptom, different cause. +because `Price Calculation Mgt.` has no setup row that names it. + +`FindSetup` resolves a handler in two stages, and only the second one +looks at `Default`. It first asks `codeunit 7004 "Price Calculation Dtld. +Setup"` to match the line against `table 7008 "Dtld. Price Calculation +Setup"` ("Detailed Price Calculation Setup", keyed to an exact +`Method`/`Type`/`Asset Type`/`Source`/`Asset No.` combination via its own +`"Setup Code"`); on a match it does `PriceCalculationSetup.Get(... +"Setup Code")` directly, with no `Default` filter. Only when no detailed +row matches does it fall back to `SetRange(Default, true)` plus +`SetRange(Method, ...)` to pick the one catch-all row for that +combination. A row without `Default := true` is invisible to *that* +fallback, but not invisible outright — a detailed-setup row can still +select it by naming its `Code`. A row whose `Method` matches neither path +is invisible either way — same symptom, different cause. ## Best Practice @@ -37,8 +45,13 @@ Ship a new `Price Calculation Handler` value together with an `OnFindSupportedSetup` subscriber that inserts at least one `Price Calculation Setup` record naming it as the `Implementation`, for the relevant `Method` (e.g. `"Lowest Price"`), `Type` (`Sale`/`Purchase`), and -`Asset Type` — with `Default := true`, since `FindSetup` only considers -rows where `Default` is set when resolving a handler for a line. +`Asset Type`. `Default := true` is required only when this row is the +*fallback* for that combination — the row `FindSetup`'s own +`SetRange(Default, true)` branch selects when no more specific setup +applies. A handler meant to be selected only for specific customers or +items should instead be reachable through a matching `"Dtld. Price +Calculation Setup"` row; `FindSetup` resolves that before it ever checks +`Default`, so it needs no `Default := true`. See sample: [`activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al`](activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al). @@ -58,25 +71,28 @@ See sample: [`activate-new-price-calculation-handler-via-onfindsupportedsetup.ba BCApps (`src/Layers/W1/BaseApp/Pricing/Calculation/`): `PriceCalculationHandler.Enum.al` (`enum 7011 "Price Calculation Handler" implements "Price Calculation"`); `PriceCalculationMgt.Codeunit.al` -(`local procedure OnFindSupportedSetup(var TempPriceCalculationSetup: -Record "Price Calculation Setup" temporary)`, called during setup -resolution, and `procedure FindSetup(...)`, which requires -`SetRange(Default, true)` and a matching `SetRange(Method, -DtldPriceCalcSetup.Method)` before a row can be selected); -`PriceCalculationSetup.Table.al` (`table 7006 "Price Calculation Setup"`: -`Code` (Code[100]), `Method` (Enum "Price Calculation Method"), `Type` -(Enum "Price Type"), `"Asset Type"` (Enum "Price Asset Type"), -`Implementation` (Enum "Price Calculation Handler"), `Enabled` (Boolean), -`Default` (Boolean)). - -BCApps (`src/Layers/W1/BaseApp/Pricing/PriceList/`): `PriceType.Enum.al` -(`enum 7009 "Price Type"`: `Any`(0)/`Sale`(1)/`Purchase`(2)). +(`OnFindSupportedSetup(var TempPriceCalculationSetup: Record "Price +Calculation Setup" temporary)`, and `FindSetup(...): Boolean`, which +first calls `PriceCalculationDtldSetup.FindSetup(DtldPriceCalcSetup)` and +on a match does `PriceCalculationSetup.Get(... "Setup Code")` with no +`Default` filter — only on failure does it fall back to +`SetRange(Enabled, true)`, `SetRange(Default, true)`, `SetRange(Method, +...)`); `PriceCalculationSetup.Table.al` (`table 7006 "Price Calculation +Setup"`: `Code`, `Method`, `Type`, `"Asset Type"`, `Implementation`, +`Enabled`, `Default`); `PriceCalculationDtldSetup.Codeunit.al` (`codeunit +7004 "Price Calculation Dtld. Setup"`, `FindSetup(var DtldPriceCalcSetup: +Record "Dtld. Price Calculation Setup"): Boolean`, matching progressively +looser `Source Group`/`Source No.`/`Asset Type`/`Asset No.` combinations — +never `Default`); `DtldPriceCalculationSetup.Table.al` (`table 7008 "Dtld. +Price Calculation Setup"`, Caption "Detailed Price Calculation Setup", +`"Setup Code"` relates to `"Price Calculation Setup".Code where(Enabled = +const(true))` — no `Default` condition). -Microsoft Learn, "Extending Price Calculations": "For the new codeunit, -you must extend the Price Calculation Handler enum that implements Price -Calculation interface... Afterwards you can insert a record in the Price -Calculation Setup table... Each codeunit that implements the Price -Calculation interface must subscribe to the OnFindSupportedSetup() event -of the Price Calculation Mgt codeunit to fill the price calculation setup -table with new options." +Microsoft Learn, "Extending Price Calculations": "Each codeunit that +implements the Price Calculation interface must subscribe to the +OnFindSupportedSetup() event... to fill the price calculation setup +table." Same article: "You can enter detailed setup records for +non-default setup lines... If a matching setup is found its +implementation is used... If there is no matching setup exception, we +use the default implementation." (https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations) 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 index 92834394..486eee8c 100644 --- 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 @@ -1,3 +1,19 @@ +table 50104 "Sample Posted Document Header" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { Caption = 'No.'; } + field(2; "Posting Date"; Date) { Caption = 'Posting Date'; } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + codeunit 50103 "Sample Navigate Subscribers" { // WRONG: registers the row, so it appears in the Find Entries result 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 index 843935ca..c0d658bf 100644 --- 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 @@ -1,3 +1,36 @@ +table 50104 "Sample Posted Document Header" +{ + DataClassification = CustomerContent; + + fields + { + field(1; "No."; Code[20]) { Caption = 'No.'; } + field(2; "Posting Date"; Date) { Caption = 'Posting Date'; } + } + + keys + { + key(PK; "No.") { Clustered = true; } + } +} + +page 50104 "Sample Posted Document" +{ + PageType = Card; + SourceTable = "Sample Posted Document Header"; + UsageCategory = None; + ApplicationArea = All; + + layout + { + area(Content) + { + field("No."; Rec."No.") { ApplicationArea = All; } + field("Posting Date"; Rec."Posting Date") { ApplicationArea = All; } + } + } +} + codeunit 50103 "Sample Navigate Subscribers" { [EventSubscriber(ObjectType::Page, Page::Navigate, 'OnAfterFindRecords', '', false, false)] diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al index 56659fe9..6132c60e 100644 --- a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al @@ -5,8 +5,8 @@ tableextension 50105 "Sample Sales Line Ext" extends "Sales Line" // WRONG: no OnValidate trigger. The field is registered as a // price source below via OnAfterAddSources, so new lines price // correctly - but changing this field on an existing line never - // calls UpdateUnitPriceByField, so the unit price silently keeps - // its old value. + // triggers a recalculation (e.g. via UpdateUnitPrice), so the + // unit price silently keeps its old value. field(50100; "Sample Loyalty Customer No."; Code[20]) { Caption = 'Sample Loyalty Customer No.'; diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al index 27f5d1c6..6f8b1576 100644 --- a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al @@ -13,7 +13,16 @@ tableextension 50105 "Sample Sales Line Ext" extends "Sales Line" // the field on an existing line never re-runs price // calculation, even though the source is already a known // candidate via OnAfterAddSources below. - UpdateUnitPriceByField(FieldNo("Sample Loyalty Customer No.")); + // + // UpdateUnitPriceByField(CalledByFieldNo) only recalculates + // if PlanPriceCalcByField(CalledByFieldNo) was already + // called for that same field - calling it alone is a + // silent no-op. UpdateUnitPrice(CalledByFieldNo) does both + // steps in the right order (plan, then update) in one + // call; it's the same method the base app itself calls + // from outside Sales Line to trigger recalculation for a + // field it just changed. + UpdateUnitPrice(FieldNo("Sample Loyalty Customer No.")); end; } } diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md index 819e16a0..828fc8c4 100644 --- a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md @@ -21,11 +21,23 @@ being priced by it. `codeunit "Sales Line - Price"` publishes `PriceSourceList.Add(SourceType, SourceNo)` makes the source a candidate the calculation considers. But nothing about that subscription causes the price to be *recalculated* when the source field's value changes on -an existing line. That's the second, separate piece: `Sales Line`'s own -`procedure UpdateUnitPriceByField(CalledByFieldNo: Integer)` must be -called from the source field's own trigger — the same way Microsoft's -own Location example is wired from a `Sales Line` validation event, not -from the price source registration itself. +an existing line. That's the second, separate piece, and it needs to be +wired correctly: `Sales Line`'s `procedure +UpdateUnitPriceByField(CalledByFieldNo: Integer)` only recalculates if +the field was already *planned* — internally it exits immediately unless +`procedure PlanPriceCalcByField(CurrPriceFieldNo: Integer)` was already +called for that same field number. Calling `UpdateUnitPriceByField` on +its own, without a matching `PlanPriceCalcByField` call first, compiles +fine and looks correct, but silently recalculates nothing. `Sales Line` +also exposes `procedure UpdateUnitPrice(CalledByFieldNo: Integer)`, a +convenience wrapper that does both steps in the right order (plan, then +update) in one call — this is the method the base app itself calls from +*outside* `Sales Line` to trigger recalculation for a field it just +changed (see `Inventory/Item/Catalog/ItemReferenceManagement.Codeunit.al`: +`SalesLine.UpdateUnitPrice(SalesLine.FieldNo("Item Reference No."))`), and +it's what a custom price source field's own trigger should call too — the +same way Microsoft's own Location example is wired from a `Sales Line` +validation event, not from the price source registration itself. Add the source without wiring recalculation, and the failure hides easily: a *new* line still prices correctly, because the field already @@ -40,17 +52,22 @@ Wire both halves together whenever a field becomes a price source: an `OnAfterAddSources` subscriber that adds it via `PriceSourceList.Add`, and a trigger on the field itself (its own `OnValidate`, or a matching `OnAfterValidate` integration event) that calls -`SalesLine.UpdateUnitPriceByField(SalesLine.FieldNo())`. +`SalesLine.UpdateUnitPrice(SalesLine.FieldNo())`. Calling +`UpdateUnitPriceByField` directly, without first calling +`PlanPriceCalcByField` for that same field number, is *not* equivalent — +it exits immediately and recalculates nothing. `UpdateUnitPrice` does +both calls, in the correct order, in one step. See sample: [`new-price-source-must-add-candidate-and-trigger-recalculation.good.al`](new-price-source-must-add-candidate-and-trigger-recalculation.good.al). ## Anti Pattern Subscribing to `OnAfterAddSources` to register a custom field as a price -source, without also triggering recalculation from that field's own -validation. The field is a genuine, working calculation candidate — new -lines price correctly — but editing the field on an existing line leaves -the unit price stale, with nothing to indicate why. +source, without also triggering recalculation (via `UpdateUnitPrice`, or +the `PlanPriceCalcByField` + `UpdateUnitPriceByField` pair) from that +field's own validation. The field is a genuine, working calculation +candidate — new lines price correctly — but editing the field on an +existing line leaves the unit price stale, with nothing to indicate why. See sample: [`new-price-source-must-add-candidate-and-trigger-recalculation.bad.al`](new-price-source-must-add-candidate-and-trigger-recalculation.bad.al). @@ -62,7 +79,13 @@ SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit "Price Source List")`); `Pricing/Source/PriceSourceList.Codeunit.al` (`procedure Add(SourceType: Enum "Price Source Type"; SourceNo: Code[20])`); `Sales/Document/SalesLine.Table.al` (`procedure -UpdateUnitPriceByField(CalledByFieldNo: Integer)`). +PlanPriceCalcByField(CurrPriceFieldNo: Integer)`; `procedure +UpdateUnitPrice(CalledByFieldNo: Integer)`; `procedure +UpdateUnitPriceByField(CalledByFieldNo: Integer)`, which exits immediately +unless `FieldCausedPriceCalculation` already equals `CalledByFieldNo` — +the state `PlanPriceCalcByField` sets). External, idiomatic use of the +one-call form: `Inventory/Item/Catalog/ItemReferenceManagement.Codeunit.al` +(`SalesLine.UpdateUnitPrice(SalesLine.FieldNo("Item Reference No."))`). Microsoft Learn, "Extending Price Calculations" (Location example): "To recalculate the price, we can subscribe to events that pass the sales diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al index 1aae8d4b..0de8b58e 100644 --- a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al @@ -14,11 +14,23 @@ report 50110 "Sample Item Barcode Label" trigger OnAfterGetRecord() begin // WRONG: hand-rolled "encoding" instead of the Barcode - // module's provider/encoder API. This produces a string - // that looks like a Code 39 barcode (asterisk delimiters) - // but carries none of the platform's actual character-set - // or checksum handling - wrong regardless of which font - // is applied to it in the layout. + // module's provider/encoder API. This is not wrong merely + // because the delimiter was added by hand - Code 39's own + // symbology does use "*" as its start/stop character + // (Microsoft Learn, "Barcode Fonts with Business Central + // Online"). It's wrong because it's demonstrably mismatched + // with what encoding "No." through the real API would + // produce: + // - it skips ValidateInput, so a "No." value outside Code + // 39's character set, or one that needs a checksum this + // code never applies, reaches the font unvalidated; + // - IDAutomation 1D Provider's own EncodeFont output for + // Code 39 wraps the value in "(" / ")", not literal "*" + // (BCApps' own encoder test: EncodeFont('1234', Code39) + // = '(1234)') - the paired font maps those parentheses to + // the real start/stop glyph, so a string built with + // literal asterisks is simply the wrong characters for + // that font, on top of carrying no real checksum. BarcodeText := '*' + "No." + '*'; end; } diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al index cc3dcc60..8364ad89 100644 --- a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al @@ -9,32 +9,46 @@ report 50110 "Sample Item Barcode Label" dataitem(Item; Item) { column(No_; "No.") { } - column(Barcode; BarcodeText) { } + column(Barcode1D; BarcodeText) { } + column(Barcode2D; QRCodeText) { } trigger OnAfterGetRecord() var BarcodeFontProvider: Interface "Barcode Font Provider"; + BarcodeFontProvider2D: Interface "Barcode Font Provider 2D"; begin + // One-dimensional: "Barcode Font Provider" declares both + // ValidateInput and EncodeFont - call both. BarcodeFontProvider := Enum::"Barcode Font Provider"::IDAutomation1D; BarcodeFontProvider.ValidateInput("No.", BarcodeSymbology); BarcodeText := BarcodeFontProvider.EncodeFont("No.", BarcodeSymbology); + + // Two-dimensional: "Barcode Font Provider 2D" declares only + // EncodeFont - there is no ValidateInput to call here. + BarcodeFontProvider2D := Enum::"Barcode Font Provider 2D"::IDAutomation2D; + QRCodeText := BarcodeFontProvider2D.EncodeFont("No.", BarcodeSymbology2D); end; } } var BarcodeSymbology: Enum "Barcode Symbology"; + BarcodeSymbology2D: Enum "Barcode Symbology 2D"; BarcodeText: Text; + QRCodeText: Text; trigger OnInitReport() begin BarcodeSymbology := Enum::"Barcode Symbology"::Code39; + BarcodeSymbology2D := Enum::"Barcode Symbology 2D"::"QR-Code"; end; // Layout requirement (can't be enforced in AL, so it's stated here): - // the Barcode column's text box must use the real, purchased font + // the Barcode1D column's text box must use the real, purchased font // name - IDAutomationHC39M for Code 39 - never an evaluation name // like "IDAutomationSHC39M Demo". Per Microsoft Learn, using the // evaluation name in a Business Central online production - // environment means "the barcode won't render" at all. + // environment means "the barcode won't render" at all. The + // Barcode2D column's font name is IDAutomation2D (IDAutomation2D + // MaxiCode for Maxicode specifically). } diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md index 1a7ecf5d..892c2f12 100644 --- a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md @@ -1,7 +1,7 @@ --- bc-version: [all] domain: data-modeling -keywords: [barcode, qr-code, barcode-font-provider, report-layout, saas, idautomation] +keywords: [barcode, qr-code, barcode-font-provider, barcode-font-provider-2d, report-layout, saas, idautomation, code-39, checksum] technologies: [al] countries: [w1] application-area: [all] @@ -12,85 +12,89 @@ application-area: [all] ## Description Business Central's barcode support lives in the System Application's -`Barcode` module (`src/System Application/App/Barcode`), not in a -project's own code: `interface "Barcode Font Provider"` / -`"Barcode Font Provider 2D"`, `enum "Barcode Symbology"` / -`"Barcode Symbology 2D"` (Code39, Code128, EAN-13, QR-Code, Data Matrix, -and more), and built-in implementations -(`codeunit 9215 "IDAutomation 1D Provider"`, -`codeunit 9221 "IDAutomation 2D Provider"`). A report encodes a data -string into a barcode string via this API; the layout then displays that -string using a barcode *font*. - -On Business Central online, this is available with no setup at all: -"With Business Central online, the IDAutomation fonts are automatically -available as part of the service. So you can start adding barcodes to -reports right away." (Microsoft Learn, "Adding Barcodes to Reports") — -unlike on-premises, where the fonts must be purchased and installed on -the server. - -That ease hides a SaaS-specific trap in the one manual step the API -doesn't cover: naming the actual font in the report layout. IDAutomation -ships both a purchased font and a same-looking evaluation font per -version (Code 39: `IDAutomationHC39M` purchased vs. -`IDAutomationSHC39M Demo` evaluation). Per Microsoft Learn ("Barcode -Fonts with Business Central Online"): "When you're applying barcode font -in the report layout for a Business Central online production -environment, be sure to use the purchased font name; not the evaluation -font name. If you use the evaluation font name, the barcode won't -render." Getting the font name wrong doesn't distort the barcode — it -produces nothing, in a step that lives in the layout file, not in AL, so -no compiler or reviewer catches it by reading the report object. Nothing -in the cited documentation says what an evaluation font name does outside -a production environment — the claim here is scoped exactly as -Microsoft states it: wrong in production, full stop. +`Barcode` module (`src/System Application/App/Barcode`): `interface +"Barcode Font Provider"` / `"Barcode Font Provider 2D"`, `enum "Barcode +Symbology"` / `"Barcode Symbology 2D"`, and built-in implementations +(`codeunit 9215`/`9221`). A report encodes a data string via this API; +the layout then displays it using a barcode *font*. + +The two interfaces are not symmetric: `"Barcode Font Provider"` (1D) +declares both `ValidateInput` and `EncodeFont`; `"Barcode Font Provider +2D"` declares only `EncodeFont` (see Source). BCApps' `Item GTIN Label` +report reflects that split exactly — it validates then encodes through +the 1D provider, but only encodes through the 2D provider, for the same +"No." value. + +On Business Central online this needs no setup ("the IDAutomation fonts +are automatically available as part of the service" — Microsoft Learn), +unlike on-premises, where fonts must be purchased and installed. That +ease hides a SaaS-specific trap the API doesn't cover: naming the actual +font. IDAutomation ships both a purchased font and a same-looking +evaluation font per version (Code 39: `IDAutomationHC39M` purchased vs. +`IDAutomationSHC39M Demo`) — per Microsoft Learn, "be sure to use the +purchased font name... If you use the evaluation font name, the barcode +won't render." The wrong name produces nothing, in the layout not AL, so +no reviewer catches it reading the object. ## Best Practice -Encode through the real API — declare the provider via its interface and -enum, then call `ValidateInput`/`EncodeFont` — and treat naming the -production font in the layout as an equally required part of the same -task, not an afterthought left to whoever happens to touch the `.docx`/ -`.rdl` file. For a two-dimensional symbology other than Maxicode, the -font name to specify is literally `IDAutomation2D` (Maxicode itself uses -`IDAutomation2D MaxiCode`); for a one-dimensional symbology, use the -purchased version name for that specific font (e.g. `IDAutomationHC39M` -for Code 39), never a name containing `Demo`. +Encode through the real API, matching the calls to what the chosen +interface actually declares. One-dimensional: declare `Interface +"Barcode Font Provider"` and call both `ValidateInput` and `EncodeFont` +— skipping validation lets a value outside the character set, or one +needing a checksum setting never applied, reach the font unchecked. +Two-dimensional: declare `Interface "Barcode Font Provider 2D"` and call +`EncodeFont` alone — there is no `ValidateInput` on this interface. + +Treat naming the production font in the layout as equally required, not +an afterthought. Two-dimensional symbologies other than Maxicode use +`IDAutomation2D` (Maxicode: `IDAutomation2D MaxiCode`); one-dimensional +symbologies use the purchased version name (e.g. `IDAutomationHC39M` for +Code 39), never a name containing `Demo`. See sample: [`report-barcodes-must-use-barcode-module-and-production-font-name.good.al`](report-barcodes-must-use-barcode-module-and-production-font-name.good.al). ## Anti Pattern -Constructing a barcode string by hand — string concatenation, manual -delimiters — instead of going through the Barcode module's provider -interface. It can look right (asterisks around a value, resembling -Code 39) while carrying none of the platform's actual character-set -handling or checksum logic, so it's wrong regardless of which font is -later applied to it. +Constructing a barcode string by hand instead of using the module's +provider/encoder API — not because a manual delimiter is inherently +wrong (Code 39's own symbology does use `*` as start/stop; Microsoft +Learn's font table says so), but because hand-rolled construction is +demonstrably mismatched with what the real encoder produces: it skips +`ValidateInput` (so a value outside the character set, or needing a +checksum/extended-charset setting never applied, reaches the font +unvalidated), and IDAutomation 1D Provider's own Code 39 output is +wrapped in `(`/`)`, not literal `*` (BCApps test: +`EncodeFont('1234', Code39) = '(1234)'`) — the paired font maps those +parentheses to the real start/stop glyph, so `'*' + value + '*'` is +simply the wrong characters, plus no checksum. -A second version of the same underlying mistake: encoding correctly -through the real API, but naming the evaluation font instead of the -purchased one in the layout. Both produce a report that looks complete -in review and testing and fails silently — the first because the encoded -data was never a real barcode, the second because Business Central -online refuses to render it at all. +Flag demonstrably invalid or mismatched hand construction, not manual +delimiter use as a category — a custom provider paired with a font that +genuinely expects literal `*` delimiters is a different, legitimate case. + +A second version of the same mistake: encoding correctly, but naming the +evaluation font instead of the purchased one. Both look complete in +review and fail silently — the first because the data was never a real +barcode, the second because BC online refuses to render it. See sample: [`report-barcodes-must-use-barcode-module-and-production-font-name.bad.al`](report-barcodes-must-use-barcode-module-and-production-font-name.bad.al). ## Source -BCApps System Application (`src/System Application/App/Barcode/src/`): -`Barcode Provider/Font/BarcodeFontProvider.Interface.al` -(`ValidateInput(InputText: Text; BarcodeSymbology: Enum "Barcode Symbology")`, -`EncodeFont(InputText: Text; BarcodeSymbology: Enum "Barcode Symbology"): Text`), -`Barcode Provider/Font/BarcodeFontProvider.Enum.al` -(`value(0; IDAutomation1D)`), `Barcode Provider/BarcodeSymbology.Enum.al` -(`value(100; Code39)`). Real BaseApp usage: -`src/Layers/W1/BaseApp/Inventory/Item/ItemGTINLabel.Report.al` -(`report 6625 "Item GTIN Label"`, lines 44-64). - -Microsoft Learn: "Adding Barcodes to Reports" -(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-add-barcodes) -and "Barcode Fonts with Business Central Online" -(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-barcode-fonts) -— both quoted verbatim above. +BCApps (`src/System Application/App/Barcode/src/`): +`Barcode Provider/Font/BarcodeFontProvider.Interface.al` (1D: +`ValidateInput` + `EncodeFont`); `Barcode Provider 2D/Font/ +BarcodeFontProvider2D.Interface.al` (2D: only `EncodeFont`); both read +fresh from source. `IDAutomation 1D Provider/Encoders/ +IDA1DCode39Encoder.Codeunit.al` (`codeunit 9204`, regex accepts literal +`*` as plain input; `EncodeFont` → `DotNet FontEncoder.Code39`). Split +and delimiter mismatch both confirmed live: `.../Inventory/Item/ +ItemGTINLabel.Report.al` (`report 6625`, validates+encodes 1D, only +encodes 2D, same value) and `.../Test/Barcode/.../IDA1DCode39Test. +Codeunit.al` (`codeunit 135044`): `EncodeFontSuccessTest('1234', Code39, +'(1234)')` — wrapped in `(`/`)`, never literal `*`. + +Microsoft Learn "Adding Barcodes to Reports" and "Barcode Fonts with +Business Central Online" — quoted above, incl. the Code39 row ("`*` is +used for both start and stop delimiters"). diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 325a706a..20dfaa1f 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -59,7 +59,7 @@ The following targeted checks cover every current `data-modeling` article. Treat - 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`, without subscribing to the matching *single* counterparty's full triad — the filter event (`OnAfterFilterCustomerUsageReportSelections` on `page 9657` for a sales usage, `OnAfterFilterVendorUsageReportSelections` on `page 9658` for a purchase usage) AND the page-facing usage-enum map/validate events (`enumextension` on `"Custom Report Selection Sales"`/`"Report Selection Usage Vendor"` plus the matching map/validate subscribers) — `extend-report-selection-usage-for-new-document-types`. Requiring or wiring *both* counterparties by default for a one-sided document is also the anti-pattern (`ReportSelectionHandlerCZZ` partitions strictly by counterparty); only a genuinely two-sided usage (as `ReportSelectionHandlerCZC` demonstrates for Compensation) needs both. - 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. -- An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Type`/`Asset Type`, with `Method` and `Default := true` also set — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. +- An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Method`/`Type`/`Asset Type` — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. `Default := true` is only required on that row when it is meant as the fallback for its `Method`/`Type`/`Asset Type` combination; a row meant to be selected only through an explicit, specific `"Dtld. Price Calculation Setup"` row does not need it, so do not flag a missing `Default := true` by itself — flag the missing setup row/subscriber entirely. - An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`. - A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that calls `SalesLine.UpdateUnitPriceByField` to recalculate — `new-price-source-must-add-candidate-and-trigger-recalculation`. - A report builds a barcode string by manual concatenation/delimiters instead of the Barcode module's `"Barcode Font Provider"`/`"Barcode Font Provider 2D"` interface (`ValidateInput`/`EncodeFont`), or an otherwise correctly encoded barcode's report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. From e246b1942a7ad8960a21487f41d2d9f58c8f3d43 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Mon, 28 Sep 2026 07:40:16 +0200 Subject: [PATCH 4/7] Align price-source and barcode routing cues with corrected articles - Price-source cue now accepts UpdateUnitPrice, or the explicit PlanPriceCalcByField + UpdateUnitPriceByField sequence; bare UpdateUnitPriceByField does not count. Both APIs added to tokens. - Barcode cue no longer flags manual delimiters as a category; routes only demonstrably invalid/provider-font-mismatched hand encoding, and requires ValidateInput + EncodeFont for 1D, EncodeFont only for 2D. Co-Authored-By: Claude Opus 5.5 --- microsoft/skills/review/al-data-modeling-review.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index 20dfaa1f..e263500d 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review - The changed AL object names and types — especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, document or journal lines that reference a master, document pages/codeunits exposing print/email/Post-and-Send actions, codeunits subscribing to `Navigate`, enumextensions to `"Report Selection Usage"`/`"Price Calculation Handler"`/`"Price Source Type"`, and report objects that render barcodes. - The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, posting validation, and posting-cascade `TransferFields` calls. -- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPriceByField`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`). +- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -61,8 +61,8 @@ The following targeted checks cover every current `data-modeling` article. Treat - 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. - An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Method`/`Type`/`Asset Type` — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. `Default := true` is only required on that row when it is meant as the fallback for its `Method`/`Type`/`Asset Type` combination; a row meant to be selected only through an explicit, specific `"Dtld. Price Calculation Setup"` row does not need it, so do not flag a missing `Default := true` by itself — flag the missing setup row/subscriber entirely. - An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`. -- A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that calls `SalesLine.UpdateUnitPriceByField` to recalculate — `new-price-source-must-add-candidate-and-trigger-recalculation`. -- A report builds a barcode string by manual concatenation/delimiters instead of the Barcode module's `"Barcode Font Provider"`/`"Barcode Font Provider 2D"` interface (`ValidateInput`/`EncodeFont`), or an otherwise correctly encoded barcode's report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. +- A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that triggers recalculation — either `SalesLine.UpdateUnitPrice()`, or the explicit `SalesLine.PlanPriceCalcByField()` followed by `SalesLine.UpdateUnitPriceByField()`. A bare `UpdateUnitPriceByField` without a preceding `PlanPriceCalcByField` for the same field number does not count as recalculation (it exits without recalculating) — `new-price-source-must-add-candidate-and-trigger-recalculation`. +- A report hand-constructs a barcode string that is demonstrably invalid or mismatched with the font provider it is rendered with (e.g. `'*' + Value + '*'` rendered with the IDAutomation Code 39 font, whose paired IDAutomation 1D provider encoder emits `(`/`)` wrapping, not literal `*` — with no validation or checksum applied) instead of encoding through the Barcode module. Do not flag manual delimiter use as a category — a custom provider paired with a font that genuinely expects those literal delimiters is not a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. 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`. From 4cd41f08f7618ffa61bc57f1eec7ef1168e849dc Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:02:08 +0200 Subject: [PATCH 5/7] Make barcode bad fixture self-contained: 1D EncodeFont without ValidateInput The previous bad fixture (literal '*' delimiters, no layout/font/provider evidence) no longer matched the narrowed routing cue. It now shows an IDAutomation 1D provider path that calls EncodeFont without ValidateInput, which is visible in AL alone. Article Anti Pattern and Source updated to describe this variant (verified: IDAutomation 1D Provider's EncodeFont does not call IsValidInput). Co-Authored-By: Claude Opus 5.5 --- ...ode-module-and-production-font-name.bad.al | 38 +++++++++---------- ...barcode-module-and-production-font-name.md | 11 +++++- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al index 0de8b58e..a60692b8 100644 --- a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al @@ -12,30 +12,30 @@ report 50110 "Sample Item Barcode Label" column(Barcode; BarcodeText) { } trigger OnAfterGetRecord() + var + BarcodeFontProvider: Interface "Barcode Font Provider"; begin - // WRONG: hand-rolled "encoding" instead of the Barcode - // module's provider/encoder API. This is not wrong merely - // because the delimiter was added by hand - Code 39's own - // symbology does use "*" as its start/stop character - // (Microsoft Learn, "Barcode Fonts with Business Central - // Online"). It's wrong because it's demonstrably mismatched - // with what encoding "No." through the real API would - // produce: - // - it skips ValidateInput, so a "No." value outside Code - // 39's character set, or one that needs a checksum this - // code never applies, reaches the font unvalidated; - // - IDAutomation 1D Provider's own EncodeFont output for - // Code 39 wraps the value in "(" / ")", not literal "*" - // (BCApps' own encoder test: EncodeFont('1234', Code39) - // = '(1234)') - the paired font maps those parentheses to - // the real start/stop glyph, so a string built with - // literal asterisks is simply the wrong characters for - // that font, on top of carrying no real checksum. - BarcodeText := '*' + "No." + '*'; + // WRONG: a one-dimensional IDAutomation provider path that + // calls EncodeFont without ValidateInput. "Barcode Font + // Provider" (1D) declares both, and IDAutomation 1D + // Provider's EncodeFont does not validate on its own - it + // hands the text straight to the font encoder. Code 39 + // accepts only 0-9, A-Z, space and - . $ / + % *, but an + // Item "No." can legally contain characters outside that + // set (e.g. "_" or "#"). Such a value is never rejected; + // it silently reaches the font as an unscannable barcode. + BarcodeFontProvider := Enum::"Barcode Font Provider"::IDAutomation1D; + BarcodeText := BarcodeFontProvider.EncodeFont("No.", BarcodeSymbology); end; } } var + BarcodeSymbology: Enum "Barcode Symbology"; BarcodeText: Text; + + trigger OnInitReport() + begin + BarcodeSymbology := Enum::"Barcode Symbology"::Code39; + end; } diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md index 892c2f12..1d21211a 100644 --- a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md @@ -78,13 +78,22 @@ evaluation font instead of the purchased one. Both look complete in review and fail silently — the first because the data was never a real barcode, the second because BC online refuses to render it. +The same gap exists even when the module *is* used: a 1D path that calls +`EncodeFont` on `"Barcode Font Provider"` without `ValidateInput`. +IDAutomation 1D Provider's `EncodeFont` does not validate on its own, so +a value outside the symbology's character set is never rejected — it +reaches the font as an unscannable barcode. The sample shows this +variant, because it is visible in AL alone without layout evidence. + See sample: [`report-barcodes-must-use-barcode-module-and-production-font-name.bad.al`](report-barcodes-must-use-barcode-module-and-production-font-name.bad.al). ## Source BCApps (`src/System Application/App/Barcode/src/`): `Barcode Provider/Font/BarcodeFontProvider.Interface.al` (1D: -`ValidateInput` + `EncodeFont`); `Barcode Provider 2D/Font/ +`ValidateInput` + `EncodeFont`); `IDAutomation 1D Provider/ +IDAutomation1DProvider.Codeunit.al` (`EncodeFont` goes straight to the +symbology encoder; only `ValidateInput` calls `IsValidInput`); `Barcode Provider 2D/Font/ BarcodeFontProvider2D.Interface.al` (2D: only `EncodeFont`); both read fresh from source. `IDAutomation 1D Provider/Encoders/ IDA1DCode39Encoder.Codeunit.al` (`codeunit 9204`, regex accepts literal From aad3991d9fe37f37856e169cf653e259f4d63487 Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:45:02 +0200 Subject: [PATCH 6/7] Fix three merge-critical items from Jesper's 2026-09-29 review - Barcode: drop the false claim that '*value*' is mismatched with the IDAutomation Code 39 font; '*' is a documented start/stop form and '(' / ')' an accepted alternative. Cue and article now route only independently provable validation/checksum/font-binding defects. - Dispatch good samples (and matching bad samples) now pass a Sales Invoice Header with the S.Invoice usage, matching the record the selected report (1306 "Standard Sales - Invoice") expects. - custom-document-dispatch rule made disjunctive: a hardcoded report or a hand-built email is each a bypass on its own; scoped to customer/vendor-facing documents. Bad fixture shows the hardcoded report alone. Co-Authored-By: Claude Opus 5.5 --- ...h-must-not-bypass-report-selections.bad.al | 36 ++++------ ...-must-not-bypass-report-selections.good.al | 25 ++++--- ...patch-must-not-bypass-report-selections.md | 17 +++-- ...ons-call-report-selections-directly.bad.al | 16 +++-- ...ns-call-report-selections-directly.good.al | 21 ++++-- ...barcode-module-and-production-font-name.md | 68 ++++++++----------- .../skills/review/al-data-modeling-review.md | 4 +- 7 files changed, 100 insertions(+), 87 deletions(-) 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 index ff560e90..fbff442d 100644 --- 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 @@ -1,28 +1,20 @@ -report 50102 "Sample Settlement Doc Bad" +codeunit 50102 "Sample Posted Invoice Send" { - UsageCategory = ReportsAndAnalysis; - ApplicationArea = All; - - dataset - { - dataitem(Customer; Customer) - { - column(No_Customer; "No.") { } - } - } -} - -codeunit 50102 "Sample Settlement Document Send" -{ - procedure SendSettlementDocument(var Customer: Record Customer) + procedure SendPostedInvoice(SalesInvoiceHeader: Record "Sales Invoice Header") + var + Customer: Record Customer; begin + Customer.Get(SalesInvoiceHeader."Bill-to Customer No."); 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); + // WRONG: the report is hardcoded instead of resolved through the + // registered "S.Invoice" usage in Report Selections. This alone is + // the defect - no hand-built email is needed for it: a Report + // Selections row or a per-customer "Document Layouts" override + // that points this usage at a different report or layout is + // silently ignored, and the only way to change what this code + // prints is a code change and a new release. + SalesInvoiceHeader.SetRecFilter(); + Report.RunModal(Report::"Standard Sales - Invoice", false, false, SalesInvoiceHeader); 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 index bb210f25..f19b00a8 100644 --- 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 @@ -1,22 +1,31 @@ -codeunit 50102 "Sample Settlement Document Send" +codeunit 50102 "Sample Posted Invoice Send" { - procedure SendSettlementDocument(var Customer: Record Customer) + procedure SendPostedInvoice(SalesInvoiceHeader: Record "Sales Invoice Header") var ReportSelections: Record "Report Selections"; + ReportDistributionMgt: Codeunit "Report Distribution Management"; begin - // Custom validation specific to this document stays here... - CheckReadyToSend(Customer); + // Custom validation specific to this dispatch stays here... + CheckReadyToSend(SalesInvoiceHeader); - // ...but dispatch goes through the registered usage, so per-account + // ...but dispatch goes through the registered usage. "S.Invoice" + // resolves to a report built on "Sales Invoice Header" (by default + // report 1306 "Standard Sales - Invoice"), so the record passed in + // matches what the selected report expects, and per-account // report/layout overrides and email attachment/body configuration // on Report Selections all apply automatically. + SalesInvoiceHeader.SetRecFilter(); ReportSelections.SendEmailToCust( - "Report Selection Usage"::"S.Invoice".AsInteger(), Customer, Customer."No.", - Customer.Name, true, Customer."No."); + "Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, SalesInvoiceHeader."No.", + ReportDistributionMgt.GetFullDocumentTypeText(SalesInvoiceHeader), true, + SalesInvoiceHeader."Bill-to Customer No."); end; - local procedure CheckReadyToSend(var Customer: Record Customer) + local procedure CheckReadyToSend(SalesInvoiceHeader: Record "Sales Invoice Header") + var + Customer: Record Customer; begin + Customer.Get(SalesInvoiceHeader."Bill-to Customer No."); 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 index 96a8208f..f6b15f99 100644 --- 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 @@ -11,11 +11,15 @@ application-area: [all] ## Description -A codeunit that hardcodes which report to run (`Report.RunModal(MyReportId, ...)`) -and builds its own email directly, instead of registering the document +A codeunit that hardcodes which report to run (`Report.RunModal(MyReportId, ...)`), +or 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 +loses everything the platform's registry provides for free. Either +bypass is a defect on its own: a hardcoded report ignores the registered +report and any per-account layout override even when no email is +involved, and a hand-built email ignores the registry's attachment and +email-body configuration even when the report itself came from it. `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"`), plus a separate per-usage layout @@ -44,9 +48,10 @@ 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 +A codeunit that runs a hardcoded report ID, or builds its own email +message directly, for a document that has (or should have) a +`Report Selections` usage — each is independently a bypass, and the +sample shows the first on its own. 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. 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 index 49c5d45f..a8799c0d 100644 --- 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 @@ -1,8 +1,9 @@ -page 50101 "Sample Settlement Document Card" +page 50101 "Sample Posted Invoice Card" { PageType = Card; - SourceTable = Customer; + SourceTable = "Sales Invoice Header"; ApplicationArea = All; + Editable = false; actions { @@ -16,7 +17,9 @@ page 50101 "Sample Settlement Document Card" trigger OnAction() var + SalesInvoiceHeader: Record "Sales Invoice Header"; DocumentSendingProfile: Record "Document Sending Profile"; + ReportDistributionMgt: Codeunit "Report Distribution Management"; begin // WRONG: this is a plain, on-demand "Email" button, not // part of a combined Post-and-Send action - but this @@ -28,10 +31,13 @@ page 50101 "Sample Settlement Document Card" // Printer = Yes, "E-Mail" = No) turns this button into a // silent no-op, with no indication an unrelated setup // field is why. - DocumentSendingProfile.GetDefaultForCustomer(Rec."No.", DocumentSendingProfile); + SalesInvoiceHeader := Rec; + CurrPage.SetSelectionFilter(SalesInvoiceHeader); + DocumentSendingProfile.GetDefaultForCustomer(Rec."Bill-to Customer No.", DocumentSendingProfile); DocumentSendingProfile.Send( - "Report Selection Usage"::"S.Invoice".AsInteger(), Rec, Rec."No.", Rec."No.", - Rec.Name, Rec.FieldNo("No."), Rec.FieldNo("No.")); + "Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, Rec."No.", + Rec."Bill-to Customer No.", ReportDistributionMgt.GetFullDocumentTypeText(Rec), + SalesInvoiceHeader.FieldNo("Bill-to Customer No."), SalesInvoiceHeader.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 index 533057c3..737e09b4 100644 --- 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 @@ -1,8 +1,9 @@ -page 50101 "Sample Settlement Document Card" +page 50101 "Sample Posted Invoice Card" { PageType = Card; - SourceTable = Customer; + SourceTable = "Sales Invoice Header"; ApplicationArea = All; + Editable = false; actions { @@ -16,7 +17,9 @@ page 50101 "Sample Settlement Document Card" trigger OnAction() var + SalesInvoiceHeader: Record "Sales Invoice Header"; ReportSelections: Record "Report Selections"; + ReportDistributionMgt: Codeunit "Report Distribution Management"; begin // Calls Report Selections directly - the button's outcome // depends only on this customer's registered report/layout, @@ -24,9 +27,13 @@ page 50101 "Sample Settlement Document Card" // DocumentSendingProfile.TrySendToEMail(...) instead would // be equally correct: it never Get's the customer's // actually assigned profile, only a local, hardcoded one. + // "S.Invoice" resolves to a report on "Sales Invoice + // Header", which is the record passed here. + SalesInvoiceHeader := Rec; + CurrPage.SetSelectionFilter(SalesInvoiceHeader); ReportSelections.SendEmailToCust( - "Report Selection Usage"::"S.Invoice".AsInteger(), Rec, Rec."No.", - Rec.Name, true, Rec."No."); + "Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, Rec."No.", + ReportDistributionMgt.GetFullDocumentTypeText(Rec), true, Rec."Bill-to Customer No."); end; } action(PrintDocument) @@ -37,10 +44,14 @@ page 50101 "Sample Settlement Document Card" trigger OnAction() var + SalesInvoiceHeader: Record "Sales Invoice Header"; ReportSelections: Record "Report Selections"; begin + SalesInvoiceHeader := Rec; + CurrPage.SetSelectionFilter(SalesInvoiceHeader); ReportSelections.PrintWithDialogForCust( - "Report Selection Usage"::"S.Invoice", Rec, true, Rec.FieldNo("No.")); + "Report Selection Usage"::"S.Invoice", SalesInvoiceHeader, true, + SalesInvoiceHeader.FieldNo("Bill-to Customer No.")); end; } } diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md index 1d21211a..e85d7e1d 100644 --- a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md @@ -56,34 +56,26 @@ See sample: [`report-barcodes-must-use-barcode-module-and-production-font-name.g ## Anti Pattern -Constructing a barcode string by hand instead of using the module's -provider/encoder API — not because a manual delimiter is inherently -wrong (Code 39's own symbology does use `*` as start/stop; Microsoft -Learn's font table says so), but because hand-rolled construction is -demonstrably mismatched with what the real encoder produces: it skips -`ValidateInput` (so a value outside the character set, or needing a -checksum/extended-charset setting never applied, reaches the font -unvalidated), and IDAutomation 1D Provider's own Code 39 output is -wrapped in `(`/`)`, not literal `*` (BCApps test: -`EncodeFont('1234', Code39) = '(1234)'`) — the paired font maps those -parentheses to the real start/stop glyph, so `'*' + value + '*'` is -simply the wrong characters, plus no checksum. - -Flag demonstrably invalid or mismatched hand construction, not manual -delimiter use as a category — a custom provider paired with a font that -genuinely expects literal `*` delimiters is a different, legitimate case. - -A second version of the same mistake: encoding correctly, but naming the -evaluation font instead of the purchased one. Both look complete in -review and fail silently — the first because the data was never a real -barcode, the second because BC online refuses to render it. - -The same gap exists even when the module *is* used: a 1D path that calls -`EncodeFont` on `"Barcode Font Provider"` without `ValidateInput`. -IDAutomation 1D Provider's `EncodeFont` does not validate on its own, so -a value outside the symbology's character set is never rejected — it -reaches the font as an unscannable barcode. The sample shows this -variant, because it is visible in AL alone without layout evidence. +Constructing a barcode string by hand where that construction has a +concrete, independently provable defect: a source value that can contain +characters outside the symbology's character set is never validated, a +checksum the symbology or setup requires is never applied, or there is +concrete evidence of an incompatible font binding. + +The delimiter itself is not the defect. `*value*` is a documented, valid +Code 39 form for IDAutomation fonts (Microsoft Learn's font table and +IDAutomation's own manual both give `*` as start/stop); the `(`/`)` that +IDAutomation 1D Provider's encoder emits (BCApps test: +`EncodeFont('1234', Code39) = '(1234)'`) is an alternative start/stop +form the same fonts accept, used to keep `*` out of the human-readable +text. Never flag delimiter choice alone. + +The same validation gap exists when the module *is* used: a 1D path that +calls `EncodeFont` on `"Barcode Font Provider"` without `ValidateInput` +(IDAutomation 1D Provider's `EncodeFont` does not validate on its own). +The sample shows this variant, visible in AL alone. A last version: +encoding correctly but naming the evaluation font, which BC online +refuses to render. See sample: [`report-barcodes-must-use-barcode-module-and-production-font-name.bad.al`](report-barcodes-must-use-barcode-module-and-production-font-name.bad.al). @@ -93,17 +85,15 @@ BCApps (`src/System Application/App/Barcode/src/`): `Barcode Provider/Font/BarcodeFontProvider.Interface.al` (1D: `ValidateInput` + `EncodeFont`); `IDAutomation 1D Provider/ IDAutomation1DProvider.Codeunit.al` (`EncodeFont` goes straight to the -symbology encoder; only `ValidateInput` calls `IsValidInput`); `Barcode Provider 2D/Font/ -BarcodeFontProvider2D.Interface.al` (2D: only `EncodeFont`); both read -fresh from source. `IDAutomation 1D Provider/Encoders/ -IDA1DCode39Encoder.Codeunit.al` (`codeunit 9204`, regex accepts literal -`*` as plain input; `EncodeFont` → `DotNet FontEncoder.Code39`). Split -and delimiter mismatch both confirmed live: `.../Inventory/Item/ -ItemGTINLabel.Report.al` (`report 6625`, validates+encodes 1D, only -encodes 2D, same value) and `.../Test/Barcode/.../IDA1DCode39Test. -Codeunit.al` (`codeunit 135044`): `EncodeFontSuccessTest('1234', Code39, -'(1234)')` — wrapped in `(`/`)`, never literal `*`. +symbology encoder; only `ValidateInput` calls `IsValidInput`); `Barcode Provider 2D/Font/BarcodeFontProvider2D.Interface.al` +(2D: only `EncodeFont`). `IDAutomation 1D Provider/Encoders/IDA1DCode39Encoder.Codeunit.al` +(`codeunit 9204`, regex accepts literal `*`; `EncodeFont` → `DotNet FontEncoder.Code39`). +1D/2D split: `.../Inventory/Item/ItemGTINLabel.Report.al` (`report 6625`, +validates+encodes 1D, only encodes 2D). Encoder output form: `IDA1DCode39Test.Codeunit.al` +(`codeunit 135044`): `EncodeFontSuccessTest('1234', Code39, '(1234)')`. Microsoft Learn "Adding Barcodes to Reports" and "Barcode Fonts with Business Central Online" — quoted above, incl. the Code39 row ("`*` is -used for both start and stop delimiters"). +used for both start and stop delimiters"). IDAutomation, "Code 39 Font +User Manual" (https://idautomation.com/barcode-fonts/code-39/fontnames/): +`*` start/stop, or parentheses to keep `*` out of the human-readable text. diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index e263500d..3ecbd74b 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -54,7 +54,7 @@ The following targeted checks cover every current `data-modeling` article. Treat - 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 custom document header assigns defaults outside an `InitRecord` boundary, calls `InitRecord` before assigning its number, or places UI-independent defaults only in a page trigger — `initialize-document-defaults-in-initrecord`. - Directed `Round` calls use `'<'` as mathematical floor or `'>'` as mathematical ceiling, especially where negative amounts are possible — `round-direction-symbols-use-magnitude`. -- 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 codeunit dispatches a document by calling `Report.Run`/`Report.RunModal` with a hardcoded report ID, or by building its own email directly, instead of going through the `Report Selections` usage for that document — `custom-document-dispatch-must-not-bypass-report-selections`. Either bypass is a finding on its own; both need not be present. Scope this to customer/vendor-facing documents that have (or should have) a `Report Selection Usage` — a hardcoded `Report.Run` of an ordinary list/analysis report is not this anti-pattern. 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`, without subscribing to the matching *single* counterparty's full triad — the filter event (`OnAfterFilterCustomerUsageReportSelections` on `page 9657` for a sales usage, `OnAfterFilterVendorUsageReportSelections` on `page 9658` for a purchase usage) AND the page-facing usage-enum map/validate events (`enumextension` on `"Custom Report Selection Sales"`/`"Report Selection Usage Vendor"` plus the matching map/validate subscribers) — `extend-report-selection-usage-for-new-document-types`. Requiring or wiring *both* counterparties by default for a one-sided document is also the anti-pattern (`ReportSelectionHandlerCZZ` partitions strictly by counterparty); only a genuinely two-sided usage (as `ReportSelectionHandlerCZC` demonstrates for Compensation) needs both. @@ -62,7 +62,7 @@ The following targeted checks cover every current `data-modeling` article. Treat - An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Method`/`Type`/`Asset Type` — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. `Default := true` is only required on that row when it is meant as the fallback for its `Method`/`Type`/`Asset Type` combination; a row meant to be selected only through an explicit, specific `"Dtld. Price Calculation Setup"` row does not need it, so do not flag a missing `Default := true` by itself — flag the missing setup row/subscriber entirely. - An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`. - A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that triggers recalculation — either `SalesLine.UpdateUnitPrice()`, or the explicit `SalesLine.PlanPriceCalcByField()` followed by `SalesLine.UpdateUnitPriceByField()`. A bare `UpdateUnitPriceByField` without a preceding `PlanPriceCalcByField` for the same field number does not count as recalculation (it exits without recalculating) — `new-price-source-must-add-candidate-and-trigger-recalculation`. -- A report hand-constructs a barcode string that is demonstrably invalid or mismatched with the font provider it is rendered with (e.g. `'*' + Value + '*'` rendered with the IDAutomation Code 39 font, whose paired IDAutomation 1D provider encoder emits `(`/`)` wrapping, not literal `*` — with no validation or checksum applied) instead of encoding through the Barcode module. Do not flag manual delimiter use as a category — a custom provider paired with a font that genuinely expects those literal delimiters is not a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. +- A report hand-constructs a barcode string only where a concrete, independently provable defect is visible: the source value can contain characters outside the symbology's character set and is never validated, a checksum the symbology/setup requires is never applied, or there is concrete evidence of an incompatible font binding. Do not flag manual start/stop delimiters by themselves — `*value*` is a documented, valid Code 39 form for IDAutomation fonts (IDAutomation also accepts parentheses), so delimiter choice alone is never a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. 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`. From 833a002095960506ed4cb4919c2e4a20ed14dd1b Mon Sep 17 00:00:00 2001 From: Michael Dieringer <65093775+MichaelDieringer@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:53:13 +0200 Subject: [PATCH 7/7] Clarify TrySendToEMail comment in print/email good sample Make explicit that TrySendToEMail is also correct *because* it never reads the customer's assigned profile (local record, E-Mail option set by the helper itself), and name Get/GetDefaultForCustomer + Send as the anti-pattern. Matches the article's Best Practice and BaseApp's own Sales Invoice Header.EmailRecords. Co-Authored-By: Claude Opus 5.5 --- ...-email-actions-call-report-selections-directly.good.al | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index 737e09b4..c68724b2 100644 --- 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 @@ -25,8 +25,12 @@ page 50101 "Sample Posted Invoice Card" // depends only on this customer's registered report/layout, // not on any Document Sending Profile setting. Calling // DocumentSendingProfile.TrySendToEMail(...) instead would - // be equally correct: it never Get's the customer's - // actually assigned profile, only a local, hardcoded one. + // also be correct, because it never reads the customer's + // assigned profile: it only uses a local record that it + // never retrieves with Get, and sets its "E-Mail" option + // itself. The + // anti-pattern is Get/GetDefaultForCustomer followed by + // Send, which makes the outcome depend on that profile. // "S.Invoice" resolves to a report on "Sales Invoice // Header", which is the record passed here. SalesInvoiceHeader := Rec;