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>
This commit is contained in:
Michael Dieringer 2026-09-24 06:33:04 +02:00
parent 83f041b662
commit 31206f6616
23 changed files with 801 additions and 156 deletions

View file

@ -2,7 +2,7 @@
The evaluation is convention-driven. The harness discovers every `<layer>/skills/review/al-<domain>-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.

View file

@ -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"
},

View file

@ -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.

View file

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

View file

@ -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)

View file

@ -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/`.

View file

@ -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."));

View file

@ -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.");

View file

@ -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

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`.
## 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)

View file

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

View file

@ -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;
[EventSubscriber(ObjectType::Page, Page::"Vendor Report Selections", 'OnAfterFilterVendorUsageReportSelections', '', false, false)]
local procedure AddSampleUsageOnAfterFilterVendorUsageReportSelections(var ReportSelections: Record "Report Selections")
// 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;

View file

@ -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.)

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
// 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;
}

View file

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

View file

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

View file

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

View file

@ -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.
}

View file

@ -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.

View file

@ -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.

View file

@ -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)