diff --git a/evaluation/review-fixtures.json b/evaluation/review-fixtures.json index a43ec1f..5f0e50a 100644 --- a/evaluation/review-fixtures.json +++ b/evaluation/review-fixtures.json @@ -13,10 +13,19 @@ }, "data-modeling": { "articles": [ + "activate-new-price-calculation-handler-via-onfindsupportedsetup", "check-blocked-in-referencing-code-not-in-master", "code-must-not-change-workdate", + "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-price-source-type-must-sync-document-subset-enum", + "extend-report-selection-usage-for-new-document-types", + "new-price-source-must-add-candidate-and-trigger-recalculation", "pictures-must-use-media-not-blob", - "table-design-must-match-bc-table-type-conventions" + "report-barcodes-must-use-barcode-module-and-production-font-name", + "table-design-must-match-bc-table-type-conventions", + "transferfields-mirrored-fields-must-match-type-and-length" ] }, "error-handling": { diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al new file mode 100644 index 0000000..6a02378 --- /dev/null +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.bad.al @@ -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. diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al new file mode 100644 index 0000000..8f27beb --- /dev/null +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.good.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md new file mode 100644 index 0000000..3654468 --- /dev/null +++ b/microsoft/knowledge/data-modeling/activate-new-price-calculation-handler-via-onfindsupportedsetup.md @@ -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) diff --git a/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al new file mode 100644 index 0000000..fbff442 --- /dev/null +++ b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.bad.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al new file mode 100644 index 0000000..f19b00a --- /dev/null +++ b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.good.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md new file mode 100644 index 0000000..f6b15f9 --- /dev/null +++ b/microsoft/knowledge/data-modeling/custom-document-dispatch-must-not-bypass-report-selections.md @@ -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/`. diff --git a/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al new file mode 100644 index 0000000..a8799c0 --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.bad.al @@ -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; + } + } + } +} diff --git a/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al new file mode 100644 index 0000000..c68724b --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.good.al @@ -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; + } + } + } +} diff --git a/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md new file mode 100644 index 0000000..f331619 --- /dev/null +++ b/microsoft/knowledge/data-modeling/document-print-and-email-actions-call-report-selections-directly.md @@ -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 diff --git a/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al new file mode 100644 index 0000000..486eee8 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.bad.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al new file mode 100644 index 0000000..c0d658b --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.good.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md new file mode 100644 index 0000000..468756e --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-find-entries-navigate-for-new-document-types.md @@ -0,0 +1,93 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [navigate, find-entries, document-entry, integration-event, drill-down] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Extend Find Entries (Navigate) for new document or transaction tables + +## Description + +`page 344 Navigate` (caption "Find entries") lets a user enter a document +number and posting date and see, across every document and ledger entry +table BC knows about, how many matching records exist — then drill into +any of those rows. It works over a temporary `table "Document Entry"` +that gets populated, one row per source table, by dozens of separate +lookups hardcoded into the page (`Rec.InsertIntoDocEntry(Database::"Sales +Invoice Header", ...)` and similar, one per table). A new custom document +or transaction table is invisible to Find Entries by default — nobody +searching by document number will ever see it in the result list — until +it registers itself. + +Registration is a two-sided integration event, and only implementing one +side produces a page that is worse than not participating at all. The +`OnAfterFindRecords` event lets a subscriber add a row to the result list +for a custom table. But the subsequent "show records" action, `procedure +ShowRecords`, resolves which page to open through its own hardcoded `case +Rec."Table ID" of` — the same shape as the row-population code, and just +as unaware of any table added by an extension. That `case` statement has +no `else` branch. A custom table's row can appear in the result list, +with a correct count, and be entirely un-clickable: the user selects it, +chooses "Show records", and nothing happens, silently. + +## Best Practice + +Subscribe to both `Navigate::OnAfterFindRecords` and +`Navigate::OnBeforeShowRecords` together, as one unit of work, for any +custom table that should be searchable by document number: + +- In `OnAfterFindRecords`, filter the custom table by the given + `DocNoFilter`/`PostingDateFilter` and call + `DocumentEntry.InsertIntoDocEntry(Database::"My Table", TableCaption, + Count)` to add it to the result list. +- In `OnBeforeShowRecords`, check whether + `TempDocumentEntry."Table ID" = Database::"My Table"`; if so, re-apply + the same filters, open the appropriate card or list page, and set + `IsHandled := true` so the page's own unrelated `case` statement is + never reached for this table. +- If `OnAfterFindRecords` filters the custom table by a field that is not + already that table's own unique key — for example an external + reference number received from a counterparty, rather than the + table's own `No.` — add a key combining that field with `Posting Date`, + the same way BCApps does for `Purch. Inv. Header`'s `"Vendor Invoice + No."` (see Source). This does not apply when filtering the table's own + primary key, which is already unique on its own: `Sales Invoice + Header` filters `"No."` and `"Posting Date"` through two separate, + uncombined keys, with no compound key between them, because `"No."` + alone is already sufficient. + +See sample: [`extend-find-entries-navigate-for-new-document-types.good.al`](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. diff --git a/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al new file mode 100644 index 0000000..0435992 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.bad.al @@ -0,0 +1,14 @@ +enumextension 50100 "Sample Price Source Ext" extends "Price Source Type" +{ + value(50100; "Sample.LoyaltyTier") + { + Caption = 'Loyalty Tier'; + Implementation = "Price Source" = "Price Source - Customer", "Price Source Group" = "Price Source Group - Customer"; + } +} + +// WRONG: no matching value was added to "Sales Price Source Type" (or the +// purchase/job equivalents). "Sample.LoyaltyTier" compiles, installs, and +// is a real value on "Price Source Type" - it just never appears as an +// Applies-to Type option on the Sales Price List page, because that page +// is driven by the separate subset enum, not the base one. diff --git a/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al new file mode 100644 index 0000000..8acd6c6 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.good.al @@ -0,0 +1,19 @@ +enumextension 50100 "Sample Price Source Ext" extends "Price Source Type" +{ + value(50100; "Sample.LoyaltyTier") + { + Caption = 'Loyalty Tier'; + Implementation = "Price Source" = "Price Source - Customer", "Price Source Group" = "Price Source Group - Customer"; + } +} + +enumextension 50101 "Sample Sales Price Source Ext" extends "Sales Price Source Type" +{ + // Same numeric ID (50100) as the Price Source Type value above. That + // match is what makes "Sample.LoyaltyTier" show up as a selectable + // Applies-to Type on an actual sales price list. + value(50100; "Sample.LoyaltyTier") + { + Caption = 'Loyalty Tier'; + } +} diff --git a/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md new file mode 100644 index 0000000..02e6287 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-price-source-type-must-sync-document-subset-enum.md @@ -0,0 +1,68 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [price-calculation, price-source, price-source-type, enumextension, pricing] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Extend Price Source Type and its matching document subset enum together, with the same ID + +## Description + +`enum 7003 "Price Source Type"` (`implements "Price Source", "Price Source +Group"`) is the base list of who a price can apply to — Customer, Vendor, +Customer Price Group, Campaign, and so on. It is not, by itself, what +drives the "Applies-to Type" field on an actual sales, purchase, or job +price list. Each document area has its own subset enum — +`enum 7006 "Sales Price Source Type"`, the equivalent purchase and job +enums — and these are what the price list pages actually expose. Every +value the two enums share today uses the identical numeric ID: `All +Customers`/`Customer`/`Customer Price Group`/`Customer Disc. +Group`/`Campaign`/`Contact` are 10/11/12/13/50/51 in both `Price Source +Type` and `Sales Price Source Type`. + +Adding a new value to `Price Source Type` alone does nothing for a sales +price list: the base enum and the document subset enum are two separate +extensible enums, linked only by convention, not by any platform +mechanism that keeps their IDs in sync. Give the new value a different ID +in each enum, or extend only the base enum, and the source is real and +selectable in some contexts (the base enum is used elsewhere, such as +the generic `Price Source` table) but absent from the specific document +price list a developer actually tested against. + +## Best Practice + +When a new price source should be usable in a sales, purchase, or job +price list, extend `Price Source Type` and the matching document subset +enum (`Sales Price Source Type`, `Purchase Price Source Type`, `Job Price +Source Type`) together, using the identical numeric ID in both. + +See sample: [`extend-price-source-type-must-sync-document-subset-enum.good.al`](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) diff --git a/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al new file mode 100644 index 0000000..d09d34a --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.bad.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al new file mode 100644 index 0000000..065e802 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.good.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md new file mode 100644 index 0000000..3750645 --- /dev/null +++ b/microsoft/knowledge/data-modeling/extend-report-selection-usage-for-new-document-types.md @@ -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.) diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al new file mode 100644 index 0000000..6132c60 --- /dev/null +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.bad.al @@ -0,0 +1,25 @@ +tableextension 50105 "Sample Sales Line Ext" extends "Sales Line" +{ + fields + { + // WRONG: no OnValidate trigger. The field is registered as a + // price source below via OnAfterAddSources, so new lines price + // correctly - but changing this field on an existing line never + // 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; +} diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al new file mode 100644 index 0000000..6f8b157 --- /dev/null +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.good.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md new file mode 100644 index 0000000..828fc8c --- /dev/null +++ b/microsoft/knowledge/data-modeling/new-price-source-must-add-candidate-and-trigger-recalculation.md @@ -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())`. 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) diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al new file mode 100644 index 0000000..a60692b --- /dev/null +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.bad.al @@ -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; +} diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al new file mode 100644 index 0000000..8364ad8 --- /dev/null +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.good.al @@ -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). +} diff --git a/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md new file mode 100644 index 0000000..e85d7e1 --- /dev/null +++ b/microsoft/knowledge/data-modeling/report-barcodes-must-use-barcode-module-and-production-font-name.md @@ -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. diff --git a/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al new file mode 100644 index 0000000..b91dba8 --- /dev/null +++ b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.bad.al @@ -0,0 +1,30 @@ +tableextension 50100 "Sample Sales Header Ext" extends "Sales Header" +{ + fields + { + field(50000; "Reference No."; Code[20]) + { + Caption = 'Reference No.'; + DataClassification = CustomerContent; + } + } +} + +tableextension 50101 "Sample Sales Invoice Header Ext" extends "Sales Invoice Header" +{ + fields + { + // WRONG: same field number 50000, but a shorter length than the + // Sales Header extension above. This compiles fine and posts + // fine for every "Reference No." of 10 characters or less - + // SalesInvHeader.TransferFields(SalesHeader) in + // SalesPost.Codeunit.al only throws once an actual value longer + // than 10 characters reaches posting, which typical test data + // never triggers. + field(50000; "Reference No."; Code[10]) + { + Caption = 'Reference No.'; + DataClassification = CustomerContent; + } + } +} diff --git a/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al new file mode 100644 index 0000000..9f6bcc3 --- /dev/null +++ b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.good.al @@ -0,0 +1,28 @@ +tableextension 50100 "Sample Sales Header Ext" extends "Sales Header" +{ + fields + { + field(50000; "Reference No."; Code[20]) + { + Caption = 'Reference No.'; + DataClassification = CustomerContent; + } + } +} + +tableextension 50101 "Sample Sales Invoice Header Ext" extends "Sales Invoice Header" +{ + fields + { + // Same field number, same type, same length as the Sales Header + // extension above. SalesInvHeader.TransferFields(SalesHeader) in + // SalesPost.Codeunit.al only bridges two fields that agree on all + // three - matching all three here is what makes this value + // survive posting for every possible "Reference No." value. + field(50000; "Reference No."; Code[20]) + { + Caption = 'Reference No.'; + DataClassification = CustomerContent; + } + } +} diff --git a/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md new file mode 100644 index 0000000..70feb22 --- /dev/null +++ b/microsoft/knowledge/data-modeling/transferfields-mirrored-fields-must-match-type-and-length.md @@ -0,0 +1,87 @@ +--- +bc-version: [all] +domain: data-modeling +keywords: [transferfields, field-number, posting-cascade, schema-design, custom-field] +technologies: [al] +countries: [w1] +application-area: [all] +--- + +# Mirrored TransferFields cascade fields must match type and length exactly + +## Description + +Most custom fields genuinely belong to only one table — a status used +only before posting, a note relevant only afterwards, whatever the case +may be. That is the ordinary, unremarkable default, and it needs no +justification: `TransferFields` never touches a field that doesn't exist +on the destination. Per Microsoft's own documentation, a source field's +contents are copied "if such a field exists" on the destination with a +matching field number — a field defined on only one side of a posting +cascade is simply outside `TransferFields`' reach, not a gap to fix. + +The narrower case this rule addresses is when a field **is** deliberately +mirrored across a known cascade — the same field number reused on +another table specifically so the value survives posting, for example a +field added to both `Sales Header` (36) and `Sales Invoice Header` (112), +which `SalesPost.Codeunit.al` connects via +`SalesInvHeader.TransferFields(SalesHeader)`. The two definitions have to +agree on type and, less obviously, on length. A field defined `Text[100]` +on `Sales Header` and `Text[50]` on `Sales Invoice Header` compiles +cleanly on both sides, and the `TransferFields` call runs without error +for every value up to 50 characters. Per Microsoft's documentation, a +runtime error only occurs when there isn't "room for the actual length +of the contents of the field to be copied" — so nothing fails while test +data, or early production data, stays short. The error surfaces only the +day an actual value finally exceeds the shorter definition, on a document +type that may have been posting cleanly for months. + +See also `transferfields-skip-type-mismatch-can-drop-data.md`, which +covers `SkipFieldsNotMatchingType = true` silently skipping a *type* +mismatch between same-extension fields. That parameter has no effect on +length: two fields of the same type but different length still raise the +runtime error described above regardless of how `SkipFieldsNotMatchingType` +is set, which is the distinct failure mode this article addresses. + +## Best Practice + +When mirroring a field across a `TransferFields` cascade, define it with +the exact same field number, data type, and length on every table in +that cascade, at creation time. A field intentionally left local to one +table is unaffected by this and needs no mirroring at all — this is a +consistency requirement between definitions that are already meant to be +linked, not a mandate to check every field against every table on the +cascade. + +See sample: [`transferfields-mirrored-fields-must-match-type-and-length.good.al`](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. diff --git a/microsoft/skills/review/al-data-modeling-review.md b/microsoft/skills/review/al-data-modeling-review.md index a73f929..5f4cec7 100644 --- a/microsoft/skills/review/al-data-modeling-review.md +++ b/microsoft/skills/review/al-data-modeling-review.md @@ -16,7 +16,7 @@ application-area: [all] Reviews AL source changes against the `data-modeling` knowledge domain in BCQuality and emits a findings report. This is a leaf action skill: it invokes no sub-skills. It is one of the skills composed by `al-code-review`. -An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains setup or master tables, their card pages, primary keys, number-series assignment, block enforcement, audit fields, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. The skill returns `not-applicable` when none of those apply. +An orchestrator invokes this skill with a `pr-diff`, `file-path`, or `folder-path`. Data-modeling findings are narrow by design — they apply when the review scope contains 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, barcode/report-layout font-provider usage, dimension wiring, journal-based posting-routine structure, or Item Ledger Entry document-number lookups after a combined sales post. 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`, `InitRecord`, `Round`, `Precision`, `Direction`, `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`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`, `TransferFields`, `Navigate`, `OnAfterFindRecords`, `OnBeforeShowRecords`, `Report Selections`, `Report Selection Usage`, `InsertRecord`, `Document Sending Profile`, `PrintForCust`, `PrintWithDialogForCust`, `PrintWithDialogForVend`, `SendEmailToCust`, `SendEmailToVendor`, `Report.RunModal`, `Report.Run`, `Price Calculation Handler`, `Price Calculation`, `OnFindSupportedSetup`, `Price Calculation Setup`, `Price Source Type`, `PriceSourceList`, `OnAfterAddSources`, `UpdateUnitPrice`, `PlanPriceCalcByField`, `UpdateUnitPriceByField`, `Barcode Font Provider`, `Barcode Font Provider 2D`, `EncodeFont`, `ValidateInput`). A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files. @@ -58,6 +58,15 @@ The following targeted checks cover every current `data-modeling` article. Treat - A `Media` or `MediaSet` field is assigned directly between different table types or different field IDs instead of registering each shared item with `MediaSet.Insert` — `share-mediaset-items-with-insert-not-field-assignment`. - A custom document header assigns defaults outside an `InitRecord` boundary, calls `InitRecord` before assigning its number, or places UI-independent defaults only in a page trigger — `initialize-document-defaults-in-initrecord`. - Directed `Round` calls use `'<'` as mathematical floor or `'>'` as mathematical ceiling, especially where negative amounts are possible — `round-direction-symbols-use-magnitude`. +- A codeunit dispatches a document by calling `Report.Run`/`Report.RunModal` with a hardcoded report ID, or by building its own email directly, instead of going through the `Report Selections` usage for that document — `custom-document-dispatch-must-not-bypass-report-selections`. Either bypass is a finding on its own; both need not be present. Scope this to customer/vendor-facing documents that have (or should have) a `Report Selection Usage` — a hardcoded `Report.Run` of an ordinary list/analysis report is not this anti-pattern. A call that already goes through `Report Selections`' own Print/Email procedures is not this anti-pattern. +- A document's own interactive Print/Email action routes through `Document Sending Profile` (`DocumentSendingProfile.Send`/`SendVendor`) instead of calling `Report Selections` (`PrintForCust`/`PrintWithDialogForCust`/`SendEmailToCust`/`PrintWithDialogForVend`/`SendEmailToVendor`) directly — `document-print-and-email-actions-call-report-selections-directly`. Do not flag `Document Sending Profile` usage that is genuinely part of a combined Post-and-Send action. +- An `EventSubscriber` is added for `Navigate::OnAfterFindRecords` (registering a custom table in Find Entries) without a matching `Navigate::OnBeforeShowRecords` subscriber for the same table, or vice versa — `extend-find-entries-navigate-for-new-document-types`. Both subscribers must be added together for the same table. +- An `enumextension` extends `"Report Selection Usage"` and registers a report via `ReportSelections.InsertRecord`, without subscribing to the matching *single* counterparty's full triad — the filter event (`OnAfterFilterCustomerUsageReportSelections` on `page 9657` for a sales usage, `OnAfterFilterVendorUsageReportSelections` on `page 9658` for a purchase usage) AND the page-facing usage-enum map/validate events (`enumextension` on `"Custom Report Selection Sales"`/`"Report Selection Usage Vendor"` plus the matching map/validate subscribers) — `extend-report-selection-usage-for-new-document-types`. Requiring or wiring *both* counterparties by default for a one-sided document is also the anti-pattern (`ReportSelectionHandlerCZZ` partitions strictly by counterparty); only a genuinely two-sided usage (as `ReportSelectionHandlerCZC` demonstrates for Compensation) needs both. +- The same field number is added as a new field on two or more tables connected by a `TransferFields` call in a posting cascade (e.g. a header table and the posted-document table `SalesPost.Codeunit.al`/`PurchPost.Codeunit.al` transfer into), with a different data type or length on one side — `transferfields-mirrored-fields-must-match-type-and-length`. A field defined on only one side of the cascade is out of scope; this cues only on a field deliberately mirrored across the cascade with a type or length mismatch. +- An `enumextension` extends `"Price Calculation Handler"` and implements the `Price Calculation` interface, without a matching `OnFindSupportedSetup` subscriber inserting a `Price Calculation Setup` record naming that implementation as the `Implementation` for a `Method`/`Type`/`Asset Type` — `activate-new-price-calculation-handler-via-onfindsupportedsetup`. `Default := true` is only required on that row when it is meant as the fallback for its `Method`/`Type`/`Asset Type` combination; a row meant to be selected only through an explicit, specific `"Dtld. Price Calculation Setup"` row does not need it, so do not flag a missing `Default := true` by itself — flag the missing setup row/subscriber entirely. +- An `enumextension` extends `"Price Source Type"` with a new value intended for a sales, purchase, or job price list, without extending the matching document subset enum (`"Sales Price Source Type"`, `"Purchase Price Source Type"`, `"Job Price Source Type"`) with a value at the same numeric ID — `extend-price-source-type-must-sync-document-subset-enum`. +- A codeunit subscribes to `"Sales Line - Price"`'s `OnAfterAddSources` to register a custom field as a price source via `PriceSourceList.Add`, but that field has no `OnValidate` (or matching `OnAfterValidate`) that triggers recalculation — either `SalesLine.UpdateUnitPrice()`, or the explicit `SalesLine.PlanPriceCalcByField()` followed by `SalesLine.UpdateUnitPriceByField()`. A bare `UpdateUnitPriceByField` without a preceding `PlanPriceCalcByField` for the same field number does not count as recalculation (it exits without recalculating) — `new-price-source-must-add-candidate-and-trigger-recalculation`. +- A report hand-constructs a barcode string only where a concrete, independently provable defect is visible: the source value can contain characters outside the symbology's character set and is never validated, a checksum the symbology/setup requires is never applied, or there is concrete evidence of an incompatible font binding. Do not flag manual start/stop delimiters by themselves — `*value*` is a documented, valid Code 39 form for IDAutomation fonts (IDAutomation also accepts parentheses), so delimiter choice alone is never a finding. Also flag module use that does not match the interface: a 1D `"Barcode Font Provider"` path must call both `ValidateInput` and `EncodeFont`; a 2D `"Barcode Font Provider 2D"` path calls `EncodeFont` only (the 2D interface has no `ValidateInput`, so its absence there is not a finding). Separately, flag an otherwise correctly encoded barcode whose report layout names an evaluation/demo font instead of the purchased production font name — `report-barcodes-must-use-barcode-module-and-production-font-name`. - A new field is typed `Code`/`Text` and its `OnValidate` calls `DimensionManagement`/`DimMgt`, or a table adds Shortcut Dimension fields, a `Dimension Set ID` field, or `AddDimSource`/`GetDefaultDimID` — `dimension-management-wiring`. A master table calling `SaveDefaultDim` and a document/journal table computing its own `Dimension Set ID` are two different valid shapes; do not flag a master table for lacking a `Dimension Set ID` field or a document for lacking `SaveDefaultDim`. - A journal-based posting codeunit is added or changed and validation, Journal-table access, ledger writes, and user-interaction (`Confirm`/dialogs) all occur in one procedure or one codeunit, rather than split across `Check Line`/`Post Line`/`Post Batch`-shaped companions — `check-post-line-batch-pattern`. A document posting routine calling `Post Line` directly without a `Post Batch` companion is not this anti-pattern. - An existing, already-published table's `keys` block adds, removes, or reorders a field in its primary key or any `Clustered = true` key — `do-not-change-primary-key`. A new table defining its own key for the first time is not this anti-pattern; requires repository/publication context to know the table has already shipped. @@ -91,7 +100,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, audit-field, dimension-wiring, posting-routine-structure, or Item-Ledger-Entry-document-number 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, barcode/report-font-provider usage, dimension wiring, posting-routine structure, or Item-Ledger-Entry-document-number surface. - `partial` — a budget was hit before the worklist was exhausted. - `failed` — an unrecoverable error occurred. diff --git a/tools/Test-ReviewFixtures.ps1 b/tools/Test-ReviewFixtures.ps1 index 2941c02..f9be512 100644 --- a/tools/Test-ReviewFixtures.ps1 +++ b/tools/Test-ReviewFixtures.ps1 @@ -303,6 +303,7 @@ foreach ($domain in $leafDomains) { $caseList.Add($case) | Out-Null } } + } $cases = @($caseList)