diff --git a/content/docs/patterns/no-series/index.md b/content/docs/patterns/no-series/index.md new file mode 100644 index 00000000..a28c48ec --- /dev/null +++ b/content/docs/patterns/no-series/index.md @@ -0,0 +1,286 @@ ++++ +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. + +## 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**: + +```AL +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" + +```AL +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. + +```AL +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: + +```AL +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: + +```AL +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`: + +```AL +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: + +```AL +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: + +```AL +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: + +```AL +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: +- [Microsoft Docs: Create Number Series](https://docs.microsoft.com/en-us/dynamics365/business-central/ui-create-number-series) +- [Microsoft Learn: Set up number series and trail codes](https://docs.microsoft.com/en-us/learn/modules/number-series-trail-codes-dynamics-365-business-central/) + +For more programming details, there is more information on [Microsoft Docs: Number Sequences in Business Central](https://docs.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-number-sequences). \ No newline at end of file