IceOnlys Blog

All Around Microsoft Dynamics Business Central

BusinessCraft: A Mini Base App, RDLC Reports, and XLIFF

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 mini base app

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:

  1. Create a customer and an item. Both get their number from a number series.
  2. Create a sales quote and add lines.
  3. Make Order: confirm, and the quote becomes an order.
  4. Post: confirm, and the order becomes a posted sales invoice. Inventory items create item ledger entries.
  5. Print: the request page opens and the RDLC report shows a preview.

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.

Number series

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 and AutoIncrement

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.

RDLC

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:

  • Page header and body, page size and margins
  • Positioned textboxes with font, alignment, and borders
  • A tablix with header, detail, and footer rows, including ColSpan
  • Fields!X.Value, string concatenation with &, and Sum(...) in the footer
  • Globals!PageNumber and Globals!TotalPages

It 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.

XLIFF

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, and Opus 5.5 just knew

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) in Microsoft.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:

  • Why names instead of IDs? If you renumber an object, for example from 50107 to 50200, the trans-unit IDs stay the same. Your translations survive.
  • The downside: if you rename the page, the action, a field, or a control, the ID changes. The existing translation no longer matches and has to be reassigned.

The readable version is always in the Xliff Generator note of the same trans-unit, as you can see above.

In Rust

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.

What changed since the last post

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.

Numbers

  • 72 commits, up from 52
  • About 40,900 lines of Rust, up from about 31,500
  • One mini base app: 3 reports, 5 number series, 1 ledger, and a German translation
  • All of it built with a Cursor Pro subscription for $20 a month

Next

Transactions

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.

Events

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.