mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 14:46:55 +01:00
Fix 5 merge-critical issues from Jesper's 2026-09-24 review round
- activate-new-price-calculation-handler-via-onfindsupportedsetup: Default := true is required only for the fallback branch of PriceCalculationMgt's two-stage FindSetup - a handler reachable via a specific Dtld. Price Calculation Setup row needs no Default. Softened the article and its worklist cue accordingly. Also fixed an undefined "Sample Price Calc - Special" codeunit referenced but never declared in the eval fixtures - added a real implementation of interface "Price Calculation" with stub methods. - new-price-source-must-add-candidate-and-trigger-recalculation: the good fixture called UpdateUnitPriceByField directly, which is a silent no-op without a prior PlanPriceCalcByField call (FieldCausedPriceCalculation gating, verified against SalesLine.Table.al). Switched to the public UpdateUnitPrice wrapper, matching real BCApps usage in ItemReferenceManagement.Codeunit.al. - report-barcodes-must-use-barcode-module-and-production-font-name: split the 1D (ValidateInput + EncodeFont) and 2D (EncodeFont only) Barcode Font Provider interfaces, which the article previously conflated. Reframed the Code 39 anti-pattern around demonstrable encoding/checksum mismatch (verified against IDA1DCode39Encoder.Codeunit.al's real '(value)' output) rather than rejecting all manual delimiter use, since '*' is a legitimate Code 39 start/stop character. Also fixed extend-find-entries-navigate- for-new-document-types' eval fixtures, which referenced an undefined "Sample Posted Document Header" table/page - declared both. All claims re-verified against live microsoft/BCApps source. Validators: frontmatter 0/0, review-fixtures 126/20 domains PASSED, knowledge-index 342/575 PASSED, skill-index 19 leaves PASSED. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e22352b248
commit
eb9cae0af4
12 changed files with 371 additions and 120 deletions
|
|
@ -7,9 +7,68 @@ enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation H
|
|||
}
|
||||
}
|
||||
|
||||
// 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. It ships invisible until someone notices and
|
||||
// configures a setup row for it by hand.
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,64 @@ enumextension 50102 "Sample Price Calc Handler Ext" extends "Price Calculation H
|
|||
}
|
||||
}
|
||||
|
||||
// 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)]
|
||||
|
|
@ -19,6 +77,13 @@ codeunit 50104 "Sample Price Calc Setup Install"
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -23,13 +23,21 @@ Implementation, Enabled, Default)` rows populated at startup by its own
|
|||
to subscribe to that event and insert its own setup row(s). A handler
|
||||
enum value with no matching setup row is real and selectable in the enum
|
||||
itself, but never chosen for any actual sale, purchase, or job line,
|
||||
because `Price Calculation Mgt.` has no setup row that names it. A setup
|
||||
row that exists but doesn't match is just as invisible: `FindSetup`
|
||||
filters candidates with `SetRange(Default, true)` and `SetRange(Method,
|
||||
DtldPriceCalcSetup.Method)` (a document's blank Method is normalized to
|
||||
`"Lowest Price"` before that filter runs), so a row inserted without
|
||||
`Default := true`, or with a `Method` that doesn't match, is never
|
||||
selected either — same symptom, different cause.
|
||||
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
|
||||
|
||||
|
|
@ -37,8 +45,13 @@ Ship a new `Price Calculation Handler` value together with an
|
|||
`OnFindSupportedSetup` subscriber that inserts at least one `Price
|
||||
Calculation Setup` record naming it as the `Implementation`, for the
|
||||
relevant `Method` (e.g. `"Lowest Price"`), `Type` (`Sale`/`Purchase`), and
|
||||
`Asset Type` — with `Default := true`, since `FindSetup` only considers
|
||||
rows where `Default` is set when resolving a handler for a line.
|
||||
`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).
|
||||
|
||||
|
|
@ -58,25 +71,28 @@ See sample: [`activate-new-price-calculation-handler-via-onfindsupportedsetup.ba
|
|||
BCApps (`src/Layers/W1/BaseApp/Pricing/Calculation/`):
|
||||
`PriceCalculationHandler.Enum.al` (`enum 7011 "Price Calculation Handler"
|
||||
implements "Price Calculation"`); `PriceCalculationMgt.Codeunit.al`
|
||||
(`local procedure OnFindSupportedSetup(var TempPriceCalculationSetup:
|
||||
Record "Price Calculation Setup" temporary)`, called during setup
|
||||
resolution, and `procedure FindSetup(...)`, which requires
|
||||
`SetRange(Default, true)` and a matching `SetRange(Method,
|
||||
DtldPriceCalcSetup.Method)` before a row can be selected);
|
||||
`PriceCalculationSetup.Table.al` (`table 7006 "Price Calculation Setup"`:
|
||||
`Code` (Code[100]), `Method` (Enum "Price Calculation Method"), `Type`
|
||||
(Enum "Price Type"), `"Asset Type"` (Enum "Price Asset Type"),
|
||||
`Implementation` (Enum "Price Calculation Handler"), `Enabled` (Boolean),
|
||||
`Default` (Boolean)).
|
||||
(`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).
|
||||
|
||||
BCApps (`src/Layers/W1/BaseApp/Pricing/PriceList/`): `PriceType.Enum.al`
|
||||
(`enum 7009 "Price Type"`: `Any`(0)/`Sale`(1)/`Purchase`(2)).
|
||||
|
||||
Microsoft Learn, "Extending Price Calculations": "For the new codeunit,
|
||||
you must extend the Price Calculation Handler enum that implements Price
|
||||
Calculation interface... Afterwards you can insert a record in the Price
|
||||
Calculation Setup table... Each codeunit that implements the Price
|
||||
Calculation interface must subscribe to the OnFindSupportedSetup() event
|
||||
of the Price Calculation Mgt codeunit to fill the price calculation setup
|
||||
table with new options."
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,19 @@
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,36 @@
|
|||
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)]
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ tableextension 50105 "Sample Sales Line Ext" extends "Sales Line"
|
|||
// WRONG: no OnValidate trigger. The field is registered as a
|
||||
// price source below via OnAfterAddSources, so new lines price
|
||||
// correctly - but changing this field on an existing line never
|
||||
// calls UpdateUnitPriceByField, so the unit price silently keeps
|
||||
// its old value.
|
||||
// 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.';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,16 @@ tableextension 50105 "Sample Sales Line Ext" extends "Sales Line"
|
|||
// the field on an existing line never re-runs price
|
||||
// calculation, even though the source is already a known
|
||||
// candidate via OnAfterAddSources below.
|
||||
UpdateUnitPriceByField(FieldNo("Sample Loyalty Customer No."));
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,23 @@ being priced by it. `codeunit "Sales Line - Price"` publishes
|
|||
`PriceSourceList.Add(SourceType, SourceNo)` makes the source a candidate
|
||||
the calculation considers. But nothing about that subscription causes
|
||||
the price to be *recalculated* when the source field's value changes on
|
||||
an existing line. That's the second, separate piece: `Sales Line`'s own
|
||||
`procedure UpdateUnitPriceByField(CalledByFieldNo: Integer)` must be
|
||||
called from the source field's own trigger — the same way Microsoft's
|
||||
own Location example is wired from a `Sales Line` validation event, not
|
||||
from the price source registration itself.
|
||||
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
|
||||
|
|
@ -40,17 +52,22 @@ Wire both halves together whenever a field becomes a price source: an
|
|||
`OnAfterAddSources` subscriber that adds it via `PriceSourceList.Add`, and
|
||||
a trigger on the field itself (its own `OnValidate`, or a matching
|
||||
`OnAfterValidate` integration event) that calls
|
||||
`SalesLine.UpdateUnitPriceByField(SalesLine.FieldNo(<TheField>))`.
|
||||
`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 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.
|
||||
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).
|
||||
|
||||
|
|
@ -62,7 +79,13 @@ SalesLine: Record "Sales Line"; PriceType: Enum "Price Type"; var
|
|||
PriceSourceList: Codeunit "Price Source List")`); `Pricing/Source/PriceSourceList.Codeunit.al`
|
||||
(`procedure Add(SourceType: Enum "Price Source Type"; SourceNo: Code[20])`);
|
||||
`Sales/Document/SalesLine.Table.al` (`procedure
|
||||
UpdateUnitPriceByField(CalledByFieldNo: Integer)`).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -14,11 +14,23 @@ report 50110 "Sample Item Barcode Label"
|
|||
trigger OnAfterGetRecord()
|
||||
begin
|
||||
// WRONG: hand-rolled "encoding" instead of the Barcode
|
||||
// module's provider/encoder API. This produces a string
|
||||
// that looks like a Code 39 barcode (asterisk delimiters)
|
||||
// but carries none of the platform's actual character-set
|
||||
// or checksum handling - wrong regardless of which font
|
||||
// is applied to it in the layout.
|
||||
// module's provider/encoder API. This is not wrong merely
|
||||
// because the delimiter was added by hand - Code 39's own
|
||||
// symbology does use "*" as its start/stop character
|
||||
// (Microsoft Learn, "Barcode Fonts with Business Central
|
||||
// Online"). It's wrong because it's demonstrably mismatched
|
||||
// with what encoding "No." through the real API would
|
||||
// produce:
|
||||
// - it skips ValidateInput, so a "No." value outside Code
|
||||
// 39's character set, or one that needs a checksum this
|
||||
// code never applies, reaches the font unvalidated;
|
||||
// - IDAutomation 1D Provider's own EncodeFont output for
|
||||
// Code 39 wraps the value in "(" / ")", not literal "*"
|
||||
// (BCApps' own encoder test: EncodeFont('1234', Code39)
|
||||
// = '(1234)') - the paired font maps those parentheses to
|
||||
// the real start/stop glyph, so a string built with
|
||||
// literal asterisks is simply the wrong characters for
|
||||
// that font, on top of carrying no real checksum.
|
||||
BarcodeText := '*' + "No." + '*';
|
||||
end;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,32 +9,46 @@ report 50110 "Sample Item Barcode Label"
|
|||
dataitem(Item; Item)
|
||||
{
|
||||
column(No_; "No.") { }
|
||||
column(Barcode; BarcodeText) { }
|
||||
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 Barcode column's text box must use the real, purchased font
|
||||
// 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.
|
||||
// environment means "the barcode won't render" at all. The
|
||||
// Barcode2D column's font name is IDAutomation2D (IDAutomation2D
|
||||
// MaxiCode for Maxicode specifically).
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: data-modeling
|
||||
keywords: [barcode, qr-code, barcode-font-provider, report-layout, saas, idautomation]
|
||||
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]
|
||||
|
|
@ -12,85 +12,89 @@ application-area: [all]
|
|||
## Description
|
||||
|
||||
Business Central's barcode support lives in the System Application's
|
||||
`Barcode` module (`src/System Application/App/Barcode`), not in a
|
||||
project's own code: `interface "Barcode Font Provider"` /
|
||||
`"Barcode Font Provider 2D"`, `enum "Barcode Symbology"` /
|
||||
`"Barcode Symbology 2D"` (Code39, Code128, EAN-13, QR-Code, Data Matrix,
|
||||
and more), and built-in implementations
|
||||
(`codeunit 9215 "IDAutomation 1D Provider"`,
|
||||
`codeunit 9221 "IDAutomation 2D Provider"`). A report encodes a data
|
||||
string into a barcode string via this API; the layout then displays that
|
||||
string using a barcode *font*.
|
||||
`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*.
|
||||
|
||||
On Business Central online, this is available with no setup at all:
|
||||
"With Business Central online, the IDAutomation fonts are automatically
|
||||
available as part of the service. So you can start adding barcodes to
|
||||
reports right away." (Microsoft Learn, "Adding Barcodes to Reports") —
|
||||
unlike on-premises, where the fonts must be purchased and installed on
|
||||
the server.
|
||||
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.
|
||||
|
||||
That ease hides a SaaS-specific trap in the one manual step the API
|
||||
doesn't cover: naming the actual font in the report layout. IDAutomation
|
||||
ships both a purchased font and a same-looking evaluation font per
|
||||
version (Code 39: `IDAutomationHC39M` purchased vs.
|
||||
`IDAutomationSHC39M Demo` evaluation). Per Microsoft Learn ("Barcode
|
||||
Fonts with Business Central Online"): "When you're applying barcode font
|
||||
in the report layout for a Business Central online production
|
||||
environment, be sure to use the purchased font name; not the evaluation
|
||||
font name. If you use the evaluation font name, the barcode won't
|
||||
render." Getting the font name wrong doesn't distort the barcode — it
|
||||
produces nothing, in a step that lives in the layout file, not in AL, so
|
||||
no compiler or reviewer catches it by reading the report object. Nothing
|
||||
in the cited documentation says what an evaluation font name does outside
|
||||
a production environment — the claim here is scoped exactly as
|
||||
Microsoft states it: wrong in production, full stop.
|
||||
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 — declare the provider via its interface and
|
||||
enum, then call `ValidateInput`/`EncodeFont` — and treat naming the
|
||||
production font in the layout as an equally required part of the same
|
||||
task, not an afterthought left to whoever happens to touch the `.docx`/
|
||||
`.rdl` file. For a two-dimensional symbology other than Maxicode, the
|
||||
font name to specify is literally `IDAutomation2D` (Maxicode itself uses
|
||||
`IDAutomation2D MaxiCode`); for a one-dimensional symbology, use the
|
||||
purchased version name for that specific font (e.g. `IDAutomationHC39M`
|
||||
for Code 39), never a name containing `Demo`.
|
||||
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 — string concatenation, manual
|
||||
delimiters — instead of going through the Barcode module's provider
|
||||
interface. It can look right (asterisks around a value, resembling
|
||||
Code 39) while carrying none of the platform's actual character-set
|
||||
handling or checksum logic, so it's wrong regardless of which font is
|
||||
later applied to it.
|
||||
Constructing a barcode string by hand instead of using the module's
|
||||
provider/encoder API — not because a manual delimiter is inherently
|
||||
wrong (Code 39's own symbology does use `*` as start/stop; Microsoft
|
||||
Learn's font table says so), but because hand-rolled construction is
|
||||
demonstrably mismatched with what the real encoder produces: it skips
|
||||
`ValidateInput` (so a value outside the character set, or needing a
|
||||
checksum/extended-charset setting never applied, reaches the font
|
||||
unvalidated), and IDAutomation 1D Provider's own Code 39 output is
|
||||
wrapped in `(`/`)`, not literal `*` (BCApps test:
|
||||
`EncodeFont('1234', Code39) = '(1234)'`) — the paired font maps those
|
||||
parentheses to the real start/stop glyph, so `'*' + value + '*'` is
|
||||
simply the wrong characters, plus no checksum.
|
||||
|
||||
A second version of the same underlying mistake: encoding correctly
|
||||
through the real API, but naming the evaluation font instead of the
|
||||
purchased one in the layout. Both produce a report that looks complete
|
||||
in review and testing and fails silently — the first because the encoded
|
||||
data was never a real barcode, the second because Business Central
|
||||
online refuses to render it at all.
|
||||
Flag demonstrably invalid or mismatched hand construction, not manual
|
||||
delimiter use as a category — a custom provider paired with a font that
|
||||
genuinely expects literal `*` delimiters is a different, legitimate case.
|
||||
|
||||
A second version of the same mistake: encoding correctly, but naming the
|
||||
evaluation font instead of the purchased one. Both look complete in
|
||||
review and fail silently — the first because the data was never a real
|
||||
barcode, the second because BC online refuses to render it.
|
||||
|
||||
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 System Application (`src/System Application/App/Barcode/src/`):
|
||||
`Barcode Provider/Font/BarcodeFontProvider.Interface.al`
|
||||
(`ValidateInput(InputText: Text; BarcodeSymbology: Enum "Barcode Symbology")`,
|
||||
`EncodeFont(InputText: Text; BarcodeSymbology: Enum "Barcode Symbology"): Text`),
|
||||
`Barcode Provider/Font/BarcodeFontProvider.Enum.al`
|
||||
(`value(0; IDAutomation1D)`), `Barcode Provider/BarcodeSymbology.Enum.al`
|
||||
(`value(100; Code39)`). Real BaseApp usage:
|
||||
`src/Layers/W1/BaseApp/Inventory/Item/ItemGTINLabel.Report.al`
|
||||
(`report 6625 "Item GTIN Label"`, lines 44-64).
|
||||
BCApps (`src/System Application/App/Barcode/src/`):
|
||||
`Barcode Provider/Font/BarcodeFontProvider.Interface.al` (1D:
|
||||
`ValidateInput` + `EncodeFont`); `Barcode Provider 2D/Font/
|
||||
BarcodeFontProvider2D.Interface.al` (2D: only `EncodeFont`); both read
|
||||
fresh from source. `IDAutomation 1D Provider/Encoders/
|
||||
IDA1DCode39Encoder.Codeunit.al` (`codeunit 9204`, regex accepts literal
|
||||
`*` as plain input; `EncodeFont` → `DotNet FontEncoder.Code39`). Split
|
||||
and delimiter mismatch both confirmed live: `.../Inventory/Item/
|
||||
ItemGTINLabel.Report.al` (`report 6625`, validates+encodes 1D, only
|
||||
encodes 2D, same value) and `.../Test/Barcode/.../IDA1DCode39Test.
|
||||
Codeunit.al` (`codeunit 135044`): `EncodeFontSuccessTest('1234', Code39,
|
||||
'(1234)')` — wrapped in `(`/`)`, never literal `*`.
|
||||
|
||||
Microsoft Learn: "Adding Barcodes to Reports"
|
||||
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-add-barcodes)
|
||||
and "Barcode Fonts with Business Central Online"
|
||||
(https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-report-barcode-fonts)
|
||||
— both quoted verbatim above.
|
||||
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").
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue