alguidelines/content/docs/patterns/no-series/index.md
Igor 7dd5bae8c9
Update index.md
## Update No. Series pattern for BC v24+ modern API

Microsoft deprecated `NoSeriesManagement` in BC v24+. Added modern `codeunit "No. Series"` pattern documentation with simplified API examples, comparison table, and method reference. Legacy content preserved for backward compatibility.

Real-world implementation verified in production BC extension.
2025-10-01 15:34:12 -05:00

14 KiB

+++ title = "No. Series" tags = ["AL"] categories = ["Pattern"] +++

Created by Microsoft, Described by Jeremy Vyska (Spare Brained Ideas)

Abstract

The "Number Series" system is used extensively to provide numbers to master records, documents, and other transactions through Microsoft Dynamics 365 Business Central.

Important: BC v24+ Modern Pattern (Updated 2024)

As of Business Central version 24.0 and later, Microsoft deprecated the NoSeriesManagement codeunit in favor of the new codeunit "No. Series" with simplified methods.

Modern Implementation (BC v24+)

Variable declaration:

var
    NoSeries: Codeunit "No. Series";

OnInsert pattern (simplified):

trigger OnInsert()
begin
    if "No." = '' then begin
        MySetup.Get();
        MySetup.TestField("Document Nos.");
        "No. Series" := MySetup."Document Nos.";
        if NoSeries.AreRelated(MySetup."Document Nos.", xRec."No. Series") then
            "No. Series" := xRec."No. Series";
        "No." := NoSeries.GetNextNo("No. Series");
    end;
end;

OnValidate pattern (same as before):

trigger OnValidate()
begin
    if "No." <> xRec."No." then begin
        MySetup.Get();
        NoSeries.TestManual(MySetup."Document Nos.");
        "No. Series" := '';
    end;
end;

Key Differences from Legacy Pattern

Legacy (NoSeriesManagement) Modern (No. Series)
NoSeriesMgt.InitSeries(...) - 5 parameters NoSeries.GetNextNo(...) - 1-2 parameters
NoSeriesMgt.TestManual(...) NoSeries.TestManual(...) - Same method name
NoSeriesMgt.TryGetNextNo(...) NoSeries.PeekNextNo(...) - New name
NoSeriesMgt.SelectSeries(...) NoSeries.AreRelated(...) - Simplified API
Complex parameter passing Simplified, intuitive API

Migration Strategy

For backward compatibility (supporting both BC v23 and v24+):

  1. Use conditional compilation with #if directives based on platform version
  2. Check runtime platform version and branch logic accordingly
  3. Implement both patterns in separate procedures with version detection

{{% alert title="Note" color="warning" %}} The examples below reflect the LEGACY pattern (NoSeriesManagement codeunit) for reference and historical context. For new development on BC v24+, use the modern codeunit "No. Series" pattern shown above. {{% /alert %}}


Description

At the heart of things, the Number Series engine allows users to define structure for a sequential numeric or alphanumeric string (collectively referred to as a 'number series'), then assign that structure to different parts of the system.

Typically, one creates a single number series for each type of data entity. For example, Customers or Sales Orders each could have a series defined so that all new Customers or Sales Orders get a new number automatically.

The Number Series system serves a few ancillary roles:

  • maintains the usage information to know when the last number was generated and on which date
  • allows for date driven structures, so that different periods may have different structures
  • allows control of if manual entries are or are not permitted
  • allow for incrementing in different steps (+1 each time or +1000 each time)
  • warn users as a series is running out of numbers
  • control if any gaps in a series are permitted (as some regional laws do not allow skipping)

This is many roles, features, and controls for generation of a single field so the implementation of this can seem difficult at first.

{{% alert title="Note" color="info" %}} One additional (and somewhat optional) feature in the Number Series engine allows multiple sequences per type, called Relationships. For example, different numbers for Items that are finished goods versus raw materials. This requires additional hooks on the Page. {{% /alert %}}

Usage in Data Entities

To understand an example use in the Base App, the Customer data entity is a good choice.

Implementation to connect the Customer No. field to the Number Series engine is done at the table level. The Customer table contains:

A field to contain the number (typically the primary key), which will be of type Code, length of 20:

field(1; "No."; Code[20])
{
    Caption = 'No.';

    trigger OnValidate()
    begin
        [...]
    end;
}

A field to contain the unique ID of the Number Series, typically called "No. Series"

field(107; "No. Series"; Code[20])
{
    Caption = 'No. Series';
    Editable = false;
    TableRelation = "No. Series";
}

{{% alert title="Note" color="warning" %}} The TableRelation is important, and the Editable being false is advised. {{% /alert %}}

And on the OnInsert trigger, code populates the No. Series and No. field.

trigger OnInsert()
var
    IsHandled: Boolean;
begin
    IsHandled := false;
    OnBeforeInsert(Rec, IsHandled);
    if IsHandled then
        exit;

    if "No." = '' then begin
        SalesSetup.Get();
        SalesSetup.TestField("Customer Nos.");
        NoSeriesMgt.InitSeries(SalesSetup."Customer Nos.", xRec."No. Series", 0D, "No.", "No. Series");
    end;
    [...]

    OnAfterOnInsert(Rec, xRec);
    end;

In the case of Customer, this is a Data Entity within the Sales module of the system. The Sales module has a Sales Setup table where the user can specify a No. Series to use for Customers by default.

SalesSetup.Get(); fetches the sole setup table record.

SalesSetup.TestField("Customer Nos."); is the basic validation that the Sales Setup table has a non-empty Customer Nos. field. If the setup field isn't populated, when the user attempts to create a new Customer, they will receive an error message.

NoSeriesMgt.InitSeries(SalesSetup."Customer Nos.", xRec."No. Series", 0D, "No.", "No. Series"); is more parameters to a function than most expect.

The function call takes the following parameters:

procedure InitSeries(
    DefaultNoSeriesCode: Code[20]; 
    OldNoSeriesCode: Code[20];
    NewDate: Date;
    var NewNo: Code[20];
    var NewNoSeriesCode: Code[20])

The DefaultNoSeriesCode parameter is typically from a setup table. In the Customer example, this comes from the Sales Setup Customer Nos. setting.

The OldNoSeriesCode is used to verify when changing from one No Series to another that they are related.

The NewDate parameter is used to drive numbering based on Dates. This is typically used on Documents. For master entities, like Customer, an empty date 0D can be passed in.

{{% alert title="Note" color="info" %}} Many parts of the NoSeriesManagement codeunit predate method overloading, so if the system was created today, some parameters like NewDate would likely be optional. {{% /alert %}}

The NewNo is a var parameter, and is how the new value comes back from the engine. This also serves two other purposes:

  • if passed in blank, the Number Series used must be configured to have Default Nos. enabled
  • if passed in with a value, the Number Series used must be configured to have Manual Nos enabled.

The NewNoSeriesCode is more often used to switch between related number series, but is a required parameter, and is also passed back from the engine, so it is also a var.

Additionally, it is a good idea to have OnValidate functionality on the No. field. The complete code for the Customer No. field:

field(1; "No."; Code[20])
{
    Caption = 'No.';

    trigger OnValidate()
    begin
        if "No." <> xRec."No." then begin
            SalesSetup.Get();
            NoSeriesMgt.TestManual(SalesSetup."Customer Nos.");
            "No. Series" := '';
        end;
        if "Invoice Disc. Code" = '' then
            "Invoice Disc. Code" := "No.";
    end;
}

If the user has changed the No. field ("No." <> xRec."No."), then:

  • the Number Series is checked if manually setting a new value is allowed via the TestManual function
  • The No. Series is cleared on the record, as it has no longer been given a value from that Series.

Since the Customer data entity supports the No. Series Relationship functionality, there are additional components. On the table, there is a function called AssistEdit:

procedure AssistEdit(OldCust: Record Customer): Boolean
var
    Cust: Record Customer;
begin
    with Cust do begin
        Cust := Rec;
        SalesSetup.Get();
        SalesSetup.TestField("Customer Nos.");
        if NoSeriesMgt.SelectSeries(SalesSetup."Customer Nos.", OldCust."No. Series", "No. Series") then begin
            NoSeriesMgt.SetSeries("No.");
            Rec := Cust;
            OnAssistEditOnBeforeExit(Cust);
            exit(true);
        end;
    end;
end;

{{% alert title="Note" color="warning" %}} The use of WITH is deprecated. While this code block represents the current state of the Base App, the use of WTIH should not be copied. {{% /alert %}}

Similar to the OnInsert trigger, some setup fields are checked.

Then, the SelectSeries function is called. This will present a List to the user of available and relevant No. Series that are connected to the SalesSetup."Customer Nos." by a Number Series Relationship.

