9 AL/BC patterns: document distribution, price calculation & barcode extensibility (#175)
Some checks failed
Validate knowledge index / validate-index (push) Has been cancelled
Validate AL review fixtures / validate-review-fixtures (push) Has been cancelled
Validate skill index and report schemas / validate-contract (push) Has been cancelled
Validate frontmatter and structure / validate (push) Has been cancelled

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michael Dieringer 2026-09-30 13:22:38 +02:00 • committed by GitHub
parent 164b27d0b2
commit fd59919778
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1644 additions and 6 deletions

View file

@ -0,0 +1,74 @@
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";
}
}
// 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, 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.

View file

@ -0,0 +1,90 @@
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";
}
}
// 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)]
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;
// 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;
}

View file

@ -0,0 +1,98 @@
---
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.
`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
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`. `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).
## 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`](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`
(`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": "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)

View file

@ -0,0 +1,20 @@
codeunit 50102 "Sample Posted Invoice Send"
{
procedure SendPostedInvoice(SalesInvoiceHeader: Record "Sales Invoice Header")
var
Customer: Record Customer;
begin
Customer.Get(SalesInvoiceHeader."Bill-to Customer No.");
Customer.TestField("E-Mail");
// 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;
}

View file

@ -0,0 +1,31 @@
codeunit 50102 "Sample Posted Invoice Send"
{
procedure SendPostedInvoice(SalesInvoiceHeader: Record "Sales Invoice Header")
var
ReportSelections: Record "Report Selections";
ReportDistributionMgt: Codeunit "Report Distribution Management";
begin
// Custom validation specific to this dispatch stays here...
CheckReadyToSend(SalesInvoiceHeader);
// ...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(), SalesInvoiceHeader, SalesInvoiceHeader."No.",
ReportDistributionMgt.GetFullDocumentTypeText(SalesInvoiceHeader), true,
SalesInvoiceHeader."Bill-to Customer No.");
end;
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;
}

View file

@ -0,0 +1,69 @@
---
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, ...)`),
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. 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
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
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`](custom-document-dispatch-must-not-bypass-report-selections.good.al).
## Anti Pattern
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.
See sample: [`custom-document-dispatch-must-not-bypass-report-selections.bad.al`](custom-document-dispatch-must-not-bypass-report-selections.bad.al).
## Source
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/`.

View file

@ -0,0 +1,45 @@
page 50101 "Sample Posted Invoice Card"
{
PageType = Card;
SourceTable = "Sales Invoice Header";
ApplicationArea = All;
Editable = false;
actions
{
area(Processing)
{
action(EmailDocument)
{
ApplicationArea = All;
Caption = 'Email';
Image = Email;
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
// 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.
SalesInvoiceHeader := Rec;
CurrPage.SetSelectionFilter(SalesInvoiceHeader);
DocumentSendingProfile.GetDefaultForCustomer(Rec."Bill-to Customer No.", DocumentSendingProfile);
DocumentSendingProfile.Send(
"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;
}
}
}
}

View file

@ -0,0 +1,63 @@
page 50101 "Sample Posted Invoice Card"
{
PageType = Card;
SourceTable = "Sales Invoice Header";
ApplicationArea = All;
Editable = false;
actions
{
area(Processing)
{
action(EmailDocument)
{
ApplicationArea = All;
Caption = 'Email';
Image = Email;
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,
// not on any Document Sending Profile setting. Calling
// DocumentSendingProfile.TrySendToEMail(...) instead would
// 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;
CurrPage.SetSelectionFilter(SalesInvoiceHeader);
ReportSelections.SendEmailToCust(
"Report Selection Usage"::"S.Invoice".AsInteger(), SalesInvoiceHeader, Rec."No.",
ReportDistributionMgt.GetFullDocumentTypeText(Rec), true, Rec."Bill-to Customer No.");
end;
}
action(PrintDocument)
{
ApplicationArea = All;
Caption = 'Print';
Image = Print;
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", SalesInvoiceHeader, true,
SalesInvoiceHeader.FieldNo("Bill-to Customer No."));
end;
}
}
}
}

View file

@ -0,0 +1,100 @@
---
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 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.
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.
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, either call the
relevant `Report Selections` procedure directly —
`PrintForCust`/`PrintWithDialogForCust`/`SendEmailToCust` for a
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`](document-print-and-email-actions-call-report-selections-directly.good.al).
## Anti Pattern
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`](document-print-and-email-actions-call-report-selections-directly.bad.al).
## Source
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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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.

View file

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

View file

@ -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`](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`](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)

View file

@ -0,0 +1,47 @@
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 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;
}

View file

@ -0,0 +1,87 @@
enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selection Usage"
{
value(50100; "Sample.SettlementDoc")
{
Caption = 'Sample Settlement Document';
}
}
// 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;
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"
{
// 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
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;
// 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;
local procedure GetUsageFilter(var ReportSelections: Record "Report Selections") UsageFilter: Text
begin
UsageFilter := Format("Report Selection Usage"::"Sample.SettlementDoc");
if ReportSelections.GetFilter(Usage) <> '' then
UsageFilter := StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage), UsageFilter);
end;
}

View file

@ -0,0 +1,99 @@
---
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 wire it into Document Layouts correctly
## Description
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.
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
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`](extend-report-selection-usage-for-new-document-types.good.al)
(customer-only document — only the customer-side enum and triad added).
## Anti Pattern
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`](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
`ReportSelections.Table.al` (table 77, `InsertRecord` line 344),
`ReportSelectionUsage.Enum.al` (enum 77, `Extensible = true`),
`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.)

View file

@ -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
// 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.';
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;
}

View file

@ -0,0 +1,38 @@
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(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;
}
}
}
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;
}

View file

@ -0,0 +1,97 @@
---
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, 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
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.UpdateUnitPrice(SalesLine.FieldNo(<TheField>))`. 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 (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).
## 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
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
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)

View file

@ -0,0 +1,41 @@
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
// 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;
}

View file

@ -0,0 +1,54 @@
report 50110 "Sample Item Barcode Label"
{
UsageCategory = Tasks;
ApplicationArea = All;
Caption = 'Sample Item Barcode Label';
dataset
{
dataitem(Item; Item)
{
column(No_; "No.") { }
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 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. The
// Barcode2D column's font name is IDAutomation2D (IDAutomation2D
// MaxiCode for Maxicode specifically).
}

View file

@ -0,0 +1,99 @@
---
bc-version: [all]
domain: data-modeling
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]
---
# 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`): `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, 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 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).
## Source
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`). `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"). 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.

View file

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

View file

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

View file

@ -0,0 +1,87 @@
---
bc-version: [all]
domain: data-modeling
keywords: [transferfields, field-number, posting-cascade, schema-design, custom-field]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Mirrored TransferFields cascade fields must match type and length exactly
## Description
Most custom fields genuinely belong to only one table — a status used
only before posting, a note relevant only afterwards, whatever the case
may be. That is the ordinary, unremarkable default, and it needs no
justification: `TransferFields` never touches a field that doesn't exist
on the destination. Per Microsoft's own documentation, a source field's
contents are copied "if such a field exists" on the destination with a
matching field number — a field defined on only one side of a posting
cascade is simply outside `TransferFields`' reach, not a gap to fix.
The narrower case this rule addresses is when a field **is** deliberately
mirrored across a known cascade — the same field number reused on
another table specifically so the value survives posting, for example a
field added to both `Sales Header` (36) and `Sales Invoice Header` (112),
which `SalesPost.Codeunit.al` connects via
`SalesInvHeader.TransferFields(SalesHeader)`. The two definitions have to
agree on type and, less obviously, on length. A field defined `Text[100]`
on `Sales Header` and `Text[50]` on `Sales Invoice Header` compiles
cleanly on both sides, and the `TransferFields` call runs without error
for every value up to 50 characters. Per Microsoft's documentation, a
runtime error only occurs when there isn't "room for the actual length
of the contents of the field to be copied" — so nothing fails while test
data, or early production data, stays short. The error surfaces only the
day an actual value finally exceeds the shorter definition, on a document
type that may have been posting cleanly for months.
See also `transferfields-skip-type-mismatch-can-drop-data.md`, which
covers `SkipFieldsNotMatchingType = true` silently skipping a *type*
mismatch between same-extension fields. That parameter has no effect on
length: two fields of the same type but different length still raise the
runtime error described above regardless of how `SkipFieldsNotMatchingType`
is set, which is the distinct failure mode this article addresses.
## Best Practice
When mirroring a field across a `TransferFields` cascade, define it with
the exact same field number, data type, and length on every table in
that cascade, at creation time. A field intentionally left local to one
table is unaffected by this and needs no mirroring at all — this is a
consistency requirement between definitions that are already meant to be
linked, not a mandate to check every field against every table on the
cascade.
See sample: [`transferfields-mirrored-fields-must-match-type-and-length.good.al`](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`](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.