mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Add 5 AL/BC patterns: document distribution (Report Selections, Document Sending Profile, Find Entries, TransferFields)
Five rules about Business Central's document distribution architecture, verified against BCApps source and Microsoft Learn. - custom-document-dispatch-must-not-bypass-report-selections - document-print-and-email-actions-call-report-selections-directly - extend-find-entries-navigate-for-new-document-types - extend-report-selection-usage-for-new-document-types - transferfields-mirrored-fields-must-match-type-and-length Wired into al-data-modeling-review.md's worklist cues. Added a disambiguation note on the TransferFields article distinguishing it from the existing transferfields-skip-type-mismatch-can-drop-data.md (type-mismatch skipping vs. length mismatch, which SkipFieldsNotMatchingType does not affect). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
8584217c75
commit
83f041b662
16 changed files with 761 additions and 0 deletions
|
|
@ -0,0 +1,28 @@
|
|||
report 50102 "Sample Settlement Doc Bad"
|
||||
{
|
||||
UsageCategory = ReportsAndAnalysis;
|
||||
ApplicationArea = All;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
column(No_Customer; "No.") { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50102 "Sample Settlement Document Send"
|
||||
{
|
||||
procedure SendSettlementDocument(var Customer: Record Customer)
|
||||
begin
|
||||
Customer.TestField("E-Mail");
|
||||
|
||||
// WRONG: hardcoded report, no Report Selections row backing it.
|
||||
// Works for the default case, but there is nowhere for an admin to
|
||||
// change the report or layout for one specific customer - this
|
||||
// document never shows up on "Document Layouts" at all, and the
|
||||
// only way to change it is a code change and a new release.
|
||||
Report.RunModal(Report::"Sample Settlement Doc Bad", false, false, Customer);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
codeunit 50102 "Sample Settlement Document Send"
|
||||
{
|
||||
procedure SendSettlementDocument(var Customer: Record Customer)
|
||||
var
|
||||
ReportSelections: Record "Report Selections";
|
||||
begin
|
||||
// Custom validation specific to this document stays here...
|
||||
CheckReadyToSend(Customer);
|
||||
|
||||
// ...but dispatch goes through the registered usage, so per-account
|
||||
// report/layout overrides and email attachment/body configuration
|
||||
// on Report Selections all apply automatically.
|
||||
ReportSelections.SendEmailToCust(
|
||||
"Report Selection Usage"::"S.Invoice".AsInteger(), Customer, Customer."No.",
|
||||
Customer.Name, true, Customer."No.");
|
||||
end;
|
||||
|
||||
local procedure CheckReadyToSend(var Customer: Record Customer)
|
||||
begin
|
||||
Customer.TestField("E-Mail");
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [report-selections, document-layouts, custom-report-layout, email-attachment, bespoke-dispatch]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Custom document dispatch must not bypass Report Selections
|
||||
|
||||
## Description
|
||||
|
||||
A codeunit that hardcodes which report to run (`Report.RunModal(MyReportId, ...)`)
|
||||
and builds its own email directly, instead of registering the document
|
||||
through `table 77 "Report Selections"` and calling its own
|
||||
Print/Email procedures, works for the one case it was written for — and
|
||||
loses everything the platform's registry provides for free. `Report
|
||||
Selections` carries its own attachment/email-body configuration per usage
|
||||
(`"Use for Email Attachment"`, `"Use for Email Body"`, `"Email Body Layout
|
||||
Code"`, `"Email Body Layout Type"`, `"Custom Report Layout Code"`), and
|
||||
`table 9657 "Custom Report Selection"` (the "Document Layouts" page on the
|
||||
Customer/Vendor card) lets one specific account override the report or
|
||||
layout without touching code at all. None of that exists for a document
|
||||
whose dispatch was hand-rolled: there is no registry row to point
|
||||
"Document Layouts" at, so an admin who goes looking for where to change
|
||||
this document's layout — the same place they'd look for every other
|
||||
document in the system — finds nothing, because the document was never
|
||||
registered there.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Register the document under a `Report Selection Usage` value (see
|
||||
`extend-report-selection-usage-for-new-document-types.md`) and dispatch
|
||||
through `Report Selections`' own Print/Email procedures (see
|
||||
`document-print-and-email-actions-call-report-selections-directly.md`),
|
||||
even when the surrounding business logic — which counterparty to use,
|
||||
what validation must pass before sending — is genuinely specific to the
|
||||
document. Custom logic belongs around the call to `Report Selections`,
|
||||
not instead of it.
|
||||
|
||||
See sample: `custom-document-dispatch-must-not-bypass-report-selections.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A codeunit that runs a hardcoded report ID and builds its own email
|
||||
message directly, with no `Report Selections` row backing it. It works for
|
||||
the default case, but the report/layout cannot be changed per account
|
||||
without a code change and a new release, and the document is invisible to
|
||||
"Document Layouts" — the standard place every other document's
|
||||
distribution is configured.
|
||||
|
||||
See sample: `custom-document-dispatch-must-not-bypass-report-selections.bad.al`.
|
||||
|
||||
## Source
|
||||
|
||||
BCApps `ReportSelections.Table.al` (table 77 — fields 19–26 for email
|
||||
attachment/body configuration; `SendEmailToCust`/`PrintWithDialogForCust`
|
||||
as the registry-backed dispatch entry points) and
|
||||
`CustomReportSelection.Table.al` (table 9657, the per-account override
|
||||
backing the "Document Layouts" page) — both under
|
||||
`src/Layers/W1/BaseApp/Foundation/Reporting/`.
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
page 50101 "Sample Settlement Document Card"
|
||||
{
|
||||
PageType = Card;
|
||||
SourceTable = Customer;
|
||||
ApplicationArea = All;
|
||||
|
||||
actions
|
||||
{
|
||||
area(Processing)
|
||||
{
|
||||
action(EmailDocument)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Email';
|
||||
Image = Email;
|
||||
|
||||
trigger OnAction()
|
||||
var
|
||||
DocumentSendingProfile: Record "Document Sending Profile";
|
||||
begin
|
||||
// WRONG: this is a plain, on-demand "Email" button, not
|
||||
// part of a combined Post-and-Send action - but routing
|
||||
// it through Document Sending Profile means the outcome
|
||||
// now silently depends on this customer's assigned
|
||||
// profile. If that profile's "E-Mail" option is No, the
|
||||
// user sees nothing happen after clicking Email, with no
|
||||
// indication that an unrelated setup field is why.
|
||||
DocumentSendingProfile.Send(
|
||||
"Report Selection Usage"::"S.Invoice".AsInteger(), Rec, Rec."No.", Rec."No.",
|
||||
Rec.Name, Rec.FieldNo("No."), Rec.FieldNo("No."));
|
||||
end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
page 50101 "Sample Settlement Document Card"
|
||||
{
|
||||
PageType = Card;
|
||||
SourceTable = Customer;
|
||||
ApplicationArea = All;
|
||||
|
||||
actions
|
||||
{
|
||||
area(Processing)
|
||||
{
|
||||
action(EmailDocument)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Email';
|
||||
Image = Email;
|
||||
|
||||
trigger OnAction()
|
||||
var
|
||||
ReportSelections: Record "Report Selections";
|
||||
begin
|
||||
// Calls Report Selections directly - the button's outcome
|
||||
// depends only on this customer's registered report/layout,
|
||||
// not on any Document Sending Profile setting.
|
||||
ReportSelections.SendEmailToCust(
|
||||
"Report Selection Usage"::"S.Invoice".AsInteger(), Rec, Rec."No.",
|
||||
Rec.Name, true, Rec."No.");
|
||||
end;
|
||||
}
|
||||
action(PrintDocument)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Print';
|
||||
Image = Print;
|
||||
|
||||
trigger OnAction()
|
||||
var
|
||||
ReportSelections: Record "Report Selections";
|
||||
begin
|
||||
ReportSelections.PrintWithDialogForCust(
|
||||
"Report Selection Usage"::"S.Invoice", Rec, true, Rec.FieldNo("No."));
|
||||
end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [report-selections, document-sending-profile, print, email, post-and-send]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# A document's own Print/Email actions call Report Selections directly; Document Sending Profile is scoped to Post-and-Send
|
||||
|
||||
## Description
|
||||
|
||||
`table 60 "Document Sending Profile"` is not a general gateway that every
|
||||
print/email path should route through — it exists specifically for the
|
||||
combined **Post and Send** action: "You can set each customer up with a
|
||||
preferred method of sending sales documents, so that you do not have to
|
||||
select a sending option every time you choose the Post and Send action"
|
||||
(Microsoft Learn, "Set Up Document Sending Profiles"). A document's own, ordinary
|
||||
Print/Email ribbon actions call `table 77 "Report Selections"` directly
|
||||
and are not affected by any Document Sending Profile at all. This is the
|
||||
pattern BC's own base application uses for a document's plain print/email
|
||||
actions: the Sales Order's "Print Confirmation"/"Email Confirmation"
|
||||
actions (`codeunit "Document-Print"`, `PrintSalesOrder`/`EmailSalesHeader`)
|
||||
call `ReportSelections.PrintWithDialogForCust`/`SendEmailToCust` directly,
|
||||
and the posted `Purch. Inv. Header`'s own `PrintRecords` does the same
|
||||
through `ReportSelection.PrintWithDialogForVend` — no customer's or
|
||||
vendor's actually assigned Document Sending Profile is consulted by
|
||||
either.
|
||||
|
||||
The unposted `Purchase Header`'s own `PrintRecords` is a partial exception
|
||||
worth naming precisely: it calls `DocumentSendingProfile.TrySendToPrinterVendor(...)`,
|
||||
but only as a stateless, never-`Get`'d local record carrying print-dialog
|
||||
options, never a vendor's actually configured profile — that helper still
|
||||
resolves the report through `ReportSelections.PrintWithDialogForVend(...)`,
|
||||
the same as everywhere else.
|
||||
|
||||
Only the combined Post-and-Send flow resolves through Document Sending
|
||||
Profile: `Sales-Post and Send` calls `Sales Invoice Header.SendProfile`,
|
||||
which calls `DocumentSendingProfile.Send(...)`, which then decides
|
||||
Print/Email/Disk/Electronic based on the customer's assigned profile and
|
||||
only *then* calls back into `Report Selections` (for the PDF cases) or
|
||||
`Electronic Document Format` (for machine-readable cases).
|
||||
|
||||
Whether a document needs outbound distribution at all isn't determined by
|
||||
Customer-vs-Vendor, but by whether the document is genuinely *outbound* to
|
||||
its counterparty. A posted Purchase Invoice records what a vendor already
|
||||
billed you — nothing to send back — and its posted `Purch. Inv. Header`
|
||||
exposes only a bare `PrintRecords`, no `SendProfile`/`SendRecords`/email at
|
||||
all. A Purchase *Order* is genuinely outbound before posting, which is why
|
||||
the full `SendProfile`/`SendRecords`/`PrintRecords` triplet lives on the
|
||||
unposted `Purchase Header` instead.
|
||||
|
||||
## Best Practice
|
||||
|
||||
For a document's own interactive Print/Email actions, call the relevant
|
||||
`Report Selections` procedure directly —
|
||||
`PrintForCust`/`PrintWithDialogForCust`/`SendEmailToCust` for a
|
||||
customer-facing document, `PrintWithDialogForVend`/`SendEmailToVendor` for
|
||||
a vendor-facing one — using the usage value registered per
|
||||
`extend-report-selection-usage-for-new-document-types.md`. Wire into
|
||||
`Document Sending Profile` only when specifically building a combined
|
||||
Post-and-Send action for that document. Before adding any send capability
|
||||
at all, confirm the document is genuinely outbound to the counterparty
|
||||
it's attached to; a document that only records something already received
|
||||
needs print-for-reference at most, not a send path.
|
||||
|
||||
See sample: `document-print-and-email-actions-call-report-selections-directly.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Routing a document's plain, on-demand "Email" button through
|
||||
`DocumentSendingProfile.Send`/`SendVendor` instead of calling
|
||||
`ReportSelections.SendEmailToCust`/`SendEmailToVendor` directly. The
|
||||
button's outcome now silently depends on that customer's or vendor's
|
||||
assigned Document Sending Profile — if its `"E-Mail"` option happens to be
|
||||
`No`, clicking "Email" does nothing observable, with no indication to the
|
||||
user that a profile setting (meant for the Post-and-Send flow) is the
|
||||
reason. A second version of the same mistake: adding an email action to a
|
||||
document that only receives from its counterparty and was never meant to
|
||||
send anything back.
|
||||
|
||||
See sample: `document-print-and-email-actions-call-report-selections-directly.bad.al`.
|
||||
|
||||
## Source
|
||||
|
||||
BCApps `DocumentPrint.Codeunit.al` (`EmailSalesHeader`/`DoPrintSalesHeader`/`PrintSalesOrder`,
|
||||
calling `ReportSelections.SendEmailToCust`/`PrintForCust`/`PrintWithDialogForCust`
|
||||
directly), `PurchaseHeader.Table.al` (`SendProfile` at line ~6387, calling
|
||||
`DocumentSendingProfile.SendVendor`), `PurchInvHeader.Table.al` (`PrintRecords`
|
||||
calling `ReportSelection.PrintWithDialogForVend` directly, no send capability),
|
||||
`SalesPost.Codeunit.al`
|
||||
(`SendPostedDocumentRecord` at line 7660 → `SalesInvHeader.SendProfile` at
|
||||
lines 7680/7699 → `DocumentSendingProfile.Send`),
|
||||
`DocumentSendingProfile.Table.al` (table 60; `TrySendToPrinterVendor` at
|
||||
line 552 and `SendToPrinterVendor` at line 716, called from
|
||||
`PurchaseHeader.PrintRecords` at line 6357) — all under
|
||||
`src/Layers/W1/BaseApp/`. Microsoft Learn, "Set Up Document Sending Profiles":
|
||||
https://learn.microsoft.com/dynamics365/business-central/sales-how-setup-document-send-profiles
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
codeunit 50103 "Sample Navigate Subscribers"
|
||||
{
|
||||
// WRONG: registers the row, so it appears in the Find Entries result
|
||||
// list with a correct table name and record count - but there is no
|
||||
// OnBeforeShowRecords subscriber for this table. ShowRecords()'s own
|
||||
// case statement has no branch and no else for it either, so
|
||||
// selecting this row and choosing "Show records" does nothing,
|
||||
// silently, with no error.
|
||||
[EventSubscriber(ObjectType::Page, Page::Navigate, 'OnAfterFindRecords', '', false, false)]
|
||||
local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)
|
||||
var
|
||||
SampleDocHeader: Record "Sample Posted Document Header";
|
||||
begin
|
||||
SampleDocHeader.SetFilter("No.", DocNoFilter);
|
||||
SampleDocHeader.SetFilter("Posting Date", PostingDateFilter);
|
||||
DocumentEntry.InsertIntoDocEntry(
|
||||
Database::"Sample Posted Document Header", SampleDocHeader.TableCaption(), SampleDocHeader.Count());
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
codeunit 50103 "Sample Navigate Subscribers"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Page, Page::Navigate, 'OnAfterFindRecords', '', false, false)]
|
||||
local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)
|
||||
var
|
||||
SampleDocHeader: Record "Sample Posted Document Header";
|
||||
begin
|
||||
SampleDocHeader.SetFilter("No.", DocNoFilter);
|
||||
SampleDocHeader.SetFilter("Posting Date", PostingDateFilter);
|
||||
DocumentEntry.InsertIntoDocEntry(
|
||||
Database::"Sample Posted Document Header", SampleDocHeader.TableCaption(), SampleDocHeader.Count());
|
||||
end;
|
||||
|
||||
// Without this second subscriber, the row added above shows up in the
|
||||
// Find Entries result list with a correct count, but "Show records"
|
||||
// has nothing to open it with - see the .bad.al sample.
|
||||
[EventSubscriber(ObjectType::Page, Page::Navigate, 'OnBeforeShowRecords', '', false, false)]
|
||||
local procedure OnBeforeShowRecords(var TempDocumentEntry: Record "Document Entry" temporary; DocNoFilter: Text; PostingDateFilter: Text; ItemTrackingSearch: Boolean; ContactNo: Code[250]; ExtDocNo: Code[250]; var IsHandled: Boolean)
|
||||
var
|
||||
SampleDocHeader: Record "Sample Posted Document Header";
|
||||
begin
|
||||
if TempDocumentEntry."Table ID" <> Database::"Sample Posted Document Header" then
|
||||
exit;
|
||||
|
||||
SampleDocHeader.SetFilter("No.", DocNoFilter);
|
||||
SampleDocHeader.SetFilter("Posting Date", PostingDateFilter);
|
||||
if TempDocumentEntry."No. of Records" = 1 then begin
|
||||
SampleDocHeader.FindFirst();
|
||||
Page.Run(Page::"Sample Posted Document", SampleDocHeader);
|
||||
end else
|
||||
Page.Run(0, SampleDocHeader);
|
||||
|
||||
IsHandled := true;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [navigate, find-entries, document-entry, integration-event, drill-down]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Extend Find Entries (Navigate) for new document or transaction tables
|
||||
|
||||
## Description
|
||||
|
||||
`page 344 Navigate` (caption "Find entries") lets a user enter a document
|
||||
number and posting date and see, across every document and ledger entry
|
||||
table BC knows about, how many matching records exist — then drill into
|
||||
any of those rows. It works over a temporary `table "Document Entry"`
|
||||
that gets populated, one row per source table, by dozens of separate
|
||||
lookups hardcoded into the page (`Rec.InsertIntoDocEntry(Database::"Sales
|
||||
Invoice Header", ...)` and similar, one per table). A new custom document
|
||||
or transaction table is invisible to Find Entries by default — nobody
|
||||
searching by document number will ever see it in the result list — until
|
||||
it registers itself.
|
||||
|
||||
Registration is a two-sided integration event, and only implementing one
|
||||
side produces a page that is worse than not participating at all. The
|
||||
`OnAfterFindRecords` event lets a subscriber add a row to the result list
|
||||
for a custom table. But the subsequent "show records" action, `procedure
|
||||
ShowRecords`, resolves which page to open through its own hardcoded `case
|
||||
Rec."Table ID" of` — the same shape as the row-population code, and just
|
||||
as unaware of any table added by an extension. That `case` statement has
|
||||
no `else` branch. A custom table's row can appear in the result list,
|
||||
with a correct count, and be entirely un-clickable: the user selects it,
|
||||
chooses "Show records", and nothing happens, silently.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Subscribe to both `Navigate::OnAfterFindRecords` and
|
||||
`Navigate::OnBeforeShowRecords` together, as one unit of work, for any
|
||||
custom table that should be searchable by document number:
|
||||
|
||||
- In `OnAfterFindRecords`, filter the custom table by the given
|
||||
`DocNoFilter`/`PostingDateFilter` and call
|
||||
`DocumentEntry.InsertIntoDocEntry(Database::"My Table", TableCaption,
|
||||
Count)` to add it to the result list.
|
||||
- In `OnBeforeShowRecords`, check whether
|
||||
`TempDocumentEntry."Table ID" = Database::"My Table"`; if so, re-apply
|
||||
the same filters, open the appropriate card or list page, and set
|
||||
`IsHandled := true` so the page's own unrelated `case` statement is
|
||||
never reached for this table.
|
||||
- If `OnAfterFindRecords` filters the custom table by a field that is not
|
||||
already that table's own unique key — for example an external
|
||||
reference number received from a counterparty, rather than the
|
||||
table's own `No.` — add a key combining that field with `Posting Date`,
|
||||
the same way BCApps does for `Purch. Inv. Header`'s `"Vendor Invoice
|
||||
No."` (see Source). This does not apply when filtering the table's own
|
||||
primary key, which is already unique on its own: `Sales Invoice
|
||||
Header` filters `"No."` and `"Posting Date"` through two separate,
|
||||
uncombined keys, with no compound key between them, because `"No."`
|
||||
alone is already sufficient.
|
||||
|
||||
See sample: `extend-find-entries-navigate-for-new-document-types.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Subscribing only to `OnAfterFindRecords` (or only to
|
||||
`OnBeforeShowRecords`). Registering the row without handling its
|
||||
drill-down produces a search result that looks complete — the table name
|
||||
and a correct record count both show up — but leads nowhere when
|
||||
selected, with no error and no indication to the user that anything is
|
||||
wrong.
|
||||
|
||||
See sample: `extend-find-entries-navigate-for-new-document-types.bad.al`.
|
||||
|
||||
## Source
|
||||
|
||||
BCApps `Navigate.Page.al` (page 344, `src/Layers/W1/BaseApp/Foundation/Navigate/`):
|
||||
- `[IntegrationEvent(true, false)] local procedure OnAfterFindRecords(var DocumentEntry: Record "Document Entry"; DocNoFilter: Text; PostingDateFilter: Text)`
|
||||
- `[IntegrationEvent(true, false)] local procedure OnBeforeShowRecords(var TempDocumentEntry: Record "Document Entry" temporary; DocNoFilter: Text; PostingDateFilter: Text; ItemTrackingSearch: Boolean; ContactNo: Code[250]; ExtDocNo: Code[250]; var IsHandled: Boolean)`
|
||||
- `procedure ShowRecords()`'s `case Rec."Table ID" of ... end;` has no `else` branch — confirmed by reading the full case block, which ends directly with `end;` followed by `OnAfterShowRecords(...)`.
|
||||
|
||||
BCApps `DocumentEntry.Table.al` (table backing page 344):
|
||||
`procedure InsertIntoDocEntry(DocTableID: Integer; DocTableName: Text; DocNoOfRecords: Integer)` — the registration entry point called from `OnAfterFindRecords` subscribers.
|
||||
|
||||
BCApps `SalesInvoiceHeader.Table.al` (`src/Layers/W1/BaseApp/Sales/History/`):
|
||||
`key(Key1; "No.")` (`Clustered = true`) and `key(Key9; "Posting Date")` are
|
||||
two separate, uncombined keys — no compound key exists between them.
|
||||
|
||||
BCApps `PurchInvHeader.Table.al` (`src/Layers/W1/BaseApp/Purchases/History/`):
|
||||
`key(Key4; "Vendor Invoice No.", "Posting Date")` — a compound key
|
||||
combining a non-unique, externally-supplied reference number with
|
||||
`Posting Date`, distinct from `key(Key1; "No.")`, its own unique primary
|
||||
key.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selection Usage"
|
||||
{
|
||||
value(50100; "Sample.SettlementDoc")
|
||||
{
|
||||
Caption = 'Sample Settlement Document';
|
||||
}
|
||||
}
|
||||
|
||||
report 50100 "Sample Settlement Document"
|
||||
{
|
||||
UsageCategory = ReportsAndAnalysis;
|
||||
ApplicationArea = All;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
column(No_Customer; "No.") { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50100 "Sample Report Selection Install"
|
||||
{
|
||||
procedure InstallDefaultReportSelection()
|
||||
var
|
||||
ReportSelections: Record "Report Selections";
|
||||
begin
|
||||
ReportSelections.InsertRecord(
|
||||
"Report Selection Usage"::"Sample.SettlementDoc", '1', Report::"Sample Settlement Document");
|
||||
// Registration ends here. No subscriber added to
|
||||
// OnAfterFilterCustomerUsageReportSelections / OnAfterFilterVendorUsageReportSelections
|
||||
// - the tenant-wide default works, but "Copy from Report Selection"
|
||||
// on the Document Layouts page never lists this usage value, so a
|
||||
// per-account override can only be entered by hand, if a user even
|
||||
// knows to look for it.
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
enumextension 50100 "Sample Report Selection Usage Ext" extends "Report Selection Usage"
|
||||
{
|
||||
value(50100; "Sample.SettlementDoc")
|
||||
{
|
||||
Caption = 'Sample Settlement Document';
|
||||
}
|
||||
}
|
||||
|
||||
report 50100 "Sample Settlement Document"
|
||||
{
|
||||
UsageCategory = ReportsAndAnalysis;
|
||||
ApplicationArea = All;
|
||||
|
||||
dataset
|
||||
{
|
||||
dataitem(Customer; Customer)
|
||||
{
|
||||
column(No_Customer; "No.") { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50100 "Sample Report Selection Install"
|
||||
{
|
||||
procedure InstallDefaultReportSelection()
|
||||
var
|
||||
ReportSelections: Record "Report Selections";
|
||||
begin
|
||||
ReportSelections.InsertRecord(
|
||||
"Report Selection Usage"::"Sample.SettlementDoc", '1', Report::"Sample Settlement Document");
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50101 "Sample Report Selection Subscribers"
|
||||
{
|
||||
// Appends to whatever the standard filter already contains, following
|
||||
// the real BCApps pattern in ReportSelectionHandlerCZC.Codeunit.al.
|
||||
[EventSubscriber(ObjectType::Page, Page::"Customer Report Selections", 'OnAfterFilterCustomerUsageReportSelections', '', false, false)]
|
||||
local procedure AddSampleUsageOnAfterFilterCustomerUsageReportSelections(var ReportSelections: Record "Report Selections")
|
||||
begin
|
||||
ReportSelections.SetFilter(Usage, GetUsageFilter(ReportSelections));
|
||||
end;
|
||||
|
||||
[EventSubscriber(ObjectType::Page, Page::"Vendor Report Selections", 'OnAfterFilterVendorUsageReportSelections', '', false, false)]
|
||||
local procedure AddSampleUsageOnAfterFilterVendorUsageReportSelections(var ReportSelections: Record "Report Selections")
|
||||
begin
|
||||
ReportSelections.SetFilter(Usage, GetUsageFilter(ReportSelections));
|
||||
end;
|
||||
|
||||
local procedure GetUsageFilter(var ReportSelections: Record "Report Selections") UsageFilter: Text
|
||||
begin
|
||||
UsageFilter := Format("Report Selection Usage"::"Sample.SettlementDoc");
|
||||
if ReportSelections.GetFilter(Usage) <> '' then
|
||||
UsageFilter := StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage), UsageFilter);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [report-selections, report-selection-usage, enumextension, document-layouts, custom-report-selection]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Register a new document type through Report Selections, and extend the Document Layouts filter
|
||||
|
||||
## Description
|
||||
|
||||
A custom document that needs to be printed or emailed should be registered
|
||||
through `table 77 "Report Selections"`, not given its own bespoke
|
||||
report/layout lookup. `enum 77 "Report Selection Usage"` is
|
||||
`Extensible = true` specifically so a new document type can add its own
|
||||
usage value via an `enumextension`, then register a default report for it
|
||||
with `ReportSelections.InsertRecord(Usage, Sequence, ReportID)` — the same
|
||||
mechanism every standard Sales/Purchase/Service document uses.
|
||||
|
||||
Registering through table 77 also brings per-account customization for
|
||||
free: `table 9657 "Custom Report Selection"` (surfaced as the "Document
|
||||
Layouts" action on the Customer and Vendor cards) lets one specific
|
||||
account override both the report and the layout, and the platform's
|
||||
lookup checks that table first before falling back to the tenant-wide
|
||||
default. But the "Copy from Report Selection" action on the Document
|
||||
Layouts pages — the convenience button a user actually uses to seed a
|
||||
per-account override — filters to a **hardcoded** list of usage values
|
||||
(`FilterCustomerUsageReportSelections`/`FilterVendorUsageReportSelections`
|
||||
on `page 9657 "Customer Report Selections"`/`page 9658 "Vendor Report
|
||||
Selections"`). A new custom usage value is not included automatically. Both
|
||||
pages publish `OnAfterFilterCustomerUsageReportSelections(var
|
||||
ReportSelections: Record "Report Selections")` /
|
||||
`OnAfterFilterVendorUsageReportSelections(...)` for exactly this reason —
|
||||
real BCApps localization apps (e.g. the Czech Compensation localization,
|
||||
`ReportSelectionHandlerCZC.Codeunit.al`) subscribe to both events and
|
||||
extend the filter with `StrSubstNo('%1|%2', ReportSelections.GetFilter(Usage),
|
||||
UsageFilter)`, appending to whatever filter already exists rather than
|
||||
replacing it.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Add the new usage value via `enumextension ... extends "Report Selection
|
||||
Usage"`, register a tenant-wide default row with
|
||||
`ReportSelections.InsertRecord(...)`, and subscribe to both
|
||||
`OnAfterFilterCustomerUsageReportSelections` and
|
||||
`OnAfterFilterVendorUsageReportSelections` — even if the document only
|
||||
ever applies to one counterparty side — appending to the existing filter
|
||||
rather than overwriting it. Treat the registration and the filter
|
||||
subscription as one inseparable step: shipping one without the other
|
||||
leaves per-account layout customization silently unreachable through the
|
||||
standard UI.
|
||||
|
||||
See sample: `extend-report-selection-usage-for-new-document-types.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding a new `Report Selection Usage` value and registering a default
|
||||
report, but never subscribing to the filter events. The tenant-wide
|
||||
default works, so the gap isn't visible in testing — but a user who opens
|
||||
"Document Layouts" on a specific customer or vendor and clicks "Copy from
|
||||
Report Selection" to start a per-account override will never see the new
|
||||
document type in the list, with no error and no visible sign that
|
||||
anything is missing.
|
||||
|
||||
See sample: `extend-report-selection-usage-for-new-document-types.bad.al`.
|
||||
|
||||
## Source
|
||||
|
||||
BCApps `ReportSelections.Table.al` (table 77, `InsertRecord` at line 344),
|
||||
`ReportSelectionUsage.Enum.al` (enum 77, `Extensible = true`),
|
||||
`CustomReportSelection.Table.al` (table 9657), `CustomerReportSelections.Page.al`
|
||||
(page 9657, `FilterCustomerUsageReportSelections` and
|
||||
`OnAfterFilterCustomerUsageReportSelections` at line 335),
|
||||
`VendorReportSelections.Page.al` (page 9658, `OnAfterFilterVendorUsageReportSelections`
|
||||
at line 296) — all under `src/Layers/W1/BaseApp/`. Real subscriber
|
||||
precedent: `src/Apps/CZ/CompensationLocalization/app/Src/Codeunits/ReportSelectionHandlerCZC.Codeunit.al`,
|
||||
`GetUsageFilter` (line 104) and both event subscribers (lines 38, 66).
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [transferfields, field-number, posting-cascade, schema-design, custom-field]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Mirrored TransferFields cascade fields must match type and length exactly
|
||||
|
||||
## Description
|
||||
|
||||
Most custom fields genuinely belong to only one table — a status used
|
||||
only before posting, a note relevant only afterwards, whatever the case
|
||||
may be. That is the ordinary, unremarkable default, and it needs no
|
||||
justification: `TransferFields` never touches a field that doesn't exist
|
||||
on the destination. Per Microsoft's own documentation, a source field's
|
||||
contents are copied "if such a field exists" on the destination with a
|
||||
matching field number — a field defined on only one side of a posting
|
||||
cascade is simply outside `TransferFields`' reach, not a gap to fix.
|
||||
|
||||
The narrower case this rule addresses is when a field **is** deliberately
|
||||
mirrored across a known cascade — the same field number reused on
|
||||
another table specifically so the value survives posting, for example a
|
||||
field added to both `Sales Header` (36) and `Sales Invoice Header` (112),
|
||||
which `SalesPost.Codeunit.al` connects via
|
||||
`SalesInvHeader.TransferFields(SalesHeader)`. The two definitions have to
|
||||
agree on type and, less obviously, on length. A field defined `Text[100]`
|
||||
on `Sales Header` and `Text[50]` on `Sales Invoice Header` compiles
|
||||
cleanly on both sides, and the `TransferFields` call runs without error
|
||||
for every value up to 50 characters. Per Microsoft's documentation, a
|
||||
runtime error only occurs when there isn't "room for the actual length
|
||||
of the contents of the field to be copied" — so nothing fails while test
|
||||
data, or early production data, stays short. The error surfaces only the
|
||||
day an actual value finally exceeds the shorter definition, on a document
|
||||
type that may have been posting cleanly for months.
|
||||
|
||||
See also `transferfields-skip-type-mismatch-can-drop-data.md`, which
|
||||
covers `SkipFieldsNotMatchingType = true` silently skipping a *type*
|
||||
mismatch between same-extension fields. That parameter has no effect on
|
||||
length: two fields of the same type but different length still raise the
|
||||
runtime error described above regardless of how `SkipFieldsNotMatchingType`
|
||||
is set, which is the distinct failure mode this article addresses.
|
||||
|
||||
## Best Practice
|
||||
|
||||
When mirroring a field across a `TransferFields` cascade, define it with
|
||||
the exact same field number, data type, and length on every table in
|
||||
that cascade, at creation time. A field intentionally left local to one
|
||||
table is unaffected by this and needs no mirroring at all — this is a
|
||||
consistency requirement between definitions that are already meant to be
|
||||
linked, not a mandate to check every field against every table on the
|
||||
cascade.
|
||||
|
||||
See sample: `transferfields-mirrored-fields-must-match-type-and-length.good.al`.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
The same field number added to two tables that `TransferFields` connects
|
||||
in a posting cascade (e.g. `Sales Header` (36) and `Sales Invoice Header`
|
||||
(112), linked by `SalesPost.Codeunit.al`), with a shorter length — or an
|
||||
incompatible data type — on one side. Both definitions compile without
|
||||
error; nothing fails until an actual value exceeds the shorter one, which
|
||||
typical test data never does.
|
||||
|
||||
See sample: `transferfields-mirrored-fields-must-match-type-and-length.bad.al`.
|
||||
|
||||
## Source
|
||||
|
||||
Microsoft Learn, `Record.TransferFields(var Record [, Boolean])`:
|
||||
"The `TransferFields` method copies fields based on the field number on
|
||||
the fields. For each field in `Record` (the destination), the contents
|
||||
of the field that has the same field number in `FromRecord` (the source)
|
||||
will be copied, **if such a field exists**." And: "The fields must have
|
||||
the *same data type* for the copying to succeed... There must be room
|
||||
for the actual length of the contents of the field to be copied in the
|
||||
field to which it is to be copied. If any one of these conditions aren't
|
||||
fulfilled, a runtime error will occur."
|
||||
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-transferfields-table-boolean-method)
|
||||
|
||||
BCApps `SalesPost.Codeunit.al` (`src/Layers/W1/BaseApp/Sales/Posting/`):
|
||||
`SalesShptHeader.TransferFields(SalesHeader);` (line 7104),
|
||||
`ReturnRcptHeader.TransferFields(SalesHeader);` (line 7166),
|
||||
`SalesInvHeader.TransferFields(SalesHeader);` (line 7220),
|
||||
`SalesCrMemoHeader.TransferFields(SalesHeader);` (line 7275) — the real
|
||||
cascade a mirrored field on `Sales Header` (36) is checked against.
|
||||
Loading…
Add table
Add a link
Reference in a new issue