Most developers approach Business Central reporting backwards—they focus on the layout first, then try to make the data fit. This leads to frustration and reports that look okay but perform terribly or break under real-world conditions.
Here's what I learned: AL handles the "what and how" while RDLC handles the "where and how pretty."
Think of AL as the engine and RDLC as the dashboard. A Ferrari engine won't help if your dashboard shows the wrong information, and the prettiest dashboard is useless without a working engine underneath.
Skip the theoretical setup guides. Here's what you actually need for productive BC report development:
Your launch.json
file in VS Code needs these settings for report development:
{
"name": "Microsoft cloud sandbox",
"request": "launch",
"type": "al",
"environmentType": "Sandbox",
"environmentName": "MyDev",
"startupObjectId": 50100,
"startupObjectType": "Report",
"breakOnError": true,
"launchBrowser": true
}
The startupObjectId
and startupObjectType
settings automatically open your report when you hit F5, saving you from navigating through menus every time you test.
Let's build something actually useful—a sales commission report that calculates commissions by salesperson with configurable date ranges. This covers the patterns you'll use in 80% of business reports.
Start with the data structure, not the layout:
report 50100 "Sales Commission Report"
{
UsageCategory = ReportsAndAnalysis;
ApplicationArea = All;
Caption = 'Sales Commission Report';
DefaultLayout = RDLC;
RDLCLayout = './Layouts/SalesCommissionReport.rdlc';
dataset
{
dataitem(SalespersonPurchaser; "Salesperson/Purchaser")
{
RequestFilterFields = "Code";
column(SalespersonCode; Code) { }
column(SalespersonName; Name) { }
dataitem(SalesInvoiceHeader; "Sales Invoice Header")
{
DataItemLink = "Salesperson Code" = FIELD(Code);
DataItemTableView = SORTING("Posting Date");
RequestFilterFields = "Posting Date";
column(DocumentNo; "No.") { }
column(PostingDate; "Posting Date") { }
column(SelltoCustomerName; "Sell-to Customer Name") { }
column(AmountIncludingVAT; "Amount Including VAT") { }
trigger OnAfterGetRecord()
begin
// Calculate commission based on business rules
CommissionAmount := CalculateCommission("Amount Including VAT", Code);
end;
}
}
}
requestpage
{
layout
{
area(Content)
{
group(Options)
{
field(CommissionPercentage; CommissionPct)
{
ApplicationArea = All;
Caption = 'Commission %';
DecimalPlaces = 2 : 2;
}
}
}
}
}
var
CommissionPct: Decimal;
CommissionAmount: Decimal;
local procedure CalculateCommission(SalesAmount: Decimal; SalespersonCode: Code[20]): Decimal
var
Commission: Decimal;
begin
// Simple commission calculation - customize based on business rules
Commission := SalesAmount * (CommissionPct / 100);
exit(Commission);
end;
}
What makes this structure work:
Open SQL Server Report Builder and create a new report. Here's the structure that works reliably:
Page Setup:
Layout Structure:
The Table Structure:
Salesperson | Document No | Date | Customer | Amount | Commission |
---|---|---|---|---|---|
SMITH | SI-001 | 1/15/24 | ABC Corp | 1,250 | 62.50 |
SI-002 | 1/18/24 | XYZ Inc | 2,100 | 105.00 | |
Subtotal SMITH: | 3,350 | 167.50 |
This is where most developers struggle. In Report Builder:
Critical tip: Field names in RDLC must match AL column names exactly, including capitalization.
In Report Builder:
Expression for commission total:
=Sum(Fields!AmountIncludingVAT.Value * Parameters!CommissionPct.Value / 100)
After building hundreds of reports, these techniques consistently deliver better results:
Don't pull all data and filter in RDLC—filter in AL:
trigger OnPreDataItem()
begin
// Apply filters based on request page selections
if StartDate <> 0D then
SetRange("Posting Date", StartDate, EndDate);
// Only include active salespersons
SetRange(Blocked, false);
end;
For parent-child relationships, use DataItemLink instead of manual filtering:
// Good - uses indexing
dataitem(SalesLine; "Sales Line")
{
DataItemLink = "Document No." = FIELD("No.");
// Fast because it uses the document number index
}
// Bad - scans entire table
dataitem(SalesLine; "Sales Line")
{
trigger OnAfterGetRecord()
begin
if "Document No." <> SalesHeader."No." then
CurrReport.Skip();
end;
}
In RDLC, use expressions for dynamic formatting:
Highlight overdue invoices:
Background Color: =IIF(DateDiff("d", Fields!DueDate.Value, Now()) > 0, "LightCoral", "White")
Format currency based on negative values:
Color: =IIF(Fields!Amount.Value < 0, "Red", "Black")
When your main report gets too complex, use sub-reports:
// In main report
dataitem(Customer; Customer)
{
column(CustomerNo; "No.") { }
// This triggers a sub-report in RDLC
column(SubReportData; GetSubReportData("No.")) { }
}
local procedure GetSubReportData(CustomerNo: Code[20]): Text
begin
// Return data formatted for sub-report consumption
end;
Mistake: Trying to create one massive report that handles every scenario.
Better approach: Create focused reports for specific use cases, then link them through actions or drill-downs.
Real example: Instead of one 15-page sales analysis report, I built five focused reports (summary, detail, commission, trends, exceptions) that users could navigate between based on their needs.
Mistake: Not considering how your report performs with real data volumes.
Better approach: Test with production-size datasets early in development.
Performance killer: Nested loops without proper filtering can make reports that work fine with test data take 20+ minutes with real data.
Mistake: Embedding business rules directly in calculation formulas.
Better approach: Create procedures that can be modified without touching the layout.
// Bad - hardcoded in multiple places
CommissionAmount := SalesAmount * 0.05;
// Good - centralized and configurable
CommissionAmount := CalculateCommission(SalesAmount, SalespersonCode, CustomerCategory);
Use strategic message statements during development:
trigger OnAfterGetRecord()
begin
Message('Processing Salesperson: %1, Sales: %2', Code, SalesInvoiceHeader.Count);
// Remove before production
end;
In Report Builder, use text boxes to display field values during development:
="Debug: " & Fields!SalespersonCode.Value & " - " & Fields!AmountIncludingVAT.Value
Create a test version of your report with timing:
trigger OnInitReport()
begin
StartTime := CurrentDateTime;
end;
trigger OnPostReport()
begin
Message('Report completed in %1 seconds', (CurrentDateTime - StartTime) / 1000);
end;
Unlike code files, RDLC layouts need special handling in Git:
Layouts
folderWhen deploying custom reports:
Schedule quarterly reviews of custom reports to:
The best Business Central reports I've built weren't the most technically sophisticated—they were the ones that solved actual business problems elegantly and reliably.
Focus on understanding what business users actually need from their reports. Often, they ask for complex layouts when what they really need is simpler access to the right data at the right time.
Start with working functionality, then make it pretty. A ugly report that provides accurate, timely information beats a beautiful report that's slow or unreliable every time.
Most importantly, remember that custom reports are an investment in your organization's decision-making capability. When done right, they become indispensable tools that users depend on daily. When done wrong, they become maintenance nightmares that nobody wants to touch.
Learn how to efficiently prepare your data for migration to Microsoft Dynamics 365 Business Central with these expert data cleanup strategies.
Matias Orlando
2024-01-15
A comprehensive guide for beginners to develop their first extension in Microsoft Dynamics 365 Business Central using Visual Studio Code, covering everything from setup to deployment.
Kery Nguyen
2023-12-15
A step-by-step guide detailing the stages involved from pre-upgrade preparations through to post-upgrade support, ensuring a smooth transition for Microsoft Dynamics 365 Business Central users.
Kery Nguyen
2023-12-15