mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-06 07:06:54 +01:00
Merge main into jeremy-retention-policies
This commit is contained in:
commit
3b52c03121
465 changed files with 16108 additions and 1271 deletions
13
microsoft/knowledge/appsource/file-datatype-saas.bad.al
Normal file
13
microsoft/knowledge/appsource/file-datatype-saas.bad.al
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
codeunit 50104 "Import File Reader"
|
||||
{
|
||||
procedure ImportFile()
|
||||
var
|
||||
ImportFile: File;
|
||||
InStream: InStream;
|
||||
begin
|
||||
ImportFile.WriteMode(false);
|
||||
ImportFile.TextMode(true);
|
||||
ImportFile.Open('C:\Import\data.txt'); // fails in SaaS — no local filesystem
|
||||
ImportFile.CreateInStream(InStream);
|
||||
end;
|
||||
}
|
||||
24
microsoft/knowledge/appsource/file-datatype-saas.good.al
Normal file
24
microsoft/knowledge/appsource/file-datatype-saas.good.al
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
codeunit 50104 "Import File Reader"
|
||||
{
|
||||
procedure ImportFile()
|
||||
var
|
||||
TempBlob: Codeunit "Temp Blob";
|
||||
FromInStream: InStream;
|
||||
ToOutStream: OutStream;
|
||||
InStream: InStream;
|
||||
begin
|
||||
if not UploadIntoStream('All Files (*.*)|*.*', FromInStream) then
|
||||
exit;
|
||||
|
||||
TempBlob.CreateOutStream(ToOutStream);
|
||||
CopyStream(ToOutStream, FromInStream);
|
||||
|
||||
TempBlob.CreateInStream(InStream);
|
||||
ParseStream(InStream);
|
||||
end;
|
||||
|
||||
local procedure ParseStream(var InStream: InStream)
|
||||
begin
|
||||
// parse InStream content here
|
||||
end;
|
||||
}
|
||||
28
microsoft/knowledge/appsource/file-datatype-saas.md
Normal file
28
microsoft/knowledge/appsource/file-datatype-saas.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [file-datatype, saas, onprem, uploadintostream, downloadfromstream, instream, outstream, streaming]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# The File data type's direct I/O methods are OnPrem-only
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
The classic `File` variable type — `Open`/`Create`/`Read`/`Write`/`Close` against a path on the local or server filesystem — is scoped OnPrem-only. Code targeting Business Central Online that calls `File.Open`, `File.Create`, `File.Read`, or `File.Write` fails to compile against a Cloud-scoped project; it does not compile successfully and fail or get silently skipped at runtime. Separately, and regardless of the compile-time scoping, no server/local filesystem path is available to an extension actually running in Business Central Online.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use the stream-based equivalents: `UploadIntoStream` to read user-selected file content into an `InStream`, and `DownloadFromStream` to write an `OutStream`'s content to a file the user saves. Stage the content in a `TempBlob` between the stream and the rest of the parsing/formatting code.
|
||||
|
||||
See sample: [`file-datatype-saas.good.al`](file-datatype-saas.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Opening a hardcoded or user-supplied filesystem path with the `File` variable type. This is a strong signal the code was written for on-premises only, or copied from material that predates the cloud-first streaming APIs.
|
||||
|
||||
See sample: [`file-datatype-saas.bad.al`](file-datatype-saas.bad.al).
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
// Anti-pattern: an own object with no affix. Another app that also defines a
|
||||
// "Loyalty Tier" table cannot be installed alongside this one.
|
||||
table 50379 "Loyalty Tier"
|
||||
{
|
||||
Caption = 'Loyalty Tier';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Code"; Code[20])
|
||||
{
|
||||
Caption = 'Code';
|
||||
}
|
||||
field(10; Description; Text[100])
|
||||
{
|
||||
Caption = 'Description';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Code")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Anti-pattern (the common half-measure): the extension object carries the
|
||||
// affix, but the field it adds to the standard Customer table does not. That
|
||||
// unaffixed field still collides with any other app that adds "Loyalty Points"
|
||||
// to Customer, and AS0011 flags it.
|
||||
tableextension 50378 "ABC Customer Ext" extends Customer
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(50378; "Loyalty Points"; Integer)
|
||||
{
|
||||
Caption = 'Loyalty Points';
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// Own object: the affix "ABC" is carried at object-name level.
|
||||
table 50377 "ABC Loyalty Tier"
|
||||
{
|
||||
Caption = 'Loyalty Tier';
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Code"; Code[20])
|
||||
{
|
||||
Caption = 'Code';
|
||||
}
|
||||
field(10; Description; Text[100])
|
||||
{
|
||||
Caption = 'Description';
|
||||
}
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Code")
|
||||
{
|
||||
Clustered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension of a standard object: the added field is individually affixed,
|
||||
// because the object name (Customer) belongs to the base application.
|
||||
tableextension 50376 "ABC Customer Ext" extends Customer
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(50376; "Loyalty Points ABC"; Integer)
|
||||
{
|
||||
Caption = 'Loyalty Points';
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [object-affix, prefix, suffix, as0011, appsourcecop, collision, tableextension, first-party, isv]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Apply a reserved affix to objects and to members added to base objects
|
||||
|
||||
## Description
|
||||
|
||||
An AppSource extension must prevent name collisions through its registered affix or, on BC23 and later for objects it owns, a namespace with at least two levels. The affix still applies to every field, key, control, or action added to a base-application object; see `two-level-namespace-replaces-object-affix-not-extension-member-affix.md`. Without either mechanism, two apps that both define a `Loyalty Tier` table cannot coexist, and two apps that add an unaffixed `Loyalty Points` field to `Customer` still collide regardless of their namespaces.
|
||||
|
||||
AppSourceCop enforces this. The primary rule is AS0011 ("An affix is required"); the affixes are configured through `mandatoryAffixes` (and `mandatoryPrefix`) in `AppSourceCop.json`. Two placements matter and are easy to get half-right: an object you define carries the affix at **object-name** level, while a member you add to a **standard** object carries the affix on that **member's** name. Adding an affixed object is not enough — an unaffixed field bolted onto `Customer` still collides and still fails validation.
|
||||
|
||||
This rule scopes to Marketplace ISV extensions, which is what AppSourceCop validates. A first-party Microsoft in-box module (publisher `Microsoft`, an object range reserved for first-party use, and no `AppSourceCop.json`/`mandatoryAffixes` in the app) is not built or shipped as an Marketplace extension and is not subject to AS0011, so an unaffixed action or field it adds to a base-application page is not a collision risk to flag. Renaming an existing shipped first-party member to add an affix is itself a breaking change to that module's own history and is not required by this rule.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Own objects use the registered affix (for example `ABC Loyalty Tier`) or, when targeting BC23 or later, a qualifying namespace. Every field or action added to a standard object remains individually affixed (for example `Loyalty Points ABC` on a `Customer` tableextension).
|
||||
|
||||
See sample: [`object-affixes-prevent-collisions.good.al`](object-affixes-prevent-collisions.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An owned object with neither a qualifying namespace nor an affix, an unaffixed extension member, or the common half-measure where the extension object carries the affix but a field it adds to a standard table does not. AS0011 flags the missing collision protection and the field can still collide with another app.
|
||||
|
||||
See sample: [`object-affixes-prevent-collisions.bad.al`](object-affixes-prevent-collisions.bad.al).
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [version, release, app-json, semver, al-go, appsource]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Update the app version at every release
|
||||
|
||||
## Description
|
||||
|
||||
At every release — a branch merged to `main`, a tagged release build, or an AppSource submission — the app's version is consciously updated, not left to the pipeline alone.
|
||||
|
||||
| Version part | Owner | When |
|
||||
|---|---|---|
|
||||
| Major | Developer decision | Breaking change (schema, API, removed objects) |
|
||||
| Minor | Developer decision | Every release with new functionality |
|
||||
| Build / Revision | AL-Go pipeline | Automatic — never hand-edited |
|
||||
|
||||
The app's stable identity is its `id` in `app.json`; the version identifies which release — which code state — of that app is deployed. Two customer environments running "the same" version with different code is an undiagnosable support case. AppSource's actual requirement is strict full-version ordering — the complete version must be greater than the previously submitted version — which an AL-Go-generated build/revision increment can satisfy on its own; AppSource does not require major.minor itself to change. Treating major.minor as a deliberate, human-decided compatibility signal is still valuable practice — it is a statement about what changed that no pipeline can make on its own — just not a platform-enforced requirement.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Before the release merge:
|
||||
app.json: "version": "1.3.0.0" (new functionality -> minor bump, by team convention)
|
||||
AL-Go settings: "repoVersion": "1.3" (where used)
|
||||
Then: feature branch -> main via PR, tag, release.
|
||||
|
||||
"Feature branches never touch the version" and "every merge to main is a release" are workflow choices your team can adopt for compatibility clarity — not something AppSource itself requires.
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Branch merged to main and released.
|
||||
app.json still says "version": "1.2.0.0" -- same as the previous release.
|
||||
Two different code states now share one version identity.
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
namespace Contoso;
|
||||
|
||||
table 50462 "Rental Agreement"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
}
|
||||
}
|
||||
|
||||
tableextension 50463 "Rental Customer Ext" extends Customer
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(50463; "Loyalty Points"; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
namespace Contoso.Rentals;
|
||||
|
||||
table 50460 "Rental Agreement"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
}
|
||||
}
|
||||
|
||||
tableextension 50461 "Rental Customer Ext" extends Customer
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(50461; "Loyalty Points RNT"; Integer)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
bc-version: [23..]
|
||||
domain: appsource
|
||||
keywords: [namespace, two-level, affix, prefix, suffix, as0011, tableextension, pageextension, false-positive]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# A two-level namespace replaces an object affix, not an extension-member affix
|
||||
|
||||
## Description
|
||||
|
||||
Current AppSource naming guidance accepts a namespace with at least two levels, such as `Contoso.Rentals`, instead of a registered prefix or suffix on the names of objects the app owns. The namespace does not qualify members added to another publisher's object: fields, keys, controls, and actions introduced through table or page extensions still share the target object's flat member namespace and still need the registered affix.
|
||||
|
||||
The requirement comes from AppSourceCop rule AS0011, which only runs when the app enables AppSourceCop and configures a mandatory affix — normally an `AppSourceCop.json` next to the app manifest. An app that ships no such configuration is not subject to AS0011, and its extension members are not a compliance gap. This is the usual situation for first-party, in-box apps that ship as part of the product rather than through AppSource: their uniqueness comes from allocated object ID ranges and a controlled source tree, not from a registered affix. Confirm the extending app actually configures a mandatory affix before reporting an unaffixed extension member.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Choose one collision strategy for owned objects: a registered affix or a globally meaningful namespace with at least two levels. Regardless of that choice, apply the registered affix to every member added to a base or third-party object. Keep the affix configured for AppSourceCop so member validation remains deterministic. Do not raise a missing member affix against an app that does not enable AppSourceCop with a mandatory affix; there AS0011 never fires, and the app's namespace is not the reason — the absent configuration is.
|
||||
|
||||
See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using `namespace Contoso;` as though one level satisfied the AppSource alternative, or declaring `namespace Contoso.Rentals;` and then adding an unaffixed `Loyalty Points` field to `Customer` in an app that does configure a mandatory affix. The namespace distinguishes the extension's own objects; it cannot disambiguate members on Customer. The mirror-image mistake is reporting an unaffixed extension member in an app that enables no mandatory affix at all — AS0011 does not apply there, and the finding is a false positive.
|
||||
|
||||
See sample: [`two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al`](two-level-namespace-replaces-object-affix-not-extension-member-affix.bad.al).
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
// This published object previously used namespace Contoso.Rentals.
|
||||
namespace Contoso.RentalManagement;
|
||||
|
||||
codeunit 50467 "Rental Agreement Mgt."
|
||||
{
|
||||
procedure CreateAgreement()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
namespace Contoso.Rentals;
|
||||
|
||||
codeunit 50466 "Rental Agreement Mgt."
|
||||
{
|
||||
procedure CreateAgreement()
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
bc-version: [23..]
|
||||
domain: breaking-changes
|
||||
keywords: [namespace, published-object, dependency, breaking-change, as0007, compile-time-identity]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Treat a published namespace as part of object identity
|
||||
|
||||
## Description
|
||||
|
||||
AL resolves an object by namespace and name. Once an app ships and dependent extensions compile against that identity, changing the namespace breaks their references even when the object name and ID stay unchanged. AppSourceCop AS0007 rejects changing the namespace of published objects; namespaces are therefore not a cosmetic folder-like label that can be reorganized after release.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Choose a globally meaningful namespace before first publication and keep it stable. Add new functional areas beneath that structure without moving existing published objects. If an identity must move, use the platform's supported move/obsoletion lifecycle rather than a source-only namespace rename.
|
||||
|
||||
See sample: [`namespace-is-part-of-published-object-identity.good.al`](namespace-is-part-of-published-object-identity.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Changing `namespace Contoso.Rentals;` to `namespace Contoso.RentalManagement;` as a cleanup while leaving the object name and ID untouched. Every dependent `using` directive and qualified reference targets the old identity and stops compiling.
|
||||
|
||||
See sample: [`namespace-is-part-of-published-object-identity.bad.al`](namespace-is-part-of-published-object-identity.bad.al).
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
codeunit 50103 "Order Confirmation Notifier"
|
||||
{
|
||||
procedure Send(ToAddress: Text; Subject: Text; Body: Text)
|
||||
var
|
||||
Mail: Codeunit Mail;
|
||||
begin
|
||||
Mail.CreateMessage(ToAddress, '', '', Subject, Body, false, false);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
codeunit 50103 "Order Confirmation Notifier"
|
||||
{
|
||||
procedure Send(ToAddress: Text; Subject: Text; Body: Text)
|
||||
var
|
||||
Email: Codeunit Email;
|
||||
EmailMessage: Codeunit "Email Message";
|
||||
begin
|
||||
EmailMessage.Create(ToAddress, Subject, Body, true);
|
||||
Email.Send(EmailMessage, Enum::"Email Scenario"::Default);
|
||||
end;
|
||||
}
|
||||
28
microsoft/knowledge/breaking-changes/prefer-email-module.md
Normal file
28
microsoft/knowledge/breaking-changes/prefer-email-module.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: breaking-changes
|
||||
keywords: [email, codeunit-mail, email-message, email-scenario, email-account, smtp, sending-email]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Send email through the Email module, not Codeunit Mail (397)
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Older AL code sends email by calling `Codeunit Mail (397)`. Business Central's current extensibility model is a different, richer object set — `Codeunit Email`, `Codeunit "Email Message"`, `enum "Email Scenario"`, and the `Email Account`/`Email Connector` interface (Microsoft 365, Current User, SMTP, or a custom connector). `Codeunit "Email Message"` is the in-memory object you build the message on; it is not itself the persisted Sent/Outbox/Draft record — that storage is managed separately once the message is queued or sent. New code built on `Codeunit Mail` inherits its SMTP-era, single-connector assumptions and leaves no Sent/Outbox trail behind.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Build on `Codeunit Email` and `Codeunit "Email Message"`. Route the message through an `Email Scenario` so different document types can use different accounts without the calling code needing to know which account that is, and get a tracked Sent/Outbox/Draft record for free.
|
||||
|
||||
See sample: [`prefer-email-module.good.al`](prefer-email-module.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Calling `Codeunit Mail`'s `CreateMessage`. It still compiles and runs, but current `Codeunit Mail`'s own implementation of `CreateMessage` no longer sends anything by itself — it only raises integration events for a legacy subscriber to act on — so building new code on it means depending on whatever compatibility shim happens to still be wired up, with no first-class connector selection and no queryable Sent/Outbox/Draft record. `Send` and `GetErrorDesc` are not current members of `Codeunit Mail` at all; do not reference them.
|
||||
|
||||
See sample: [`prefer-email-module.bad.al`](prefer-email-module.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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
codeunit 50101 "Meter Jnl.-Post"
|
||||
{
|
||||
procedure Post(var MeterJnlLine: Record "Meter Journal Line")
|
||||
var
|
||||
MeterLedgEntry: Record "Meter Ledger Entry";
|
||||
begin
|
||||
// Validation, Journal access, and posting all mixed in one routine.
|
||||
if not Confirm('Post journal lines?') then
|
||||
exit;
|
||||
|
||||
if MeterJnlLine.FindSet() then
|
||||
repeat
|
||||
if MeterJnlLine.Quantity = 0 then
|
||||
Error('Quantity must not be zero.');
|
||||
|
||||
MeterLedgEntry.Init();
|
||||
MeterLedgEntry.TransferFields(MeterJnlLine);
|
||||
MeterLedgEntry.Insert();
|
||||
MeterJnlLine.Delete();
|
||||
until MeterJnlLine.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
codeunit 50100 "Meter Jnl.-Check Line"
|
||||
{
|
||||
procedure CheckLine(var MeterJnlLine: Record "Meter Journal Line")
|
||||
begin
|
||||
// Reads setup/dimension data only, shows no UI beyond errors.
|
||||
if MeterJnlLine.Quantity = 0 then
|
||||
Error('Quantity must not be zero.');
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50101 "Meter Jnl.-Post Line"
|
||||
{
|
||||
procedure PostLine(var MeterJnlLine: Record "Meter Journal Line")
|
||||
var
|
||||
MeterLedgEntry: Record "Meter Ledger Entry";
|
||||
begin
|
||||
// Posts exactly one journal line; never touches the Journal table.
|
||||
MeterLedgEntry.Init();
|
||||
MeterLedgEntry.TransferFields(MeterJnlLine);
|
||||
MeterLedgEntry.Insert();
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50102 "Meter Jnl.-Post Batch"
|
||||
{
|
||||
procedure PostBatch(var MeterJnlLine: Record "Meter Journal Line")
|
||||
var
|
||||
CheckLine: Codeunit "Meter Jnl.-Check Line";
|
||||
PostLine: Codeunit "Meter Jnl.-Post Line";
|
||||
begin
|
||||
if MeterJnlLine.FindSet() then
|
||||
repeat
|
||||
CheckLine.CheckLine(MeterJnlLine);
|
||||
until MeterJnlLine.Next() = 0;
|
||||
|
||||
if MeterJnlLine.FindSet() then
|
||||
repeat
|
||||
PostLine.PostLine(MeterJnlLine);
|
||||
MeterJnlLine.Delete();
|
||||
until MeterJnlLine.Next() = 0;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [posting-routine, check-line, post-line, post-batch, companion-codeunit, yes-no-wrapper, journal]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Split posting routines into Check Line / Post Line / Post Batch
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Business Central's own journal-based posting routines consistently follow a three-codeunit split, each with one primary responsibility — `Codeunit "Gen. Jnl.-Check Line"` / `"Gen. Jnl.-Post Line"` / `"Gen. Jnl.-Post Batch"` for the general journal, and the same `<Journal>-Check Line` / `<Journal>-Post Line` / `<Journal>-Post Batch` shape repeated for Item, Resource, Job, Fixed Asset, Insurance, and Cost Accounting journals: `Check Line` validates one line, `Post Line` posts exactly one journal line — `Gen. Jnl.-Post Line` itself can write more than one G/L Entry per call (a balancing entry, VAT, currency rounding, deferrals), so "exactly one line" describes its input, not a one-entry-out guarantee — and `Post Batch` loops both across the journal. A document posting routine (posting one document at a time) calls `Post Line` directly and skips `Post Batch`. This is the standard shape to evaluate a new journal-based posting routine against, not a platform-enforced constraint — a routine with a genuinely different transaction/reuse shape may legitimately organize itself differently, and the three codeunits' responsibilities are a useful default split, not a guarantee that every implementation keeps them non-overlapping. But a new routine that blurs this split without a specific reason either misses functionality other code expects to call directly, or exposes an interaction surface it shouldn't.
|
||||
|
||||
## Best Practice
|
||||
|
||||
`Check Line` reads setup/dimension data only on its first call and shows no UI beyond errors. `Post Line` only operates on the record passed to it — never the Journal table — so it can be called directly by other posting code, including a document posting routine. `Post Batch` is the only one of the three that reads and updates the Journal table, and it is the only one invoked from the Post action on a journal page. A `-Post` document codeunit is never called directly from a page; a page calls a `-Post (Yes/No)` confirmation wrapper instead, so the same `-Post` codeunit can also run unattended from a batch-posting report.
|
||||
|
||||
See sample: [`check-post-line-batch-pattern.good.al`](check-post-line-batch-pattern.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A single monolithic posting codeunit that reads the Journal table, validates lines, writes ledger entries, and shows confirmation dialogs all in one procedure. It cannot be reused by another posting routine without fabricating journal records, and it cannot run unattended because it insists on user interaction.
|
||||
|
||||
See sample: [`check-post-line-batch-pattern.bad.al`](check-post-line-batch-pattern.bad.al).
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
codeunit 50101 "Batch Job Runner"
|
||||
{
|
||||
procedure AdvanceToNextBusinessDay()
|
||||
begin
|
||||
// Anti-pattern: repurposes the user's session WorkDate as a
|
||||
// scratch variable for unrelated business logic.
|
||||
WorkDate(CalcDate('<1D>', WorkDate()));
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
codeunit 50100 "Posting Date Helper"
|
||||
{
|
||||
procedure GetDefaultPostingDate(): Date
|
||||
var
|
||||
PostingDate: Date;
|
||||
begin
|
||||
// Read the work date to default a value; never write to it.
|
||||
PostingDate := WorkDate();
|
||||
exit(PostingDate);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [workdate, session-setting, user-control, side-effect]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Application code must not change the WorkDate
|
||||
|
||||
## Description
|
||||
|
||||
The work date is a per-user session setting the user controls from the
|
||||
client (the date shown in the top-right corner, used to default posting
|
||||
dates and date filters). Business logic unrelated to that setting must not
|
||||
call `WorkDate(NewDate)` as a side effect of doing something else — that
|
||||
silently changes what the user sees and defaults to for the rest of their
|
||||
session, a surprising, hard-to-trace behavior change the user never asked
|
||||
for and has no visibility into. This is not a blanket ban on the setter
|
||||
itself: BCApps' own demo-data generators legitimately save the current
|
||||
work date, set a specific one to backdate the data they create, and
|
||||
restore it afterward (see `CreateDemoEDocsBE.Codeunit.al`'s
|
||||
`WorkDate(SampleInvoiceDate)` / `WorkDate(SavedWorkDate)` pair), and test
|
||||
codeunits routinely set `WorkDate` deliberately to control the date context
|
||||
a test runs under (hundreds of calls across BCApps' test suite, for
|
||||
example `SustainabilityPostingTest.Codeunit.al`). Both are the code's
|
||||
*actual purpose*, not a side effect of something unrelated.
|
||||
|
||||
This is a call-direction distinction for the read side: reading the
|
||||
current work date via `WorkDate` (or `WorkDate()` with no argument) is
|
||||
always fine.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Read the work date to default a value. Only write to it when changing it
|
||||
*is* the operation being performed — implementing the user's own
|
||||
work-date/settings action, or a test or demo-data routine that deliberately
|
||||
establishes a date context (saving and restoring the prior value if the
|
||||
routine must leave the session as it found it). Business logic that exists
|
||||
to do something else must never write `WorkDate` as an incidental side
|
||||
effect; if a calculation needs a specific date, pass or compute that date
|
||||
as a local variable instead.
|
||||
|
||||
See sample: [`code-must-not-change-workdate.good.al`](code-must-not-change-workdate.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Setting the work date from within a codeunit, report, or page action whose
|
||||
purpose is unrelated to the user's date preference — for example, a
|
||||
posting or calculation routine that calls `WorkDate(SomeDate)` to make its
|
||||
own logic simpler. This changes session state the user owns for the
|
||||
duration of a call that was never about the work date, and never restores
|
||||
it. This is a different case from a test or demo-data routine explicitly
|
||||
declaring a date context: the anti-pattern is unrelated logic silently
|
||||
mutating state it does not own, not the setter form itself.
|
||||
|
||||
See sample: [`code-must-not-change-workdate.bad.al`](code-must-not-change-workdate.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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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/`.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
table 50100 "Course"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(10; "Global Dimension 1 Code"; Code[20])
|
||||
{
|
||||
// No CaptionClass, no OnValidate call into DimensionManagement.
|
||||
// Accepts any value; never becomes a Default Dimension record.
|
||||
TableRelation = "Dimension Value".Code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
// Master data: Default Dimension records, no Dimension Set ID field.
|
||||
table 50100 "Course"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(10; "Global Dimension 1 Code"; Code[20])
|
||||
{
|
||||
CaptionClass = '1,1,1';
|
||||
TableRelation = "Dimension Value".Code where(
|
||||
"Global Dimension No." = const(1), Blocked = const(false));
|
||||
|
||||
trigger OnValidate()
|
||||
var
|
||||
DimMgt: Codeunit DimensionManagement;
|
||||
begin
|
||||
DimMgt.ValidateDimValueCode(1, "Global Dimension 1 Code");
|
||||
DimMgt.SaveDefaultDim(Database::Course, "No.", 1, "Global Dimension 1 Code");
|
||||
end;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnDelete()
|
||||
var
|
||||
DimMgt: Codeunit DimensionManagement;
|
||||
begin
|
||||
DimMgt.DeleteDefaultDim(Database::Course, "No.");
|
||||
end;
|
||||
}
|
||||
|
||||
// Transactional/document data: a single Dimension Set ID, inherited from the
|
||||
// related master record and overridable via shortcut dimension fields.
|
||||
table 50101 "Course Registration Header"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(2; "Customer No."; Code[20])
|
||||
{
|
||||
TableRelation = Customer;
|
||||
|
||||
trigger OnValidate()
|
||||
begin
|
||||
UpdateDimensionSetID();
|
||||
end;
|
||||
}
|
||||
field(10; "Shortcut Dimension 1 Code"; Code[20])
|
||||
{
|
||||
CaptionClass = '1,1,1';
|
||||
TableRelation = "Dimension Value".Code where(
|
||||
"Global Dimension No." = const(1), Blocked = const(false));
|
||||
|
||||
trigger OnValidate()
|
||||
var
|
||||
DimMgt: Codeunit DimensionManagement;
|
||||
begin
|
||||
DimMgt.ValidateShortcutDimValues(1, "Shortcut Dimension 1 Code", "Dimension Set ID");
|
||||
end;
|
||||
}
|
||||
field(480; "Dimension Set ID"; Integer)
|
||||
{
|
||||
Editable = false;
|
||||
TableRelation = "Dimension Set Entry"."Dimension Set ID";
|
||||
}
|
||||
}
|
||||
|
||||
local procedure UpdateDimensionSetID()
|
||||
var
|
||||
Customer: Record Customer;
|
||||
DimMgt: Codeunit DimensionManagement;
|
||||
DefaultDimSource: List of [Dictionary of [Integer, Code[20]]];
|
||||
GlobalDim2Code: Code[20];
|
||||
begin
|
||||
// Recompute from scratch (InheritFromDimSetID = 0) whether or not the
|
||||
// customer lookup succeeds. Passing the existing "Dimension Set ID"
|
||||
// here would inherit dimensions from whichever record the document
|
||||
// was previously linked to, and exiting early on a failed Get would
|
||||
// leave that same stale data in place — both defeat the point of
|
||||
// this procedure. Clearing the shortcut field and recomputing with
|
||||
// an empty source list (when the customer doesn't exist) correctly
|
||||
// clears the document's dimensions instead of leaving old ones.
|
||||
"Shortcut Dimension 1 Code" := '';
|
||||
if Customer.Get("Customer No.") then
|
||||
DimMgt.AddDimSource(DefaultDimSource, Database::Customer, "Customer No.");
|
||||
"Dimension Set ID" :=
|
||||
DimMgt.GetDefaultDimID(
|
||||
DefaultDimSource, '', "Shortcut Dimension 1 Code", GlobalDim2Code, 0, 0);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [dimensions, dimensionmanagement, global-dimension, shortcut-dimension, default-dimension, validatedimvaluecode, getdefaultdimid]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Wire dimension support through DimensionManagement, not ad hoc fields
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Adding dimension support to a custom table is not just a matter of adding a `Code[20]` field, and master tables and document/transactional tables wire into `Codeunit "Dimension Management"` through two different models — treating them as one mechanism is itself the mistake this article corrects:
|
||||
|
||||
- **Master data** (a custom master table, e.g. "Course") persists **Default Dimension** records: each shortcut dimension field validates through `ValidateDimValueCode`, then the result is saved via `SaveDefaultDim`, and `DeleteDefaultDim` removes them again in `OnDelete`. Both `ValidateDimValueCode` and `SaveDefaultDim` take the shortcut dimension *number* (1-8, matching `General Ledger Setup`'s "Shortcut Dimension N Code" fields) as their first/third argument respectively — not the AL field ID of the table field being validated. The master record itself carries no `Dimension Set ID` field.
|
||||
- **Transactional/document data** (a custom document or journal-line table) carries a single **`Dimension Set ID`** field — a pointer to a shared, deduplicated set of dimension values in `Dimension Set Entry`, assembled from whatever the document inherited plus whatever the user overrode. A document does not acquire that ID by calling `SaveDefaultDim`; it builds a source list with `AddDimSource` (naming the related master table and its key, e.g. `Database::Customer`), then calls `GetDefaultDimID` to compute a new `Dimension Set ID` that inherits the master's Default Dimension records. Editing a shortcut dimension field directly on the document validates through `ValidateShortcutDimValues`, which updates the same `Dimension Set ID` in place rather than writing a separate Default Dimension record.
|
||||
|
||||
Skipping the model that actually matches the table's kind produces a field that looks correct in the designer but silently fails to save, validate, or carry through to postings — or, for a document, one that never picks up the customer's/vendor's own dimensions at all.
|
||||
|
||||
## Best Practice
|
||||
|
||||
For a master table, validate each shortcut dimension field through `ValidateDimValueCode`, save the result with `SaveDefaultDim`, and delete the matching Default Dimension records in `OnDelete`.
|
||||
|
||||
For a document table, when the field that attaches the document to a master record changes (e.g. `Customer No.`), call `AddDimSource` naming that master table and key, then `GetDefaultDimID` to compute the document's new `Dimension Set ID`, inheriting the master's Default Dimension records. Pass `0` for `GetDefaultDimID`'s `InheritFromDimSetID` argument in this case — passing the document's *existing* `Dimension Set ID` instead inherits whatever dimensions were already in it, so a value the previous linked record supplied can survive into the new one even where the new record has no default for that dimension. Run this same recompute — clear the shortcut field, call `GetDefaultDimID` with no source added — when the lookup on the new key fails (blank or an invalid value), too: exiting early instead leaves the previous record's dimensions in place, which is the same staleness bug the `InheritFromDimSetID = 0` rule exists to prevent. Validate the document's own Shortcut Dimension fields through `ValidateShortcutDimValues`, which updates that same `Dimension Set ID` in place rather than persisting a separate Default Dimension record.
|
||||
|
||||
See sample: [`dimension-management-wiring.good.al`](dimension-management-wiring.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding a dimension-looking field with only a `TableRelation` to Dimension Value, and no call into `DimensionManagement` at all. The field accepts input but never becomes a real Default Dimension record, so it does not validate against blocked values and does not flow into postings.
|
||||
|
||||
See sample: [`dimension-management-wiring.bad.al`](dimension-management-wiring.bad.al).
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
table 50100 "Period Stats"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Period Start"; Date) { }
|
||||
field(2; Flow; Enum "Some Flow") { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
// Table already shipped with key(PK; "Period Start").
|
||||
// Adding Flow here breaks every upgrade with AS0009.
|
||||
key(PK; Flow, "Period Start") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
table 50100 "Period Stats"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Period Start"; Date) { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
key(PK; "Period Start") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
table 50101 "Period Stats By Flow"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "Period Start"; Date) { }
|
||||
field(2; Flow; Enum "Some Flow") { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
key(PK; Flow, "Period Start") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [primary-key, clustered-key, table-design, appsource, breaking-change, schema-upgrade]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Never Change a Published Table's Primary or Clustered Key Field List
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
Once a table has shipped — to AppSource, or to any customer environment that has already upgraded onto it — its primary key, and any other key marked `Clustered = true`, is frozen. This includes adding a field to the key, not only removing or reordering one: Business Central identifies existing rows by their key value, so any change to which fields compose that key invalidates every row already stored under the old shape, and the platform's upgrade validation rejects it outright (`AS0009`). This is easy to trip over because it doesn't look like the well-known "don't delete a field" mistake — the field being added is often brand new, and folding a new discriminating dimension straight into the existing key feels like the natural, un-denormalized way to model it. On an unpublished table that is correct; on a published one it is a breaking schema change regardless of which direction the field list changed, and there is no in-place fix once the upgrade is rejected, only reverting the key to its published shape.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Leave a published table's key exactly as shipped. Model a new discriminating dimension as a separate table with its own key instead of adding a field to the existing key, and branch orchestration code by the new dimension rather than filtering one shared table on an extra key field.
|
||||
|
||||
See sample: [`do-not-change-primary-key.good.al`](do-not-change-primary-key.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding a field to a published table's primary or clustered key to distinguish a new case. This fails AppSource validation or any customer upgrade with `AS0009` as soon as rows already exist under the old key shape, whether the field is being added, removed, or reordered.
|
||||
|
||||
See sample: [`do-not-change-primary-key.bad.al`](do-not-change-primary-key.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
table 50603 "Sample Order Header Bad"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; "Document Date"; Date)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnInsert()
|
||||
var
|
||||
SalesSetup: Record "Sales & Receivables Setup";
|
||||
NoSeries: Codeunit "No. Series";
|
||||
begin
|
||||
"Document Date" := WorkDate();
|
||||
|
||||
if "No." = '' then begin
|
||||
SalesSetup.Get();
|
||||
SalesSetup.TestField("Order Nos.");
|
||||
"No." := NoSeries.GetNextNo(SalesSetup."Order Nos.");
|
||||
end;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
table 50602 "Sample Order Header Good"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20])
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
field(2; "Document Date"; Date)
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
}
|
||||
}
|
||||
|
||||
trigger OnInsert()
|
||||
var
|
||||
SalesSetup: Record "Sales & Receivables Setup";
|
||||
NoSeries: Codeunit "No. Series";
|
||||
begin
|
||||
if "No." = '' then begin
|
||||
SalesSetup.Get();
|
||||
SalesSetup.TestField("Order Nos.");
|
||||
"No." := NoSeries.GetNextNo(SalesSetup."Order Nos.");
|
||||
end;
|
||||
|
||||
InitRecord();
|
||||
end;
|
||||
|
||||
procedure InitRecord()
|
||||
begin
|
||||
OnBeforeInitRecord(Rec);
|
||||
"Document Date" := WorkDate();
|
||||
OnAfterInitRecord(Rec);
|
||||
end;
|
||||
|
||||
[IntegrationEvent(false, false)]
|
||||
local procedure OnBeforeInitRecord(var SampleOrderHeader: Record "Sample Order Header Good")
|
||||
begin
|
||||
end;
|
||||
|
||||
[IntegrationEvent(false, false)]
|
||||
local procedure OnAfterInitRecord(var SampleOrderHeader: Record "Sample Order Header Good")
|
||||
begin
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [document-header, initrecord, number-series, default-values, oninsert, initialization]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Initialize document defaults in `InitRecord` after assigning the number
|
||||
|
||||
## Description
|
||||
|
||||
Business Central document headers assign their number series first and then call an `InitRecord` procedure that owns the remaining business defaults, such as posting and document dates. Keeping that sequence and extensibility point makes initialization consistent for every creation path and lets extensions subscribe around one documented operation. Defaults scattered across page triggers or unrelated helpers can differ between UI, API, test, and background creation.
|
||||
|
||||
## Best Practice
|
||||
|
||||
In the document table's insert path, assign the document number and then call `InitRecord`. Keep the default assignments in that procedure and expose narrow before/after events when other extensions must participate.
|
||||
|
||||
See sample: [`initialize-document-defaults-in-initrecord.good.al`](initialize-document-defaults-in-initrecord.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Assigning document defaults in a page trigger, or scattering them directly through `OnInsert` with no `InitRecord` boundary. Non-page creation paths can then miss the defaults, and extensions have no stable initialization hook.
|
||||
|
||||
See sample: [`initialize-document-defaults-in-initrecord.bad.al`](initialize-document-defaults-in-initrecord.bad.al).
|
||||
|
||||
## Reference
|
||||
|
||||
[Use the InitRecord function](https://learn.microsoft.com/en-us/training/modules/use-document-standards-business-central/3-use-initrecord-function)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50130 "Sample Item Ledger Lookup"
|
||||
{
|
||||
procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry")
|
||||
var
|
||||
LibrarySales: Codeunit "Library - Sales";
|
||||
InvoiceNo: Code[20];
|
||||
begin
|
||||
InvoiceNo := LibrarySales.PostSalesDocument(SalesHeader, true, true);
|
||||
ItemLedgerEntry.SetRange("Document No.", InvoiceNo);
|
||||
if ItemLedgerEntry.FindSet() then;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
codeunit 50130 "Sample Item Ledger Lookup"
|
||||
{
|
||||
procedure GetPostedItemLedgerEntries(var SalesHeader: Record "Sales Header"; var ItemLedgerEntry: Record "Item Ledger Entry")
|
||||
var
|
||||
LibrarySales: Codeunit "Library - Sales";
|
||||
ShippingNo: Code[20];
|
||||
begin
|
||||
LibrarySales.PostSalesDocument(SalesHeader, true, true);
|
||||
ShippingNo := SalesHeader."Last Shipping No.";
|
||||
ItemLedgerEntry.SetRange("Document No.", ShippingNo);
|
||||
if ItemLedgerEntry.FindSet() then;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [item-ledger-entry, document-no, last-shipping-no, ship-and-invoice, posting, sales-order]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# After Ship-and-Invoice posting, Item Ledger Entry carries the shipment document number
|
||||
|
||||
## Description
|
||||
|
||||
Posting a sales order with both Ship and Invoice in one call creates the Item Ledger Entry during the shipment leg of that combined post, so the entry's `Document No.` is stamped with the value assigned to the shipment — `Sales Header."Last Shipping No."` — not the posted sales invoice number the posting call returns. Code that filters Item Ledger Entry by the invoice number instead finds nothing: `SetRange`/`FindSet` simply return zero rows, with no error to signal the mistake.
|
||||
|
||||
## Best Practice
|
||||
|
||||
After posting a sales order with Ship and Invoice together, read `SalesHeader."Last Shipping No."` (populated during the post) and filter Item Ledger Entry by that value, not by the invoice number the posting routine returns.
|
||||
|
||||
See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.good.al`](item-ledger-entry-document-no-follows-last-shipping-no.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Filtering Item Ledger Entry by the posted sales invoice number after a combined Ship-and-Invoice post. The filter compiles and runs without error but matches zero rows, because the entry belongs to the shipment leg of the posting, not the invoice leg.
|
||||
|
||||
See sample: [`item-ledger-entry-document-no-follows-last-shipping-no.bad.al`](item-ledger-entry-document-no-follows-last-shipping-no.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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [price-calculation, price-source, onafteraddsources, recalculation, pricing]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# A new price source needs both a calculation candidate and a recalculation trigger
|
||||
|
||||
## Description
|
||||
|
||||
Making a custom field usable as a price source on a sales line is two
|
||||
separate, independent pieces of wiring, and doing only one produces a
|
||||
line that looks like it's using the new source without ever actually
|
||||
being priced by it. `codeunit "Sales Line - Price"` publishes
|
||||
`OnAfterAddSources(SalesHeader: Record "Sales Header"; SalesLine: Record
|
||||
"Sales Line"; PriceType: Enum "Price Type"; var PriceSourceList: Codeunit
|
||||
"Price Source List")` — subscribing here and calling
|
||||
`PriceSourceList.Add(SourceType, SourceNo)` makes the source a candidate
|
||||
the calculation considers. But nothing about that subscription causes
|
||||
the price to be *recalculated* when the source field's value changes on
|
||||
an existing line. That's the second, separate piece, and it needs to be
|
||||
wired correctly: `Sales Line`'s `procedure
|
||||
UpdateUnitPriceByField(CalledByFieldNo: Integer)` only recalculates if
|
||||
the field was already *planned* — internally it exits immediately unless
|
||||
`procedure PlanPriceCalcByField(CurrPriceFieldNo: Integer)` was already
|
||||
called for that same field number. Calling `UpdateUnitPriceByField` on
|
||||
its own, without a matching `PlanPriceCalcByField` call first, compiles
|
||||
fine and looks correct, but silently recalculates nothing. `Sales Line`
|
||||
also exposes `procedure UpdateUnitPrice(CalledByFieldNo: Integer)`, a
|
||||
convenience wrapper that does both steps in the right order (plan, then
|
||||
update) in one call — this is the method the base app itself calls from
|
||||
*outside* `Sales Line` to trigger recalculation for a field it just
|
||||
changed (see `Inventory/Item/Catalog/ItemReferenceManagement.Codeunit.al`:
|
||||
`SalesLine.UpdateUnitPrice(SalesLine.FieldNo("Item Reference No."))`), and
|
||||
it's what a custom price source field's own trigger should call too — the
|
||||
same way Microsoft's own Location example is wired from a `Sales Line`
|
||||
validation event, not from the price source registration itself.
|
||||
|
||||
Add the source without wiring recalculation, and the failure hides
|
||||
easily: a *new* line still prices correctly, because the field already
|
||||
holds its value when calculation first runs on insert. The gap only
|
||||
shows up when someone *changes* the source field's value on an existing
|
||||
line — the price silently keeps its old value until something unrelated
|
||||
happens to trigger recalculation.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Wire both halves together whenever a field becomes a price source: an
|
||||
`OnAfterAddSources` subscriber that adds it via `PriceSourceList.Add`, and
|
||||
a trigger on the field itself (its own `OnValidate`, or a matching
|
||||
`OnAfterValidate` integration event) that calls
|
||||
`SalesLine.UpdateUnitPrice(SalesLine.FieldNo(<TheField>))`. Calling
|
||||
`UpdateUnitPriceByField` directly, without first calling
|
||||
`PlanPriceCalcByField` for that same field number, is *not* equivalent —
|
||||
it exits immediately and recalculates nothing. `UpdateUnitPrice` does
|
||||
both calls, in the correct order, in one step.
|
||||
|
||||
See sample: [`new-price-source-must-add-candidate-and-trigger-recalculation.good.al`](new-price-source-must-add-candidate-and-trigger-recalculation.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Subscribing to `OnAfterAddSources` to register a custom field as a price
|
||||
source, without also triggering recalculation (via `UpdateUnitPrice`, or
|
||||
the `PlanPriceCalcByField` + `UpdateUnitPriceByField` pair) from that
|
||||
field's own validation. The field is a genuine, working calculation
|
||||
candidate — new lines price correctly — but editing the field on an
|
||||
existing line leaves the unit price stale, with nothing to indicate why.
|
||||
|
||||
See sample: [`new-price-source-must-add-candidate-and-trigger-recalculation.bad.al`](new-price-source-must-add-candidate-and-trigger-recalculation.bad.al).
|
||||
|
||||
## Source
|
||||
|
||||
BCApps (`src/Layers/W1/BaseApp/`): `Sales/Pricing/SalesLinePrice.Codeunit.al`
|
||||
(`local procedure OnAfterAddSources(SalesHeader: Record "Sales Header";
|
||||
SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var
|
||||
PriceSourceList: Codeunit "Price Source List")`); `Pricing/Source/PriceSourceList.Codeunit.al`
|
||||
(`procedure Add(SourceType: Enum "Price Source Type"; SourceNo: Code[20])`);
|
||||
`Sales/Document/SalesLine.Table.al` (`procedure
|
||||
PlanPriceCalcByField(CurrPriceFieldNo: Integer)`; `procedure
|
||||
UpdateUnitPrice(CalledByFieldNo: Integer)`; `procedure
|
||||
UpdateUnitPriceByField(CalledByFieldNo: Integer)`, which exits immediately
|
||||
unless `FieldCausedPriceCalculation` already equals `CalledByFieldNo` —
|
||||
the state `PlanPriceCalcByField` sets). External, idiomatic use of the
|
||||
one-call form: `Inventory/Item/Catalog/ItemReferenceManagement.Codeunit.al`
|
||||
(`SalesLine.UpdateUnitPrice(SalesLine.FieldNo("Item Reference No."))`).
|
||||
|
||||
Microsoft Learn, "Extending Price Calculations" (Location example): "To
|
||||
recalculate the price, we can subscribe to events that pass the sales
|
||||
line by reference... We'll call the UpdateUnitPriceByLocationCode()
|
||||
method, which is a simplified version of the UpdateUnitPriceByField()
|
||||
method... To add the location in the source list for price calculations,
|
||||
we'll subscribe to the OnAfterAddSources event of Codeunit 'Sales Line -
|
||||
Price,' and add the Location Code as a source."
|
||||
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-extending-best-price-calculations)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
table 50111 "Sample Item Card"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(50; Picture; BLOB)
|
||||
{
|
||||
Caption = 'Picture';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
table 50110 "Sample Item Card"
|
||||
{
|
||||
fields
|
||||
{
|
||||
field(1; "No."; Code[20]) { }
|
||||
field(50; Picture; Media)
|
||||
{
|
||||
Caption = 'Picture';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [blob, media, mediaset, picture-field, image-field, table-design]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Pictures must be stored in a Media/MediaSet field, not BLOB
|
||||
|
||||
## Description
|
||||
|
||||
`BLOB` is still a valid AL field type for arbitrary binary data, but it is
|
||||
not the right choice for storing pictures or images. The current
|
||||
recommendation is the `Media` field type for a single image, or
|
||||
`MediaSet` when a record needs several independent images (e.g. multiple
|
||||
product photos) — `MediaSet` is a collection of separately-imported media
|
||||
objects, each with its own identity; it does not generate resized variants
|
||||
or thumbnails on its own, and displaying more than one item still requires
|
||||
custom page handling. Media/MediaSet integrate with the platform's
|
||||
picture control and media repository, which a plain `BLOB` field does not
|
||||
— but any derived preview or thumbnail image still has to be generated
|
||||
explicitly and stored in its own field, regardless of which type holds the
|
||||
source image.
|
||||
|
||||
`BLOB` remains the correct choice for genuinely arbitrary binary payloads
|
||||
that are not images and don't benefit from the media pipeline (e.g. a raw
|
||||
file attachment blob unrelated to picture rendering).
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `Media` for a single image, or `MediaSet` for multiple independent
|
||||
images, for any field that holds a picture.
|
||||
|
||||
See sample: [`pictures-must-use-media-not-blob.good.al`](pictures-must-use-media-not-blob.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A `BLOB` field named "Picture" compiles and stores the image bytes, but
|
||||
it misses the picture control integration and media repository that a
|
||||
`Media`/`MediaSet` field provides for free — the anti pattern is choosing
|
||||
`BLOB` for image storage out of habit rather than recognizing that the
|
||||
field is holding a picture, not generic binary data. A related anti
|
||||
pattern: assuming `MediaSet` gives automatic image variants or thumbnails
|
||||
because it sounds like a collection with derived versions — it is only a
|
||||
collection of independently-imported media objects.
|
||||
|
||||
See sample: [`pictures-must-use-media-not-blob.bad.al`](pictures-must-use-media-not-blob.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;
|
||||
}
|
||||
|
|
@ -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).
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
codeunit 50601 "Directed Rounding Bad"
|
||||
{
|
||||
procedure FloorAmount(Value: Decimal; Precision: Decimal): Decimal
|
||||
begin
|
||||
// For negative values, '<' rounds toward zero rather than toward negative infinity.
|
||||
exit(Round(Value, Precision, '<'));
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50600 "Directed Rounding Good"
|
||||
{
|
||||
procedure RoundAmount(Value: Decimal; Precision: Decimal; IncreaseMagnitude: Boolean): Decimal
|
||||
begin
|
||||
if IncreaseMagnitude then
|
||||
exit(Round(Value, Precision, '>'));
|
||||
|
||||
exit(Round(Value, Precision, '<'));
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [round, rounding, direction, precision, negative-decimal, amount]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# `Round` direction symbols follow magnitude, not mathematical ordering
|
||||
|
||||
## Description
|
||||
|
||||
AL's `Round(Number, Precision, Direction)` uses `'>'` to round away from zero and `'<'` to round toward zero. For a negative value this reverses mathematical ordering: `Round(-1234.56789, 0.001, '<')` returns `-1234.567`, while direction `'>'` returns `-1234.568`. Code that treats the symbols as mathematical ceiling and floor produces sign-dependent amount errors, commonly on credit documents and negative adjustments.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Choose the direction from the business meaning: `'>'` increases absolute magnitude and `'<'` decreases absolute magnitude for both positive and negative values. Include positive and negative cases whenever a directed rounding rule is tested.
|
||||
|
||||
See sample: [`round-direction-symbols-use-magnitude.good.al`](round-direction-symbols-use-magnitude.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using `'<'` as a mathematical floor or `'>'` as a mathematical ceiling. The result looks correct for positive amounts but moves in the opposite mathematical direction for negative amounts.
|
||||
|
||||
See sample: [`round-direction-symbols-use-magnitude.bad.al`](round-direction-symbols-use-magnitude.bad.al).
|
||||
|
||||
## Reference
|
||||
|
||||
[Use the Round function](https://learn.microsoft.com/en-us/training/modules/use-document-standards-business-central/4a-use-round-function)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
table 50121 "Sample Ledger Entry"
|
||||
{
|
||||
fields
|
||||
{
|
||||
// Anti-pattern: a Ledger table's key must never be user-editable.
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Posting Date"; Date) { }
|
||||
field(3; Amount; Decimal) { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
// No AutoIncrement, no guard against manual insert/delete — a user or
|
||||
// integration can renumber or remove entries, breaking the Ledger
|
||||
// type's audit-trail guarantee.
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
table 50120 "Sample Ledger Entry"
|
||||
{
|
||||
fields
|
||||
{
|
||||
// Ledger primary key: Integer "Entry No.", set only by posting.
|
||||
field(1; "Entry No."; Integer) { AutoIncrement = true; }
|
||||
field(2; "Posting Date"; Date) { }
|
||||
field(3; Amount; Decimal) { }
|
||||
}
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
// No user-facing Insert/Delete/Modify path is exposed; rows are
|
||||
// created exclusively by the posting routine.
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [tables, table-design, naming-conventions, primary-key, master-table, ledger-table, journal-table, register-table, document-table, setup-table, subsidiary-table, supplemental-table]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Tables must match one of Business Central's nine table-type conventions
|
||||
|
||||
## Description
|
||||
|
||||
Business Central's Base Application follows nine recurring table types —
|
||||
Master, Supplemental, Subsidiary, Ledger, Register, Journal, Document,
|
||||
Document History, and Setup. Each type fixes a naming pattern, a
|
||||
primary-key shape, and a set of associated pages. A new or extended table
|
||||
whose design doesn't match the conventions of its own type is either
|
||||
misclassified or built inconsistently with the rest of the application,
|
||||
and should be flagged in review even if it compiles. Before assigning a
|
||||
primary key or naming a new table, first identify which of the nine types
|
||||
it is — that answer fixes almost every other design decision.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Match the table's design to its type:
|
||||
|
||||
1. **Master** (Customer, Item) — one record is the subject; primary key
|
||||
`Code[20]` named `No.`; description field in `DataCaptionFields`; Card +
|
||||
List (+ Statistics) pages.
|
||||
2. **Supplemental** (Currency, Language) — used across functional areas;
|
||||
primary key `Code[10]` named `Code`; one List page, plural name, set as
|
||||
`LookupPageID`.
|
||||
3. **Subsidiary** (Item Vendor) — subsidiary to a Master/Supplemental
|
||||
table; primary key is the parent key field(s), optionally + `Line No.`;
|
||||
page shape depends on whether the table carries its own identity: a
|
||||
pure parent-join table (Item Vendor) typically gets a plain List page
|
||||
filtered by the calling page, while a subsidiary table that supplements
|
||||
a master record with its own identity — parent key + own code, e.g.
|
||||
Ship-to Address, Customer/Vendor Bank Account — commonly gets a
|
||||
List+Card pair instead, for direct editing of that record.
|
||||
4. **Ledger** (Cust. Ledger Entry) — transactional record of a functional
|
||||
area; primary key `Integer` `Entry No.`, always auto-generated by
|
||||
posting, never user-editable, no free add/delete; List page as
|
||||
`LookupPageID`/`DrillDownPageID`.
|
||||
5. **Register** (G/L Register) — table of contents for its Ledger, one row
|
||||
per posting run; primary key `Integer` `No.`, auto-generated; carries
|
||||
`From Entry No.`/`To Entry No.`; List page with a link to the Ledger.
|
||||
6. **Journal** (Resource Journal Line) — where users enter data before
|
||||
posting to a Ledger; primary key Template + Batch + `Integer` `Line No.`;
|
||||
Worksheet page with `AutoSplitKey`, a Posting action, and a link to the
|
||||
Ledger.
|
||||
7. **Document** (Sales Header/Line) — posts to Ledgers via Journals, not
|
||||
directly; Header primary key `Code[20]` `No.` (or + `Option Document
|
||||
Type`); Line primary key = Header key renamed `<Document> No.` +
|
||||
`Integer Line No.`; Document/Card page with a Posting action and a lines
|
||||
subpage.
|
||||
8. **Document History** (Posted Sales Invoice Header/Line) — posted copy of
|
||||
a Document table, created during posting; mirrors the source table's
|
||||
fields; never user-editable; same page shape but the Line-equivalent is
|
||||
a List page, not a Worksheet.
|
||||
9. **Setup** (General Ledger Setup) — exactly one record for a functional
|
||||
area; primary key `Code[10]` named `Primary Key`, always blank; one page
|
||||
with the key field hidden, whose `OnOpenPage` creates the singleton the
|
||||
first time it's opened (`Reset()` → `Get()` → if not found, `Init()` →
|
||||
`Insert()`) rather than assuming the record pre-exists.
|
||||
|
||||
A table named "Setup" that holds more than one record follows Subsidiary
|
||||
rules instead — the name alone is not proof of type. When a table's
|
||||
identity can't be resolved from its definition alone (e.g. a "Setup"-named
|
||||
table with a real business-field key and no page), say so explicitly
|
||||
rather than forcing a classification; settling it requires checking actual
|
||||
row cardinality or call sites, not just the object definition.
|
||||
|
||||
These nine types cover Business Central's *business-record* tables — they
|
||||
are not an exhaustive catalogue of every legitimate table shape. A
|
||||
temporary/buffer table, a work queue, a log or telemetry table, a
|
||||
cross-reference/mapping table with no business meaning of its own, or a
|
||||
staging/working table used only inside one process is not required to fit
|
||||
any of the nine, and forcing one into the nearest-looking type (usually
|
||||
Ledger, because it has an `Integer` key, or Subsidiary, because it has a
|
||||
composite key) produces a harmful redesign recommendation for a table that
|
||||
was never meant to carry that type's guarantees. Apply this rule only when
|
||||
the table's name, fields, or usage genuinely establish it as one of the
|
||||
nine business-record types; when nothing points that way, this rule simply
|
||||
does not apply — that is not the same as an unresolved classification.
|
||||
|
||||
See sample: [`table-design-must-match-bc-table-type-conventions.good.al`](table-design-must-match-bc-table-type-conventions.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A table that mixes conventions from two types — for example, a "Ledger"
|
||||
table with a user-editable primary key that lets users freely insert or
|
||||
delete rows — is not "flexible", it is either misclassified or has skipped
|
||||
a design step. A Ledger table's `Entry No.` must come only from the
|
||||
posting routine; exposing it as an editable field breaks the type's core
|
||||
guarantee that entries are an immutable, sequential audit trail.
|
||||
|
||||
See sample: [`table-design-must-match-bc-table-type-conventions.bad.al`](table-design-must-match-bc-table-type-conventions.bad.al).
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50100 "Tax Posting Helper"
|
||||
{
|
||||
procedure GetTaxAccount(var Setup: Record "Sales & Receivables Setup"): Code[20]
|
||||
var
|
||||
DefaultTaxAccountTxt: Label 'DEFAULT-TAX';
|
||||
begin
|
||||
Setup.Get();
|
||||
if Setup."Tax Account No." <> '' then
|
||||
exit(Setup."Tax Account No.");
|
||||
exit(DefaultTaxAccountTxt);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
codeunit 50100 "Tax Posting Helper"
|
||||
{
|
||||
procedure GetTaxAccount(var Setup: Record "Sales & Receivables Setup"): Code[20]
|
||||
begin
|
||||
Setup.Get();
|
||||
Setup.TestField("Tax Account No.");
|
||||
exit(Setup."Tax Account No.");
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [testfield, setup-table, configuration, mandatory-field, silent-fallback, correctness]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# TestField a Setup-Table Value Before Using It in a Correctness-Critical Branch
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
A procedure that reads a field from a setup or configuration table inside a branch where the surrounding logic has already decided that value is required needs to guard against it being blank. A common anti-pattern silently treats "blank" as "feature not wanted": it checks the field for emptiness and falls through to a default instead of raising an error. `Get()` succeeding on the setup record only proves the record exists, not that the specific field was ever configured, so the fallback path makes a missing configuration indistinguishable from a deliberate one — and the wrong outcome, especially in financial, tax, or compliance postings, surfaces silently rather than as a crash a tester would notice.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Once a business rule has decided that a setup-table field's value is required for a branch to behave correctly, call `TestField` on it before use, even though a plain read would "work" by returning a blank or zero without erroring. Write a test that blanks the setup field and asserts the resulting error, so the guard itself is verified rather than merely present.
|
||||
|
||||
See sample: [`testfield-required-setup-field.good.al`](testfield-required-setup-field.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Reading a required setup-table field behind a presence check that falls through to a default value instead of erroring. This looks defensive because it never crashes, but it converts "administrator forgot to configure this" into "system silently did something else" — worse than a hard failure, because nobody is told anything went wrong.
|
||||
|
||||
See sample: [`testfield-required-setup-field.bad.al`](testfield-required-setup-field.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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`](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.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// Both fields guarded the same way, out of habit rather than analysis.
|
||||
if Customer.Get(SalesHeader."Sell-to Customer No.") then
|
||||
CustomerHomePage := Customer."Home Page"; // low blast radius - fine
|
||||
|
||||
// but the same pattern, unexamined, was also applied here:
|
||||
if SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo) then
|
||||
VATBusPostingGroup := SalesHeader."VAT Bus. Posting Group"
|
||||
else
|
||||
VATBusPostingGroup := '';
|
||||
// High blast radius: silently wrong VAT posting group reaches posting
|
||||
// with no error, no TestField, and no reviewer in the loop.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// Low blast radius: guard, with an explicit chosen fallback.
|
||||
if Customer.Get(SalesHeader."Sell-to Customer No.") then
|
||||
CustomerHomePage := Customer."Home Page"
|
||||
else
|
||||
CustomerHomePage := '';
|
||||
// Blank is an acceptable, deliberately-considered default here - the field
|
||||
// is purely a display convenience and a reviewer sees it before the document ships.
|
||||
// It is assigned explicitly, though, not left to whatever the variable
|
||||
// happened to hold before this lookup ran.
|
||||
|
||||
// High blast radius: let it fail loud, because this feeds posted VAT.
|
||||
SalesHeader.Get(SalesHeader."Document Type"::Order, DocumentNo);
|
||||
SalesHeader.TestField("VAT Bus. Posting Group");
|
||||
VATBusPostingGroup := SalesHeader."VAT Bus. Posting Group";
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: error-handling
|
||||
keywords: [defensive-programming, offensive-programming, fail-fast, blast-radius, guarded-lookup]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Match defensive vs. offensive error handling to the blast radius of being wrong
|
||||
|
||||
## Description
|
||||
|
||||
Whether code should guard gracefully (defensive) or fail loudly (offensive/fail-fast) is not a matter of habit or a blanket house style — it depends on what happens downstream if the guarded condition is silently defaulted or skipped. Treating every missing value the same way, defensively or offensively, is itself the anti-pattern: uniform defensiveness hides the failures that matter most, while uniform fail-fast turns ordinary, expected absence into unnecessary crashes. Two fields can look structurally identical — both read from a related record, both potentially missing — and still deserve opposite treatment depending on what they feed.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Trace what a silently-defaulted or skipped value actually reaches before deciding how to guard it. If it reaches a posted ledger amount, a tax/VAT calculation, a quantity or price actually used in a transaction, or a legally/compliance-facing output, code offensively: let the lookup fail loud (`TestField`, an unguarded `Get()` expected to always succeed, or an explicit `Error`) so a human sees the problem before anything posts. If it is cosmetic, informational, or easily corrected after the fact (a display field, an optional UI enhancement, a report not yet run), code defensively — but the fallback must be an explicit, deliberately-chosen, named business value, never a blank or zero that is merely the datatype default. When genuinely unsure which category a field falls into, that is a question to resolve explicitly with whoever owns the requirement, not a coin flip.
|
||||
|
||||
See sample: [`defensive-vs-offensive-code-must-match-blast-radius.good.al`](defensive-vs-offensive-code-must-match-blast-radius.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Guarding two fields the same way purely out of habit, without analyzing what each one feeds. A low-blast-radius field, such as a customer's home page URL shown only for convenience on a printed document, and a high-blast-radius field, such as the VAT posting group that determines VAT actually applied to a posted transaction, are both wrapped in the same `if Header.Get(...) then ... else` pattern with a blank/zero fallback — leaving the posting-critical field free to post with a silently wrong value. A VAT registration number is not a safe stand-in for the low-risk side of this example: it is legally relevant, often validated, and can feed external VAT services or mandated document output, so it belongs on the offensive/fail-fast side alongside the posting group, not next to it as the "safe" contrast.
|
||||
|
||||
See sample: [`defensive-vs-offensive-code-must-match-blast-radius.bad.al`](defensive-vs-offensive-code-must-match-blast-radius.bad.al).
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
codeunit 50101 "Sample Web Service Caller"
|
||||
{
|
||||
procedure CallExternalService()
|
||||
var
|
||||
ErrorLogEntry: Record "Sample Error Log";
|
||||
begin
|
||||
// BUG: the log write happens inside the same transaction as the
|
||||
// risky call, using the same Record instance as the caller.
|
||||
if not TryCallService() then begin
|
||||
ErrorLogEntry.Init();
|
||||
ErrorLogEntry."Error Message" := CopyStr(GetLastErrorText(), 1, 250);
|
||||
ErrorLogEntry.Insert();
|
||||
Error(GetLastErrorText());
|
||||
// Error() above rolls back this transaction - including the
|
||||
// Insert() just made. The failure is never actually logged.
|
||||
end;
|
||||
end;
|
||||
|
||||
[TryFunction]
|
||||
local procedure TryCallService()
|
||||
begin
|
||||
// ... external call that may fail ...
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
table 50100 "Sample Error Log Buffer"
|
||||
{
|
||||
TableType = Temporary;
|
||||
fields
|
||||
{
|
||||
field(1; "Call Duration (ms)"; Integer) { }
|
||||
field(2; "Error Message"; Text[250]) { }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50100 "Sample Error Log Writer"
|
||||
{
|
||||
// TableNo makes OnRun receive the Record that Session.StartSession
|
||||
// passes to the new session. This is the only data channel into that
|
||||
// session — there is no shared memory with the caller's instance.
|
||||
TableNo = "Sample Error Log Buffer";
|
||||
|
||||
trigger OnRun()
|
||||
var
|
||||
ErrorLogEntry: Record "Sample Error Log";
|
||||
begin
|
||||
ErrorLogEntry.Init();
|
||||
ErrorLogEntry."Call Duration (ms)" := Rec."Call Duration (ms)";
|
||||
ErrorLogEntry."Error Message" :=
|
||||
CopyStr(Rec."Error Message", 1, MaxStrLen(ErrorLogEntry."Error Message"));
|
||||
ErrorLogEntry.Insert(true);
|
||||
Commit();
|
||||
end;
|
||||
}
|
||||
|
||||
// Caller side: populate the buffer record, then hand it to StartSession.
|
||||
// The insert-and-commit above happens inside the started session, so it
|
||||
// survives even if the caller's own transaction rolls back afterward.
|
||||
codeunit 50101 "Sample Error Log Caller Excerpt"
|
||||
{
|
||||
procedure LogFailure(Duration: Integer; ErrorText: Text)
|
||||
var
|
||||
LogBuffer: Record "Sample Error Log Buffer" temporary;
|
||||
SessionId: Integer;
|
||||
begin
|
||||
LogBuffer."Call Duration (ms)" := Duration;
|
||||
LogBuffer."Error Message" := CopyStr(ErrorText, 1, MaxStrLen(LogBuffer."Error Message"));
|
||||
Session.StartSession(SessionId, Codeunit::"Sample Error Log Writer", CompanyName, LogBuffer);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: error-handling
|
||||
keywords: [logging, rollback, session, transaction, isolated-session, telemetry]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Log writes that must capture failures must survive transaction rollback
|
||||
|
||||
## Description
|
||||
|
||||
Inserting a log record inside the same transaction as the operation it logs looks correct until the operation errors: the transaction rolls back and takes the log entry with it. The result is a log that faithfully records every success and silently loses exactly the failures it exists to capture. This is a common blind spot in error/duration logging around web-service calls, background jobs, and other operations expected to fail sometimes.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Write any log whose purpose includes capturing failures from a transaction that is independent of the operation being logged: start an isolated session (`Session.StartSession` on a `TableNo`-scoped codeunit that only inserts the log record and commits) so the entry persists regardless of what happens to the caller's transaction. `StartSession`'s only channel for getting data into that new session is its optional `Record` parameter, delivered to the target codeunit's `OnRun` trigger — a separate session gets a fresh instantiation of the codeunit, so calling a setter procedure on a local object variable before starting the session does not populate anything in the new session's instance. Capture duration and other telemetry values in the caller, place them into the `Record` passed to `StartSession`, and do the insert-and-commit entirely inside that session's own `OnRun`. Logs that only record successful, committed work can safely stay in the main transaction; this pattern targets error and diagnostic logs specifically.
|
||||
|
||||
See sample: [`log-writes-must-survive-rollback.good.al`](log-writes-must-survive-rollback.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Inserting the error-log record in the same transaction as the risky operation, so a rollback deletes the very entry meant to explain the failure. Adding a stray `Commit` before the risky call is not a fix either — it breaks the caller's atomicity and can violate posting-routine rules. Swallowing the error to keep the log alive (running a codeunit without checking or re-raising its result) is equally wrong: the log must observe the failure, not suppress it.
|
||||
|
||||
See sample: [`log-writes-must-survive-rollback.bad.al`](log-writes-must-survive-rollback.bad.al).
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
codeunit 50100 "Transfer Request Bad"
|
||||
{
|
||||
// Self-contained demonstration of the anti pattern. Not derived from base-app source.
|
||||
procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal)
|
||||
var
|
||||
TransferRequest: Record "Transfer Request Bad";
|
||||
TransferSetup: Record "Transfer Setup Bad";
|
||||
begin
|
||||
TransferRequest.ChangeCompany(TargetCompany);
|
||||
TransferSetup.ChangeCompany(TargetCompany);
|
||||
TransferSetup.Get();
|
||||
|
||||
TransferRequest.Init();
|
||||
TransferRequest."Entry No." := NextEntryNo(TargetCompany);
|
||||
TransferRequest."Item No." := ItemNo;
|
||||
TransferRequest.Quantity := Quantity;
|
||||
// OnInsert is skipped below, so the default is copied by hand from the target company's setup.
|
||||
TransferRequest."Location Code" := TransferSetup."Default Location Code";
|
||||
// The OnAfterInsertEvent subscriber still fires, in the calling company, and grows the caller's counter.
|
||||
TransferRequest.Insert(false);
|
||||
|
||||
TransferSetup."Open Requests" += 1;
|
||||
TransferSetup.Modify();
|
||||
end;
|
||||
|
||||
local procedure NextEntryNo(TargetCompany: Text[30]): Integer
|
||||
var
|
||||
LastRequest: Record "Transfer Request Bad";
|
||||
begin
|
||||
LastRequest.ChangeCompany(TargetCompany);
|
||||
if LastRequest.FindLast() then
|
||||
exit(LastRequest."Entry No." + 1);
|
||||
exit(1);
|
||||
end;
|
||||
}
|
||||
|
||||
table 50100 "Transfer Request Bad"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; Quantity; Decimal) { }
|
||||
field(4; "Location Code"; Code[10]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
|
||||
trigger OnInsert()
|
||||
var
|
||||
TransferSetup: Record "Transfer Setup Bad";
|
||||
begin
|
||||
TransferSetup.Get();
|
||||
"Location Code" := TransferSetup."Default Location Code";
|
||||
end;
|
||||
}
|
||||
|
||||
table 50101 "Transfer Setup Bad"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Primary Key"; Code[10]) { }
|
||||
field(2; "Default Location Code"; Code[10]) { }
|
||||
field(3; "Open Requests"; Integer) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Primary Key") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50101 "Transfer Request Count Bad"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Table, Database::"Transfer Request Bad", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure CountOpenRequest(var Rec: Record "Transfer Request Bad"; RunTrigger: Boolean)
|
||||
var
|
||||
TransferSetup: Record "Transfer Setup Bad";
|
||||
begin
|
||||
TransferSetup.Get();
|
||||
TransferSetup."Open Requests" += 1;
|
||||
TransferSetup.Modify();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
codeunit 50100 "Transfer Request Good"
|
||||
{
|
||||
// Self-contained demonstration of the best practice. Not derived from base-app source.
|
||||
procedure RequestFromCompany(TargetCompany: Text[30]; ItemNo: Code[20]; Quantity: Decimal)
|
||||
var
|
||||
TransferRequest: Record "Transfer Request Good";
|
||||
SessionId: Integer;
|
||||
begin
|
||||
TransferRequest.Init();
|
||||
TransferRequest."Item No." := ItemNo;
|
||||
TransferRequest.Quantity := Quantity;
|
||||
// The insert runs inside TargetCompany, so OnInsert and the subscriber read that company's setup.
|
||||
StartSession(SessionId, Codeunit::"Transfer Request Create Good", TargetCompany, TransferRequest);
|
||||
end;
|
||||
}
|
||||
|
||||
codeunit 50102 "Transfer Request Create Good"
|
||||
{
|
||||
TableNo = "Transfer Request Good";
|
||||
|
||||
trigger OnRun()
|
||||
begin
|
||||
// "Entry No." is AutoIncrement, so concurrent background sessions in TargetCompany never race on the same value.
|
||||
Rec.Insert(true);
|
||||
end;
|
||||
}
|
||||
|
||||
table 50100 "Transfer Request Good"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Entry No."; Integer) { AutoIncrement = true; }
|
||||
field(2; "Item No."; Code[20]) { }
|
||||
field(3; Quantity; Decimal) { }
|
||||
field(4; "Location Code"; Code[10]) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Entry No.") { Clustered = true; }
|
||||
}
|
||||
|
||||
trigger OnInsert()
|
||||
var
|
||||
TransferSetup: Record "Transfer Setup Good";
|
||||
begin
|
||||
TransferSetup.Get();
|
||||
"Location Code" := TransferSetup."Default Location Code";
|
||||
end;
|
||||
}
|
||||
|
||||
table 50101 "Transfer Setup Good"
|
||||
{
|
||||
DataClassification = CustomerContent;
|
||||
|
||||
fields
|
||||
{
|
||||
field(1; "Primary Key"; Code[10]) { }
|
||||
field(2; "Default Location Code"; Code[10]) { }
|
||||
field(3; "Open Requests"; Integer) { }
|
||||
}
|
||||
|
||||
keys
|
||||
{
|
||||
key(PK; "Primary Key") { Clustered = true; }
|
||||
}
|
||||
}
|
||||
|
||||
codeunit 50101 "Transfer Request Count Good"
|
||||
{
|
||||
[EventSubscriber(ObjectType::Table, Database::"Transfer Request Good", OnAfterInsertEvent, '', false, false)]
|
||||
local procedure CountOpenRequest(var Rec: Record "Transfer Request Good"; RunTrigger: Boolean)
|
||||
var
|
||||
TransferSetup: Record "Transfer Setup Good";
|
||||
begin
|
||||
// Serializes the read-modify-write so concurrent background sessions don't lose an increment.
|
||||
TransferSetup.LockTable();
|
||||
TransferSetup.Get();
|
||||
TransferSetup."Open Requests" += 1;
|
||||
TransferSetup.Modify();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: events
|
||||
keywords: [changecompany, cross-company, runtrigger, trigger-event, subscriber, onafterinsertevent, insert, startsession, multi-company]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# ChangeCompany leaves triggers and trigger-event subscribers running in the calling company
|
||||
|
||||
> Contributions welcome — open a PR to refine or extend this article.
|
||||
|
||||
## Description
|
||||
|
||||
`ChangeCompany` redirects the data access of one record variable to another company's table. Execution context does not move with it: Microsoft Learn states that triggers still run in the current company, not in the company passed to `ChangeCompany`. Code that knows this usually reaches for `Insert(false)` and copies the trigger's work by hand from the target company's setup. That closes only half of the gap. The runtime raises the database trigger events (`OnBeforeInsertEvent`, `OnAfterInsertEvent`, and their modify, delete, and rename counterparts) on every database operation and only passes the `RunTrigger` flag to the subscriber, so every subscriber that does not exit on `RunTrigger = false` still runs, in the calling company, against the calling company's setup, number series, and companion tables. The row lands in the target company, the side effects land in the caller, and nothing reports an error. The per-row cost of the call is a separate concern, see `changecompany-in-loop-drops-caches`.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `ChangeCompany` to read. Access rights in the target company are still enforced, so reads are safe. When the goal is business data in another company, run the code in that company: `StartSession` takes a company name and runs a codeunit there, so triggers, validation, and subscribers all execute with the target company as their context. `StartSession` is a background session, not a synchronous call: the `Ok` return value reports only whether the session started, not whether the codeunit's work inside it succeeded, the caller's transaction does not extend into it, and an error raised there does not come back to the caller — it has to be logged or telemetered from inside that session. Reach for `StartSession` only for work the caller does not need to confirm before it continues; a write whose success the caller must know synchronously needs a durable status or error channel (a field the caller polls, a job queue with retry) rather than a bare `StartSession` call. Learn notes that a background session costs as much as a user session to start, so batch the work rather than starting one session per row, or let the target company process a hand-off row on its own schedule. A direct cross-company write is acceptable only as such a hand-off into a table the writing extension owns, whose triggers do not read company data and whose trigger-event subscribers exit when `RunTrigger` is false, using `Insert(false)`, `Modify(false)`, or `Delete(false)`, and never `Validate`.
|
||||
|
||||
See sample: [`changecompany-runs-triggers-in-the-calling-company.good.al`](changecompany-runs-triggers-in-the-calling-company.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
An `Insert`, `Modify`, `Delete`, or `Validate` on a record variable after `ChangeCompany(<name>)`, on a table whose triggers or trigger-event subscribers read setup, consume a number series, or write companion rows. With `RunTrigger = true` the trigger code fills the row from the caller's setup. With `RunTrigger = false` the trigger code is skipped, but the subscribers still fire in the caller, so a counter, log, or companion row maintained by a subscriber is written in the wrong company, and a caller that also updates the target by hand counts twice.
|
||||
|
||||
Detection signal: a record variable that has had `ChangeCompany` called on it with a company name and is later used with `Insert`, `Modify`, `Delete`, or `Validate`, where the table is not owned by the extension, or has triggers that read company data, or has trigger-event subscribers that do not exit on `RunTrigger = false`. Do not flag reads after `ChangeCompany`; writes with `RunTrigger = false` into an owned table whose triggers do not read company data and whose subscribers exit on `RunTrigger = false`; or `ChangeCompany()` without an argument, which points the variable back at the current company.
|
||||
|
||||
See sample: [`changecompany-runs-triggers-in-the-calling-company.bad.al`](changecompany-runs-triggers-in-the-calling-company.bad.al).
|
||||
|
||||
## See also
|
||||
|
||||
- Record.ChangeCompany method, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-changecompany-method
|
||||
- Record.Insert(Boolean) method, RunTrigger — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-insert-boolean-method
|
||||
- Record.Delete method, RunTrigger defaults to false — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/record/record-delete-method
|
||||
- OnInsert (Table) trigger, Remarks — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/table/devenv-oninsert-table-trigger
|
||||
- Event types, Database trigger events and order of event execution — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-event-types
|
||||
- OnAfterInsertEvent trigger event, RunTrigger parameter — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/triggers-auto/events/table/devenv-onafterinsertevent-table-trigger
|
||||
- Session.StartSession method, Company parameter, Remarks (background session, no UI), and Return Value (`Ok` reports whether the session started, not whether the codeunit's work succeeded) — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/methods-auto/session/session-startsession-integer-integer-string-table-method
|
||||
- AL error handling, error handling strategies: an error inside a rolled-back transaction is logged from a background session or telemetry, it does not return to the caller — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-al-error-handling
|
||||
- AutoIncrement property, Remarks: "if several transactions are performed at the same time, they will each be assigned a different number" — https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/properties/devenv-autoincrement-property
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50104 "Customer Settlement Actions"
|
||||
{
|
||||
procedure RequestApplication(CustomerEntryNo: Integer)
|
||||
var
|
||||
CustomerEntry: Record "Cust. Ledger Entry";
|
||||
begin
|
||||
RequireInteractiveSession();
|
||||
CustomerEntry.Get(CustomerEntryNo);
|
||||
CustomerEntry.TestField(Open, true);
|
||||
CustomerEntry.Open := false;
|
||||
CustomerEntry.Modify(true);
|
||||
end;
|
||||
|
||||
procedure RequestUnapplication(CustomerEntryNo: Integer)
|
||||
var
|
||||
DetailedCustomerEntry: Record "Detailed Cust. Ledg. Entry";
|
||||
begin
|
||||
RequireInteractiveSession();
|
||||
DetailedCustomerEntry.SetRange("Cust. Ledger Entry No.", CustomerEntryNo);
|
||||
DetailedCustomerEntry.SetRange("Entry Type", DetailedCustomerEntry."Entry Type"::Application);
|
||||
DetailedCustomerEntry.ModifyAll(Unapplied, true);
|
||||
end;
|
||||
|
||||
local procedure RequireInteractiveSession()
|
||||
begin
|
||||
if not GuiAllowed() then
|
||||
Error(InteractiveSessionErr);
|
||||
end;
|
||||
|
||||
var
|
||||
InteractiveSessionErr: Label 'Request settlement from an interactive session.';
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50104 "Customer Settlement Actions"
|
||||
{
|
||||
procedure RequestApplication(CustomerEntryNo: Integer)
|
||||
var
|
||||
CustomerEntry: Record "Cust. Ledger Entry";
|
||||
CustomerApplication: Codeunit "CustEntry-Apply Posted Entries";
|
||||
begin
|
||||
RequireInteractiveSession();
|
||||
CustomerEntry.Get(CustomerEntryNo);
|
||||
CustomerEntry.TestField(Open, true);
|
||||
CustomerApplication.ApplyCustEntryFormEntry(CustomerEntry);
|
||||
end;
|
||||
|
||||
procedure RequestUnapplication(CustomerEntryNo: Integer)
|
||||
var
|
||||
CustomerApplication: Codeunit "CustEntry-Apply Posted Entries";
|
||||
begin
|
||||
RequireInteractiveSession();
|
||||
CustomerApplication.UnApplyCustLedgEntry(CustomerEntryNo);
|
||||
end;
|
||||
|
||||
local procedure RequireInteractiveSession()
|
||||
begin
|
||||
if not GuiAllowed() then
|
||||
Error(InteractiveSessionErr);
|
||||
end;
|
||||
|
||||
var
|
||||
InteractiveSessionErr: Label 'Request settlement from an interactive session.';
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: finance
|
||||
keywords: [cust-ledger-entry, vendor-ledger-entry, detailed-ledger-entry, remaining-amount, application, unapplication, open, closed-by-entry-no]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Apply and unapply entries through the application workflow, not status flags
|
||||
|
||||
## Description
|
||||
|
||||
Customer/vendor settlement is a posting operation involving detailed ledger entries, not just a change to `Open` on the main entry. Remaining amounts are calculated from detailed entries. Unapplication posts correcting entries and handles application-derived effects such as discounts and currency gains/losses; deleting details or changing `Unapplied` cannot reproduce that history.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use `"CustEntry-Apply Posted Entries"` / `"VendEntry-Apply Posted Entries"` and the supported application or unapplication workflow. Let it check application dates, entry state, and application ordering. The `ApplyCustEntryFormEntry` / `ApplyVendEntryFormEntry` and `UnApply...LedgEntry` methods are **interactive**: users select and confirm the operation. They are not unattended "mark paid" APIs.
|
||||
|
||||
For programmatic posting, use the target version's public `Apply` / `PostUnApply...` APIs with properly prepared selection and `Apply Unapply Parameters`; handle cancellation and the workflow's transaction/commit behavior. `"Applying Entry"`, `"Applies-to ID"`, and `"Amount to Apply"` are legitimate application-preparation fields. Do not report their writes alone, temporary buffers, supported posting/compression internals, or unrelated operational/extension fields.
|
||||
|
||||
See sample: [`apply-ledger-entries-through-application-codeunits.good.al`](apply-ledger-entries-through-application-codeunits.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Implement customer/vendor payment matching, settlement, or reopening by directly persisting `Open`, `"Closed by Entry No."`, closure amounts/dates, or detailed-entry unapplication flags, or by deleting/rewriting detailed application amounts. Require confirmed writes to existing non-temporary customer/vendor or detailed customer/vendor entries and settlement intent. Item/inventory application records belong to SCM, not this rule. Do not suggest assigning a `Remaining Amount` FlowField as a fix.
|
||||
|
||||
This article owns fabricated application state. Use the [posted-financial-content rule](do-not-modify-or-delete-posted-ledger-entries.md) for original accounting-value corrections, not a second finding prescribing the same application fix.
|
||||
|
||||
See sample: [`apply-ledger-entries-through-application-codeunits.bad.al`](apply-ledger-entries-through-application-codeunits.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Apply and unapply customer transactions](https://learn.microsoft.com/en-us/dynamics365/business-central/receivables-how-apply-sales-transactions-manually).
|
||||
- [Apply and unapply vendor transactions](https://learn.microsoft.com/en-us/dynamics365/business-central/payables-how-apply-purchase-transactions-manually).
|
||||
- [BCApps: customer application workflow](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Receivables/CustEntryApplyPostedEntries.Codeunit.al).
|
||||
- [BCApps: vendor application workflow](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Purchases/Payables/VendEntryApplyPostedEntries.Codeunit.al).
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50105 "Update Ledger Due Dates"
|
||||
{
|
||||
procedure UpdateCustomerDueDate(EntryNo: Integer; NewDueDate: Date)
|
||||
var
|
||||
CustomerEntry: Record "Cust. Ledger Entry";
|
||||
begin
|
||||
CustomerEntry.Get(EntryNo);
|
||||
CustomerEntry.Validate("Due Date", NewDueDate);
|
||||
CustomerEntry.Modify(true);
|
||||
end;
|
||||
|
||||
procedure UpdateVendorDueDate(EntryNo: Integer; NewDueDate: Date)
|
||||
var
|
||||
VendorEntry: Record "Vendor Ledger Entry";
|
||||
begin
|
||||
VendorEntry.Get(EntryNo);
|
||||
VendorEntry.Validate("Due Date", NewDueDate);
|
||||
VendorEntry.Modify(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50105 "Update Ledger Due Dates"
|
||||
{
|
||||
procedure UpdateCustomerDueDate(EntryNo: Integer; NewDueDate: Date)
|
||||
var
|
||||
CustomerEntry: Record "Cust. Ledger Entry";
|
||||
begin
|
||||
CustomerEntry.Get(EntryNo);
|
||||
CustomerEntry.Validate("Due Date", NewDueDate);
|
||||
Codeunit.Run(Codeunit::"Cust. Entry-Edit", CustomerEntry);
|
||||
end;
|
||||
|
||||
procedure UpdateVendorDueDate(EntryNo: Integer; NewDueDate: Date)
|
||||
var
|
||||
VendorEntry: Record "Vendor Ledger Entry";
|
||||
begin
|
||||
VendorEntry.Get(EntryNo);
|
||||
VendorEntry.Validate("Due Date", NewDueDate);
|
||||
Codeunit.Run(Codeunit::"Vend. Entry-Edit", VendorEntry);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: finance
|
||||
keywords: [due-date, initial-entry-due-date, cust-entry-edit, vend-entry-edit, detailed-ledger-entry, aging]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Change posted customer/vendor due dates through the entry-edit workflow
|
||||
|
||||
## Description
|
||||
|
||||
A posted customer or vendor due date is supported editable operational data, but it is also represented by `Initial Entry Due Date` on related detailed ledger entries. Validating `Due Date` and calling `Modify(true)` on the main entry does not perform all synchronization done by `"Cust. Entry-Edit"` or `"Vend. Entry-Edit"`. The main entry and due-date-based analysis can otherwise disagree.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Fetch the existing entry, validate the proposed `Due Date`, and pass the changed record to the corresponding entry-edit codeunit, as the standard ledger pages do. Field validation enforces entry-state rules; the editor persists the supported change and synchronizes related detailed entries. Preserve both steps rather than treating table triggers as equivalent to the edit workflow.
|
||||
|
||||
This is a due-date synchronization rule, not a prohibition on all operational edits after posting. Exclude temporary buffers, extension-only fields, supported editor internals, and code that demonstrably performs the equivalent synchronization under the supported workflow. Check the actual table/routine instead of assuming every `*Entry-Edit` accepts the same fields.
|
||||
|
||||
See sample: [`change-ledger-due-dates-through-entry-edit.good.al`](change-ledger-due-dates-through-entry-edit.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Change `Due Date` on an existing non-temporary `Cust. Ledger Entry` or `Vendor Ledger Entry` and persist it with `Modify`, `Modify(true)`, or `ModifyAll` without the edit workflow or equivalent related-entry update. A preceding `Validate("Due Date", ...)` is not sufficient evidence of synchronization.
|
||||
|
||||
See sample: [`change-ledger-due-dates-through-entry-edit.bad.al`](change-ledger-due-dates-through-entry-edit.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Cust. Entry-Edit API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.sales.receivables.cust.-entry-edit).
|
||||
- [Vend. Entry-Edit API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.purchases.payables.vend.-entry-edit).
|
||||
- [BCApps: customer due-date synchronization](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Receivables/CustEntryEdit.Codeunit.al).
|
||||
- [BCApps: vendor due-date synchronization](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Purchases/Payables/VendEntryEdit.Codeunit.al).
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50103 "Change Journal Dimension"
|
||||
{
|
||||
procedure ChangeExistingDimension(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; DimensionCode: Code[20]; NewValue: Code[20])
|
||||
var
|
||||
JournalLine: Record "Gen. Journal Line";
|
||||
DimensionSetEntry: Record "Dimension Set Entry";
|
||||
begin
|
||||
JournalLine.Get(TemplateName, BatchName, LineNo);
|
||||
DimensionSetEntry.Get(JournalLine."Dimension Set ID", DimensionCode);
|
||||
DimensionSetEntry.Validate("Dimension Value Code", NewValue);
|
||||
DimensionSetEntry.Modify();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50103 "Change Journal Dimension"
|
||||
{
|
||||
procedure ChangeExistingDimension(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer; DimensionCode: Code[20]; NewValue: Code[20])
|
||||
var
|
||||
JournalLine: Record "Gen. Journal Line";
|
||||
TempDimensionSetEntry: Record "Dimension Set Entry" temporary;
|
||||
DimensionManagement: Codeunit DimensionManagement;
|
||||
begin
|
||||
JournalLine.Get(TemplateName, BatchName, LineNo);
|
||||
DimensionManagement.GetDimensionSet(TempDimensionSetEntry, JournalLine."Dimension Set ID");
|
||||
TempDimensionSetEntry.Get(JournalLine."Dimension Set ID", DimensionCode);
|
||||
TempDimensionSetEntry.Validate("Dimension Value Code", NewValue);
|
||||
TempDimensionSetEntry.Modify();
|
||||
JournalLine.Validate("Dimension Set ID", DimensionManagement.GetDimensionSetID(TempDimensionSetEntry));
|
||||
JournalLine.Modify(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: finance
|
||||
keywords: [dimension-set-entry, dimension-value-id, getdimensionset, getdimensionsetid, posted-dimensions, temporary]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Change a transaction's dimension set reference, not a shared set's membership
|
||||
|
||||
## Description
|
||||
|
||||
The same `Dimension Set ID` can be referenced by an unposted journal line and by many already-posted entries. Editing or deleting the persisted set's dimension/value rows therefore changes the meaning of unrelated transactions, including posting history. The dimension-set search tree also relies on those combinations remaining stable; a set is not a mutable child collection owned by one journal line.
|
||||
|
||||
## Best Practice
|
||||
|
||||
To change an unposted transaction's dimensions, load its set into a **temporary** `Dimension Set Entry` buffer with `DimensionManagement.GetDimensionSet`, change the buffer, and obtain a reusable ID with `GetDimensionSetID`. Validate dimension values in the buffer so `Dimension Value ID` matches the chosen value. Store the resulting ID on the transaction and synchronize its projections through that record's supported dimension validation.
|
||||
|
||||
For already-posted G/L dimensions, use the supported dimension-correction workflow rather than changing shared rows. Read-only access, temporary buffers, and standard maintenance of projection metadata such as `Global Dimension No.` are not membership changes. This rule protects dimension sets reached from general-journal, financial-document, or Finance-ledger flows. It does not own Item, Value, Capacity, Warehouse, or inventory-application record writes, or prescribe custom-table/default-dimension wiring.
|
||||
|
||||
See sample: [`do-not-edit-shared-dimension-sets.good.al`](do-not-edit-shared-dimension-sets.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Follow a general-journal, financial-document, or Finance-ledger `Dimension Set ID` to a **persistent** `Dimension Set Entry` and modify, rename, or delete its dimension/value membership in order to change that one transaction. Inspect `IsTemporary` guards, aliases, and the fields written before reporting: the same operations on a temporary working copy are expected.
|
||||
|
||||
See sample: [`do-not-edit-shared-dimension-sets.bad.al`](do-not-edit-shared-dimension-sets.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Dimension set entries overview](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-dimension-set-entries-overview).
|
||||
- [Supported G/L dimension correction](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-troubleshooting-correcting-dimensions).
|
||||
- [DimensionManagement API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.dimension.dimensionmanagement).
|
||||
- [BCApps: Dimension Set Entry and its set-ID resolver](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/Dimension/DimensionSetEntry.Table.al).
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50101 "Correct Posted Transaction"
|
||||
{
|
||||
procedure RequestTransactionReversal(EntryNo: Integer)
|
||||
var
|
||||
GLEntry: Record "G/L Entry";
|
||||
begin
|
||||
if not GuiAllowed() then
|
||||
Error(InteractiveSessionErr);
|
||||
GLEntry.Get(EntryNo);
|
||||
GLEntry.TestField("Transaction No.");
|
||||
GLEntry.Amount := -GLEntry.Amount;
|
||||
GLEntry.Modify(true);
|
||||
end;
|
||||
|
||||
procedure UpdateDescription(EntryNo: Integer; NewDescription: Text[100])
|
||||
var
|
||||
GLEntry: Record "G/L Entry";
|
||||
begin
|
||||
GLEntry.Get(EntryNo);
|
||||
GLEntry.Description := NewDescription;
|
||||
Codeunit.Run(Codeunit::"G/L Entry-Edit", GLEntry);
|
||||
end;
|
||||
|
||||
procedure ClearSimulation(var TempGLEntry: Record "G/L Entry" temporary)
|
||||
begin
|
||||
TempGLEntry.DeleteAll();
|
||||
end;
|
||||
|
||||
var
|
||||
InteractiveSessionErr: Label 'Request the reversal from an interactive session.';
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50101 "Correct Posted Transaction"
|
||||
{
|
||||
procedure RequestTransactionReversal(EntryNo: Integer)
|
||||
var
|
||||
GLEntry: Record "G/L Entry";
|
||||
ReversalEntry: Record "Reversal Entry";
|
||||
begin
|
||||
if not GuiAllowed() then
|
||||
Error(InteractiveSessionErr);
|
||||
GLEntry.Get(EntryNo);
|
||||
GLEntry.TestField("Transaction No.");
|
||||
ReversalEntry.ReverseTransaction(GLEntry."Transaction No.");
|
||||
end;
|
||||
|
||||
procedure UpdateDescription(EntryNo: Integer; NewDescription: Text[100])
|
||||
var
|
||||
GLEntry: Record "G/L Entry";
|
||||
begin
|
||||
GLEntry.Get(EntryNo);
|
||||
GLEntry.Description := NewDescription;
|
||||
Codeunit.Run(Codeunit::"G/L Entry-Edit", GLEntry);
|
||||
end;
|
||||
|
||||
procedure ClearSimulation(var TempGLEntry: Record "G/L Entry" temporary)
|
||||
begin
|
||||
TempGLEntry.DeleteAll();
|
||||
end;
|
||||
|
||||
var
|
||||
InteractiveSessionErr: Label 'Request the reversal from an interactive session.';
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: finance
|
||||
keywords: [g-l-entry, ledger-entry, reversal, audit-trail, correction, financial-content, entry-edit]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Correct posted financial content through posting workflows, not row surgery
|
||||
|
||||
## Description
|
||||
|
||||
Changing a posted entry's original amount, account, posting date, or tax amounts in place does not correct the related ledger, register, or source document. Deleting one erroneous row has the same problem. Business Central provides reversing and correcting posting workflows that retain the relationship between the original transaction and its correction; this is not a blanket prohibition on every write to a posted table.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use a supported transaction/register reversal, credit memo, or correcting journal appropriate to the original posting and its current state. Request the standard reversal workflow rather than negating a single row or setting `Reversed` yourself. Let that workflow enforce eligibility; do not change source or application fields to make a rejected reversal pass.
|
||||
|
||||
Supported operational edits are deliberate exceptions: for example, `"G/L Entry-Edit"` supports description changes, and `"Cust. Entry-Edit"` / `"Vend. Entry-Edit"` handle their table-specific editable fields. [Due-date synchronization](change-ledger-due-dates-through-entry-edit.md), [application/unapplication](apply-ledger-entries-through-application-codeunits.md), G/L dimension correction, and supported date compression have their own workflows. Do not flag their standard implementations, temporary simulation buffers, or extension-only metadata updates as financial row surgery. A subscriber is not exempt merely because it runs inside a supported workflow: inspect the fields it actually changes.
|
||||
|
||||
This rule covers G/L, customer/vendor/detailed, VAT, and financial-posting/register records. Item, Value, Capacity, Warehouse, inventory-application, and other inventory-posting records are SCM concerns. The financial-row leg of one inventory-posting bypass is outside this rule when the same inventory correction resolves it; an independently actionable financial defect remains in scope regardless of the containing module's name.
|
||||
|
||||
See sample: [`do-not-modify-or-delete-posted-ledger-entries.good.al`](do-not-modify-or-delete-posted-ledger-entries.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Persist a change to original financial content, delete posted rows, or fabricate reversal flags/links to repair or undo a transaction outside the supported correction/maintenance workflow. Require an existing, non-temporary Finance-owned record and evidence of the fields or rows affected; a `Modify` token or `*Ledger Entry` name alone is insufficient. Settlement-state writes belong to the application article rather than a duplicate finding here.
|
||||
|
||||
See sample: [`do-not-modify-or-delete-posted-ledger-entries.bad.al`](do-not-modify-or-delete-posted-ledger-entries.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Reverse journal postings](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-how-reverse-journal-posting).
|
||||
- [Correct G/L dimensions](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-troubleshooting-correcting-dimensions).
|
||||
- [BCApps: G/L Entry-Edit](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Ledger/GLEntryEdit.Codeunit.al).
|
||||
- [BCApps: supported customer-ledger date compression](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Sales/Receivables/DateCompressCustomerLedger.Report.al).
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50108 "Import Purchase Journal Total"
|
||||
{
|
||||
procedure ImportExampleTotal(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer)
|
||||
var
|
||||
JournalLine: Record "Gen. Journal Line";
|
||||
VATPostingSetup: Record "VAT Posting Setup";
|
||||
GeneralLedgerSetup: Record "General Ledger Setup";
|
||||
SourceInvoice: JsonObject;
|
||||
NetToken: JsonToken;
|
||||
VATToken: JsonToken;
|
||||
GrossToken: JsonToken;
|
||||
ImportedNet: Decimal;
|
||||
ImportedVAT: Decimal;
|
||||
ImportedGross: Decimal;
|
||||
begin
|
||||
SourceInvoice.ReadFrom('{"netAmount":100,"vatAmount":25,"grossAmount":125}');
|
||||
SourceInvoice.Get('netAmount', NetToken);
|
||||
SourceInvoice.Get('vatAmount', VATToken);
|
||||
SourceInvoice.Get('grossAmount', GrossToken);
|
||||
ImportedNet := NetToken.AsValue().AsDecimal();
|
||||
ImportedVAT := VATToken.AsValue().AsDecimal();
|
||||
ImportedGross := GrossToken.AsValue().AsDecimal();
|
||||
if ImportedGross <> ImportedNet + ImportedVAT then
|
||||
Error(TotalsErr);
|
||||
|
||||
JournalLine.Get(TemplateName, BatchName, LineNo);
|
||||
JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account");
|
||||
JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account");
|
||||
JournalLine.TestField("Account No.");
|
||||
JournalLine.TestField("Bal. Account No.");
|
||||
JournalLine.TestField("Currency Code", '');
|
||||
JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::Purchase);
|
||||
JournalLine.TestField("VAT Posting", JournalLine."VAT Posting"::"Automatic VAT Entry");
|
||||
JournalLine.TestField("VAT Calculation Type", JournalLine."VAT Calculation Type"::"Normal VAT");
|
||||
JournalLine.TestField("VAT %", 25);
|
||||
JournalLine.TestField("VAT Difference", 0);
|
||||
JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" ");
|
||||
JournalLine.TestField("Bal. VAT %", 0);
|
||||
VATPostingSetup.Get(JournalLine."VAT Bus. Posting Group", JournalLine."VAT Prod. Posting Group");
|
||||
VATPostingSetup.TestField("VAT Calculation Type", VATPostingSetup."VAT Calculation Type"::"Normal VAT");
|
||||
VATPostingSetup.TestField("VAT %", 25);
|
||||
VATPostingSetup.TestField("Unrealized VAT Type", VATPostingSetup."Unrealized VAT Type"::" ");
|
||||
GeneralLedgerSetup.Get();
|
||||
GeneralLedgerSetup.TestField("Additional Reporting Currency", '');
|
||||
GeneralLedgerSetup.TestField("Amount Rounding Precision", 0.01);
|
||||
|
||||
JournalLine.Validate(Amount, ImportedNet);
|
||||
JournalLine.Modify(true);
|
||||
end;
|
||||
|
||||
var
|
||||
TotalsErr: Label 'The invoice total must equal its net amount plus VAT.';
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50108 "Import Purchase Journal Total"
|
||||
{
|
||||
procedure ImportExampleTotal(TemplateName: Code[10]; BatchName: Code[10]; LineNo: Integer)
|
||||
var
|
||||
JournalLine: Record "Gen. Journal Line";
|
||||
VATPostingSetup: Record "VAT Posting Setup";
|
||||
GeneralLedgerSetup: Record "General Ledger Setup";
|
||||
SourceInvoice: JsonObject;
|
||||
NetToken: JsonToken;
|
||||
VATToken: JsonToken;
|
||||
GrossToken: JsonToken;
|
||||
ImportedNet: Decimal;
|
||||
ImportedVAT: Decimal;
|
||||
ImportedGross: Decimal;
|
||||
begin
|
||||
SourceInvoice.ReadFrom('{"netAmount":100,"vatAmount":25,"grossAmount":125}');
|
||||
SourceInvoice.Get('netAmount', NetToken);
|
||||
SourceInvoice.Get('vatAmount', VATToken);
|
||||
SourceInvoice.Get('grossAmount', GrossToken);
|
||||
ImportedNet := NetToken.AsValue().AsDecimal();
|
||||
ImportedVAT := VATToken.AsValue().AsDecimal();
|
||||
ImportedGross := GrossToken.AsValue().AsDecimal();
|
||||
if ImportedGross <> ImportedNet + ImportedVAT then
|
||||
Error(TotalsErr);
|
||||
|
||||
JournalLine.Get(TemplateName, BatchName, LineNo);
|
||||
JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account");
|
||||
JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account");
|
||||
JournalLine.TestField("Account No.");
|
||||
JournalLine.TestField("Bal. Account No.");
|
||||
JournalLine.TestField("Currency Code", '');
|
||||
JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::Purchase);
|
||||
JournalLine.TestField("VAT Posting", JournalLine."VAT Posting"::"Automatic VAT Entry");
|
||||
JournalLine.TestField("VAT Calculation Type", JournalLine."VAT Calculation Type"::"Normal VAT");
|
||||
JournalLine.TestField("VAT %", 25);
|
||||
JournalLine.TestField("VAT Difference", 0);
|
||||
JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" ");
|
||||
JournalLine.TestField("Bal. VAT %", 0);
|
||||
VATPostingSetup.Get(JournalLine."VAT Bus. Posting Group", JournalLine."VAT Prod. Posting Group");
|
||||
VATPostingSetup.TestField("VAT Calculation Type", VATPostingSetup."VAT Calculation Type"::"Normal VAT");
|
||||
VATPostingSetup.TestField("VAT %", 25);
|
||||
VATPostingSetup.TestField("Unrealized VAT Type", VATPostingSetup."Unrealized VAT Type"::" ");
|
||||
GeneralLedgerSetup.Get();
|
||||
GeneralLedgerSetup.TestField("Additional Reporting Currency", '');
|
||||
GeneralLedgerSetup.TestField("Amount Rounding Precision", 0.01);
|
||||
|
||||
JournalLine.Validate(Amount, ImportedGross);
|
||||
JournalLine.Modify(true);
|
||||
end;
|
||||
|
||||
var
|
||||
TotalsErr: Label 'The invoice total must equal its net amount plus VAT.';
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: finance
|
||||
keywords: [normal-vat, automatic-vat-entry, gross-amount, net-amount, vat-posting-setup, gen-journal-line, purchase]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Supply a VAT-inclusive journal Amount for automatic Normal VAT
|
||||
|
||||
## Description
|
||||
|
||||
For a general-journal line using **Automatic VAT Entry** and **Normal VAT**, `Amount` includes VAT. The posting engine extracts tax from that total; it does not add tax to a VAT-exclusive expense imported into `Amount`. An LCY invoice with net 100 and VAT 25 therefore needs a journal total of 125, not 100.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Map the source's VAT-inclusive total to journal `Amount` in this posting mode. Establish the intended account and posting-group combination before validating the final amount. Account-derived VAT defaults depend on `Copy VAT Setup to Jnl. Lines`; do not assume account selection always supplies the intended configuration.
|
||||
|
||||
Require the actual input contract and calculation mode, not just a variable named `NetAmount`. The examples encode source net, VAT, and gross values plus a 25% Normal-VAT setup check. They target an LCY G/L purchase with 0.01 amount rounding and without balancing-side VAT, additional reporting currency, VAT differences, or unrealized VAT. Other calculation types, Manual VAT Entry, reverse charge, Full VAT, sales/use tax, unrealized tax, and other currency/rounding contexts need their own analysis; this is not a universal gross-up formula or country-specific tax advice.
|
||||
|
||||
The concern is the supplied transaction total, not its deductible/non-deductible allocation. Non-deductible VAT features can change the allocation of that total, not turn the source's net amount into its gross amount. Do not infer a particular expense or deductible-VAT split from this rule. The samples therefore do not depend on later-version non-deductible-VAT fields.
|
||||
|
||||
See sample: [`normal-vat-journal-amount-includes-vat.good.al`](normal-vat-journal-amount-includes-vat.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
In the demonstrated automatic Normal-VAT configuration, put a provably VAT-exclusive source amount into journal `Amount` while expecting posting to add tax. An amount assignment alone, unknown setup, or a suggestive variable name is insufficient. Do not report the correctly supplied gross amount or automatically rewrite tax calculations outside this scope.
|
||||
|
||||
See sample: [`normal-vat-journal-amount-includes-vat.bad.al`](normal-vat-journal-amount-includes-vat.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [VAT posting setup combinations](https://learn.microsoft.com/en-us/dynamics365/business-central/finance-setup-vat#combine-vat-posting-groups-in-vat-posting-setups).
|
||||
- [BCApps: journal amount validation](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Journal/GenJournalLine.Table.al).
|
||||
- [BCApps: Normal VAT extraction during posting](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Posting/GenJnlPostLine.Codeunit.al).
|
||||
- [BCApps: journal VAT amount regression cases](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/Tests/VAT/ERMVATOnGenJournalLine.Codeunit.al).
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50100 "Post Transfer Journal"
|
||||
{
|
||||
procedure PostTransferBatch(TemplateName: Code[10]; BatchName: Code[10])
|
||||
var
|
||||
JournalLine: Record "Gen. Journal Line";
|
||||
LastGLEntry: Record "G/L Entry";
|
||||
NextEntryNo: Integer;
|
||||
begin
|
||||
JournalLine.SetRange("Journal Template Name", TemplateName);
|
||||
JournalLine.SetRange("Journal Batch Name", BatchName);
|
||||
if JournalLine.Count() <> 1 then
|
||||
Error(SingleTransferErr);
|
||||
JournalLine.FindFirst();
|
||||
JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account");
|
||||
JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account");
|
||||
JournalLine.TestField("Account No.");
|
||||
JournalLine.TestField("Bal. Account No.");
|
||||
JournalLine.TestField("Posting Date");
|
||||
JournalLine.TestField("Document No.");
|
||||
JournalLine.TestField(Amount);
|
||||
JournalLine.TestField("Currency Code", '');
|
||||
JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::" ");
|
||||
JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" ");
|
||||
|
||||
LastGLEntry.LockTable();
|
||||
if LastGLEntry.FindLast() then
|
||||
NextEntryNo := LastGLEntry."Entry No." + 1
|
||||
else
|
||||
NextEntryNo := 1;
|
||||
InsertLedgerRow(JournalLine, NextEntryNo, JournalLine."Account No.", JournalLine.Amount);
|
||||
InsertLedgerRow(JournalLine, NextEntryNo + 1, JournalLine."Bal. Account No.", -JournalLine.Amount);
|
||||
end;
|
||||
|
||||
local procedure InsertLedgerRow(JournalLine: Record "Gen. Journal Line"; EntryNo: Integer; AccountNo: Code[20]; Amount: Decimal)
|
||||
var
|
||||
GLEntry: Record "G/L Entry";
|
||||
begin
|
||||
GLEntry.Init();
|
||||
GLEntry."Entry No." := EntryNo;
|
||||
GLEntry."G/L Account No." := AccountNo;
|
||||
GLEntry."Posting Date" := JournalLine."Posting Date";
|
||||
GLEntry."Document Type" := JournalLine."Document Type";
|
||||
GLEntry."Document No." := JournalLine."Document No.";
|
||||
GLEntry."Source Code" := JournalLine."Source Code";
|
||||
GLEntry."Journal Batch Name" := JournalLine."Journal Batch Name";
|
||||
GLEntry."Dimension Set ID" := JournalLine."Dimension Set ID";
|
||||
GLEntry."Global Dimension 1 Code" := JournalLine."Shortcut Dimension 1 Code";
|
||||
GLEntry."Global Dimension 2 Code" := JournalLine."Shortcut Dimension 2 Code";
|
||||
GLEntry.Amount := Amount;
|
||||
if Amount > 0 then
|
||||
GLEntry."Debit Amount" := Amount
|
||||
else
|
||||
GLEntry."Credit Amount" := -Amount;
|
||||
GLEntry.Insert(true);
|
||||
end;
|
||||
|
||||
var
|
||||
SingleTransferErr: Label 'Use a journal batch containing exactly one self-balancing G/L transfer.';
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// Demonstration only; independently authored, not copied from BaseApp.
|
||||
codeunit 50100 "Post Transfer Journal"
|
||||
{
|
||||
procedure PostTransferBatch(TemplateName: Code[10]; BatchName: Code[10])
|
||||
var
|
||||
JournalLine: Record "Gen. Journal Line";
|
||||
PostBatch: Codeunit "Gen. Jnl.-Post Batch";
|
||||
begin
|
||||
JournalLine.SetRange("Journal Template Name", TemplateName);
|
||||
JournalLine.SetRange("Journal Batch Name", BatchName);
|
||||
if JournalLine.Count() <> 1 then
|
||||
Error(SingleTransferErr);
|
||||
JournalLine.FindFirst();
|
||||
JournalLine.TestField("Account Type", JournalLine."Account Type"::"G/L Account");
|
||||
JournalLine.TestField("Bal. Account Type", JournalLine."Bal. Account Type"::"G/L Account");
|
||||
JournalLine.TestField("Account No.");
|
||||
JournalLine.TestField("Bal. Account No.");
|
||||
JournalLine.TestField("Posting Date");
|
||||
JournalLine.TestField("Document No.");
|
||||
JournalLine.TestField(Amount);
|
||||
JournalLine.TestField("Currency Code", '');
|
||||
JournalLine.TestField("Gen. Posting Type", JournalLine."Gen. Posting Type"::" ");
|
||||
JournalLine.TestField("Bal. Gen. Posting Type", JournalLine."Bal. Gen. Posting Type"::" ");
|
||||
PostBatch.Run(JournalLine);
|
||||
end;
|
||||
|
||||
var
|
||||
SingleTransferErr: Label 'Use a journal batch containing exactly one self-balancing G/L transfer.';
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: finance
|
||||
keywords: [g-l-entry, ledger-entry, gen-jnl-post-line, gen-jnl-post-batch, journal-line, register, insert]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Create financial ledger entries through the owning posting engine
|
||||
|
||||
## Description
|
||||
|
||||
Standard financial ledger entries are outputs of posting, not independent rows an extension manufactures. Even two manually inserted G/L rows with balanced amounts bypass posting checks, register bookkeeping, and transaction/source relationships. `G/L Entry.Insert(true)` runs the table trigger; it does not invoke the posting engine.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Use the owning document or journal posting workflow. For a normal persisted general-journal batch, use `"Gen. Jnl.-Post Batch"`; the example posts an existing batch containing one self-balancing, non-VAT G/L transfer. Let posting allocate entries and maintain the register rather than reconstructing its tables.
|
||||
|
||||
`"Gen. Jnl.-Post Line".RunWithCheck` is appropriate for a complete journal line inside a correctly owned posting lifecycle, but it does not invent a balancing account or document number, allocate numbering merely from `Posting No. Series`, or replace [batch document-balancing policy](preserve-journal-batch-document-balance.md). The line codeunit is stateful; its checked wrapper owns its start/continue/finish work. Normal batch posting owns its numbering and commits by default; do not imply these entry points are transaction-neutral.
|
||||
|
||||
Exclude temporary buffers and the standard engine's own insertion points. A checked parent may legitimately use `RunWithoutCheck`; do not replace it without inspecting that parent. This rule owns `G/L Entry`, `Cust. Ledger Entry`, `Vendor Ledger Entry`, their detailed customer/vendor entries, `VAT Entry`, and financial-posting/register records, not a custom table merely named `Ledger Entry` or a supported, specifically reviewed migration/repair workflow.
|
||||
|
||||
`Item Ledger Entry`, `Value Entry`, Capacity/Warehouse entries, `Item Application Entry`, and other inventory-posting records are SCM concerns, not this rule's financial-ledger scope. That exclusion includes the financial-row leg of a single inventory-posting bypass when restoring the inventory workflow corrects the whole operation. Distinct, independently actionable financial defects remain in scope.
|
||||
|
||||
See sample: [`post-ledger-entries-through-posting-codeunits.good.al`](post-ledger-entries-through-posting-codeunits.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Create posted financial effects by directly inserting the Finance-owned records named above outside their owning posting workflow. Resolve the actual record type, operation, and lifecycle; do not match `*Ledger Entry` as a wildcard. Balanced debit/credit values, copied dimensions, `Insert(true)`, and a lock around entry-number allocation do not turn raw inserts into a complete posting.
|
||||
|
||||
See sample: [`post-ledger-entries-through-posting-codeunits.bad.al`](post-ledger-entries-through-posting-codeunits.bad.al).
|
||||
|
||||
## References
|
||||
|
||||
- [Posting engine structure](https://learn.microsoft.com/en-us/dynamics365/business-central/design-details-posting-engine-structure).
|
||||
- [Gen. Jnl.-Post Line API](https://learn.microsoft.com/en-us/dynamics365/business-central/application/base-application/codeunit/microsoft.finance.generalledger.posting.gen.-jnl.-post-line).
|
||||
- [BCApps: posting lifecycle and register maintenance](https://github.com/microsoft/BCApps/blob/8f7a04cb0db8aa96cb97e055c45c61aead49e280/src/Layers/W1/BaseApp/Finance/GeneralLedger/Posting/GenJnlPostLine.Codeunit.al).
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue