All Around Microsoft Dynamics Business Central
In the last BusinessCraft post, craft-server ran two small AL apps: a customer list, a cue, and a few actions.
That was a good start, but it was not a business process. So I wrote a mini base app. It has a full sales flow from quote to posted invoice, number series, ledger entries, three RDLC reports, and a German translation.
All of it now runs in BusinessCraft.
The app is called BusinessCraft Sales. It is deliberately small, but it follows the structure of the real Base App.
| Area | Objects |
|---|---|
| Customers | Customer, Customer Card, Customers, Customer Cue, Customer Activities |
| Items | Item, Item Card, Items, enum Item Type (Inventory, Non-Inventory) |
| Inventory | Item Ledger Entry, Item Ledger Entries, enum Item Ledger Entry Type, codeunit Item Ledger Post |
| Number series | No. Series, No. Series List, codeunit No. Series Mgt. |
| Setup | Sales Setup with a series for quotes, orders, invoices, customers, and items |
| Sales documents | Sales Header, Sales Line, enum Sales Document Type, Sales Quote, Sales Order, subforms and lists |
| Posting | codeunits Sales Quote to Order and Sales Post |
| Posted documents | Sales Invoice Header, Sales Invoice Line, Sales Invoice, Sales Invoices |
| Reports | Sales Quote, Sales Order Confirmation, Sales Invoice (all RDLC) |
| System | Companies, Users, User Card, Password Dialog |
The flow is the one every BC consultant knows:
There is an integration test (sales_app.rs) that runs this flow through the web client protocol. It checks that the customer gets CU-00001, the item IT-00001, the quote SQ-00001, the order SO-00001, and the invoice SI-00001.
The number series is the simplest version possible. No starting number, no ending number, no date. Just the last used number and IncStr:
procedure GetNextNo(SeriesCode: Code[20]): Code[20]
var
NoSeries: Record "No. Series";
begin
if SeriesCode = '' then
Error(SeriesNotSpecifiedErr);
NoSeries.LockTable();
NoSeries.Get(SeriesCode);
if NoSeries."Last No. Used" = '' then
Error(LastNoMissingErr, SeriesCode);
NoSeries."Last No. Used" := CopyStr(IncStr(NoSeries."Last No. Used"), 1, MaxStrLen(NoSeries."Last No. Used"));
NoSeries.Modify();
exit(NoSeries."Last No. Used");
end;
Sales Setup.InitSetup() creates the five series on first use, so a fresh company works without any setup.
The tables pull their number in OnInsert. The sales header picks the series by document type, which also means case works now:
trigger OnInsert()
var
SalesSetup: Record "Sales Setup";
NoSeriesMgt: Codeunit "No. Series Mgt.";
begin
if "No." = '' then begin
SalesSetup.InitSetup();
case "Document Type" of
"Document Type"::Quote:
"No." := NoSeriesMgt.GetNextNo(SalesSetup."Quote Nos.");
"Document Type"::Order:
"No." := NoSeriesMgt.GetNextNo(SalesSetup."Order Nos.");
end;
end;
if "Document Date" = 0D then
"Document Date" := WorkDate();
end;
Ledger entries do not use a number series. Like in BC, Entry No. is an AutoIncrement field:
field(1; "Entry No."; Integer)
{
Caption = 'Entry No.';
AutoIncrement = true;
}
This one was fun. The first posted entry worked. The second one failed with a duplicate key, because both entries got entry number 0. BusinessCraft now assigns the next value in SQLite when an AutoIncrement field is blank on insert. The first value is 1, just like in BC.
Inventory is a sum FlowField over the ledger entries:
field(6; Inventory; Decimal)
{
FieldClass = FlowField;
CalcFormula = sum("Item Ledger Entry".Quantity where("Item No." = field("No.")));
trigger OnValidate()
var
ItemLedgerPost: Codeunit "Item Ledger Post";
begin
TestField("No.");
TestField(Type, Type::Inventory);
ItemLedgerPost.PostInventoryAdjustment(Rec, Inventory);
end;
}
If you change Inventory on the item card, the app calculates the difference with CalcSums and posts a positive or negative adjustment. When you post a sales order, every inventory line creates a negative adjustment with the invoice number as document number.
The Inventory field on the card is only editable for inventory items. That works through a page variable set in OnAfterGetCurrRecord:
field(Inventory; Rec.Inventory)
{
Editable = IsInventoryItem;
}
Small thing, but it needed subtraction, unary minus, CalcSums, enum comparison, codeunit calls, and page variables in property expressions. A week ago, none of this worked.
This is the part I was most curious about.
BusinessCraft now parses AL report objects with their dataset, DataItemLink, columns, labels, and OnAfterGetRecord triggers. When you press Print, the request page opens, the dataset is built, and the stored .rdl layout is rendered as an HTML preview. The preview can be saved as a PDF.
The dataset looks like any other BC report:
dataitem(Header; "Sales Invoice Header")
{
DataItemTableView = sorting("No.");
RequestFilterFields = "No.", "Customer No.", "Posting Date";
column(InvoiceNo; "No.") { }
column(PostingDate; Format("Posting Date")) { }
column(CustomerName; "Customer Name") { }
column(CompanyDisplayName; CompanyProperty.DisplayName()) { }
dataitem(Line; "Sales Invoice Line")
{
DataItemLink = "Document No." = field("No.");
DataItemTableView = sorting("Document No.", "Line No.");
column(ItemNo; "Item No.") { }
column(Quantity; Quantity) { }
column(LineAmount; "Line Amount") { }
}
}
On the layout side, the renderer handles what these three reports need:
ColSpanFields!X.Value, string concatenation with &, and Sum(...) in the footerGlobals!PageNumber and Globals!TotalPagesIt is not a general RDLC engine yet. Images, charts, subreports, Code.* functions, and Word layouts are still missing. But a sales invoice with a header, lines, and a total renders from an unmodified .rdl file.
The app has a de-DE.xlf, and BusinessCraft now applies it. Captions, tooltips, labels, and option captions follow the session language.
To do that, BusinessCraft has to match a trans-unit to the object, control, and property it belongs to. A trans-unit looks like this:
<trans-unit id="Page 1673401186 - Action 1002420131 - Property 1295455071" size-unit="char" translate="yes" xml:space="preserve">
<source>Print the order confirmation.</source>
<target state="translated">Drucken Sie die Auftragsbestätigung.</target>
<note from="Developer" annotates="general" priority="2"/>
<note from="Xliff Generator" annotates="general" priority="3">Page Sales Order - Action Print - Property ToolTip</note>
</trans-unit>
Page 1673401186? The page is 50107. So where do these numbers come from?
I asked GitHub Copilot in VS Code with Opus 5.5. It did not search the web or open a file. It just answered:
These are not object IDs. They are hashes of the names. The AL compiler calculates them when it generates the
.g.xlf.
| Part | Hash of | Example |
|---|---|---|
Page 1673401186 |
Object name Sales Order |
not 50107 |
Action 1002420131 |
Action name, Print |
|
Property 1295455071 |
Property name | ToolTip (Caption = 2879900210) |
The hash is calculated by
LanguageFileUtilities.GetNameHash(string)inMicrosoft.Dynamics.Nav.CodeAnalysis.dll, which ships with the AL extension. It is not a standard method like MD5 or SHA.
It knew the exact procedure and the exact DLL, without looking anything up.
And the reasoning behind it makes sense:
50107 to 50200, the trans-unit IDs stay the same. Your translations survive.The readable version is always in the Xliff Generator note of the same trans-unit, as you can see above.
Knowing the procedure is one thing. Knowing what it does is another. I looked at GetNameHash separately in VS Code, outside the BusinessCraft project. It turned out to be a 32-bit FNV-1a hash over the UTF-16 bytes of the name, interpreted as a signed integer and shifted by Int32.MaxValue so it is never negative.
The agent that builds BusinessCraft in Cursor never saw any of that. It only got the description of the algorithm, which is a public one, and verified it against the IDs in the .g.xlf. This is the whole implementation in craft-server:
fn name_hash(name: &str) -> i64 {
let mut hash: u32 = 0x811C_9DC5;
for unit in name.encode_utf16() {
for byte in unit.to_le_bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x0100_0193);
}
}
i64::from(i32::from_ne_bytes(hash.to_ne_bytes())) + i64::from(i32::MAX)
}
The first version matched trans-units by the generator note. That breaks as soon as someone edits the note. Now BusinessCraft computes the hashed ID from the object and control names and matches on the ID, like BC does. The note is only the fallback.
The "does not work yet" list from the last post got shorter:
| Last post | Now |
|---|---|
Only = and not |
<>, and, or, +, -, *, unary minus |
No case |
case runs |
No Confirm |
Confirm suspends the call and waits for the user |
| No number series | Number series with LockTable and IncStr |
| No reports | RDLC reports with request page, preview, and PDF |
| No enums | Enum objects as field types |
| No translations | XLIFF per app, per session language |
There is also a lot of web client work: two-column FastTabs on cards, a date picker, action tooltips, an icon for every BC action image, and system pages to create companies, copy them, and set user passwords.
Posting works. But what happens when it fails halfway?
Sales Post inserts the invoice header, copies the lines, writes item ledger entries, and finally deletes the order. Suppose an Error occurs on the third line. In BC, everything is rolled back: no invoice, no ledger entries, the order is untouched, and the invoice number is not used up.
Does BusinessCraft do the same? That is the next thing I will test. A posting that leaves half an invoice behind is worse than no posting at all.
After that: events.
Right now, the service tier raises exactly one event, GetDefaultRoleCenterID. That is enough to open a role center, but not enough for the way AL apps are built. Every real extension hooks into the base app with OnBefore and OnAfter events, and the mini base app should publish them too. Integration events, business events, subscribers in other apps, and the handled pattern.
Without events, there is no extensibility. And without extensibility, it is not Business Central.
I'll keep you updated. :)
BusinessCraft is not affiliated with or endorsed by Microsoft. "Microsoft", "Dynamics 365" and "Business Central" are trademarks of Microsoft Corporation.