From the Customer Page (a Card type page), the No. field has an AssistEdit trigger:

trigger OnAssistEdit()
begin
    if AssistEdit(xRec) then
        CurrPage.Update();
end;

Usage in Journals

Journals utilize a Document No. as a non-primary key field and use a different strategy for use of the Number Series engine. For each Journal Batch, a different No. Series can be set.

For example, on the General Journal Page, in the OnNewRecord, the SetUpNewLine function on the Gen. Journal Line Table is called:

procedure SetUpNewLine(LastGenJnlLine: Record "Gen. Journal Line"; Balance: Decimal; BottomLine: Boolean)
var
    IsHandled: Boolean;
begin
    IsHandled := false;
    OnBeforeSetUpNewLine(GenJnlTemplate, GenJnlBatch, GenJnlLine, LastGenJnlLine, GLSetupRead, Balance, BottomLine, IsHandled);
    if IsHandled then
        exit;

    GenJnlTemplate.Get("Journal Template Name");
    GenJnlBatch.Get("Journal Template Name", "Journal Batch Name");
    GenJnlLine.SetRange("Journal Template Name", "Journal Template Name");
    GenJnlLine.SetRange("Journal Batch Name", "Journal Batch Name");
    if GenJnlLine.FindFirst then begin
        "Posting Date" := LastGenJnlLine."Posting Date";
        "Document Date" := LastGenJnlLine."Posting Date";
        "Document No." := LastGenJnlLine."Document No.";
        OnSetUpNewLineOnBeforeIncrDocNo(GenJnlLine, LastGenJnlLine, Balance, BottomLine);
        if BottomLine and
            (Balance - LastGenJnlLine."Balance (LCY)" = 0) and
            not LastGenJnlLine.EmptyLine
        then
            IncrementDocumentNo(GenJnlBatch, "Document No.");
    end else begin
        "Posting Date" := WorkDate;
        "Document Date" := WorkDate;
        if GenJnlBatch."No. Series" <> '' then begin
            Clear(NoSeriesMgt);
            "Document No." := NoSeriesMgt.TryGetNextNo(GenJnlBatch."No. Series", "Posting Date");
        end;
    end;
    [...]

If the Batch is empty, and if the Gen. Journal Batch has a No. Series set, the Document No. is set from the number series via the NoSeriesManagement codeunit's TryGetNextNo function. This takes two parameters:

  • Which No. Series to get the next number from
  • Which date to fetch for

This function does not update the Last No. Used and Last Date Used fields on the number series. Those will be updated during the Posting process.

If the Batch is not empty and the sum of the existing lines totals to zero (in balance), the General Journal assumes the user wants to start a new set of lines under a new Document No.. The table level procedure IncrementDocumentNo function is called:

procedure IncrementDocumentNo(GenJnlBatch: Record "Gen. Journal Batch"; var LastDocNumber: Code[20])
var
    NoSeriesLine: Record "No. Series Line";
begin
    if GenJnlBatch."No. Series" <> '' then begin
        NoSeriesMgt.SetNoSeriesLineFilter(NoSeriesLine, GenJnlBatch."No. Series", "Posting Date");
        if NoSeriesLine."Increment-by No." > 1 then
            NoSeriesMgt.IncrementNoText(LastDocNumber, NoSeriesLine."Increment-by No.")
        else
            LastDocNumber := IncStr(LastDocNumber);
    end else
        LastDocNumber := IncStr(LastDocNumber);
end;

If the batch's No. Series is set, it is checked if the Increment-By No. setting is anything besides 1. If so, use the special IncrementNoText function.

If neither of those cases is true, then the line's Document No. is updated with the language function IncStr.

Objects to Inspect

Business Central objects in the Base App to review to find out more:

Object Type Object ID Object Name
Table 308 No. Series
Table 309 No. Series Line
Table 310 No. Series Relationship
Page 456 No. Series
Page 457 No. Series Lines
Page 458 No. Series Relationships
Page 571 No. Series List
Codeunit 396 NoSeriesManagement

When not to use

Typically, this pattern is used for unique Data Entities. It is not recommended for use in parts of the system where entries are created permanently (such as an Entry No. for ledgers) or highly mutable / working line data (such as Line No. for journals or document lines).

List of references

For usage of number series, there is more information available on:

For more programming details, there is more information on Microsoft Docs: Number Sequences in Business Central.