Ejercicio Integrador

Biblioteca Municipal — Proyecto Completo

Dificultad: 10/10

Construimos desde cero el sistema de gestión de una biblioteca municipal. El ejercicio recorre todos los conceptos: configuración del entorno, Enums, Tablas, Claves, TableRelations, FlowFields, FlowFilters, Triggers, Pages, FactBoxes, Parts, Queries y Reports.


Paso 0 — Entorno

Preparar VS Code y crear el proyecto

Antes de escribir una sola línea de AL hay que preparar el entorno de desarrollo. Sigue estos pasos en orden la primera vez; en sesiones posteriores solo necesitarás arrancar el contenedor y abrir la carpeta.

1

Instalar extensiones en VS Code

Abre el panel de extensiones con Ctrl + Shift + X e instala las dos extensiones oficiales de Microsoft:

Extensión Para qué sirve
AL Language Sintaxis, snippets, IntelliSense, compilación y debug
AL Language Extension Pack Herramientas adicionales para BC
2

Arrancar el contenedor de Business Central

Abre PowerShell como administrador y ejecuta:

Administrador: PowerShell
Start-BcContainer bccontainer

Espera hasta que el comando termine sin errores. Puedes verificar que está corriendo con Get-BcContainers.

3

Crear el proyecto: AL: Go!

Abre la Paleta de comandos con Ctrl + Shift + P y escribe:

Paleta de comandos — VS Code
AL: Go!

VS Code te pedirá una carpeta de destino y la versión del servidor (elige Latest o la que coincida con tu contenedor). Esto genera la estructura base:

Explorador — Estructura generada
📁 BibliotecaMunicipal/
   ├── .vscode/
   │   └── launch.json        // configuración de conexión al servidor BC
   ├── app.json               // metadatos: ID, nombre, versión, dependencias
   └── HelloWorld.al          // fichero de ejemplo (puedes borrarlo)
4

Configurar app.json

Abre app.json y ajusta el rango de IDs para que coincida con los objetos de este ejercicio (50100–50999):

app.json
{
    "id":          "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",  // GUID único (no tocar)
    "name":        "BibliotecaMunicipal",
    "publisher":   "TuNombre",
    "version":     "1.0.0.0",
    "idRanges": [{ "from": 50100, "to": 50999 }],
    "runtime":     "12.0",
    "platform":    "22.0.0.0",
    "application": "22.0.0.0",
    "dependencies": []
}
5

Configurar launch.json

Abre .vscode/launch.json y asegúrate de que "server" apunta a tu contenedor local:

.vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [{
        "name":              "Publicar en contenedor local",
        "request":           "launch",
        "type":              "al",
        "environmentType":   "OnPrem",
        "server":            "http://bccontainer",  // nombre del contenedor
        "serverInstance":    "BC",
        "authentication":    "UserPassword",
        "startupObjectId":   50100,         // abrirá la Socio List al publicar
        "startupObjectType": "Page"
    }]
}
6

Descargar los símbolos: AL: Download Symbols

Los símbolos son los metadatos de todas las tablas, páginas y codeunits base de BC. Sin ellos IntelliSense no funciona. Ejecútalo antes de empezar a escribir código y cada vez que BC se actualice:

Paleta de comandos
AL: Download Symbols

VS Code descargará los ficheros .app en la carpeta .alpackages/ del proyecto. Cuando aparezcan Microsoft_Application_..., Microsoft_Base Application_... y Microsoft_System_..., puedes empezar a codificar.

⚠️ Si falla la descarga

Comprueba que el contenedor está corriendo, que el valor de "server" en launch.json coincide exactamente con el nombre del contenedor, y que las credenciales son las que configuraste al crear el contenedor.

7

Crear los ficheros AL con snippets

Crea un fichero .al por objeto (por ejemplo EstadoPrestamo.Enum.al). Usa los snippets para generar la plantilla base:

Escribe en el editor… Obtiene…
tenum + Tab Plantilla completa de un Enum AL
ttable + Tab Plantilla completa de una tabla AL
tpage + Tab Plantilla de una Page (card, list…)
tquery + Tab Plantilla de una Query
treport + Tab Plantilla de un Report
tfield + Tab Declaración de un campo
8

Compilar y publicar

Una vez tienes los ficheros escritos, publica con:

Atajo Acción
Ctrl + F5 Compilar y publicar sin debugger — el 90% de las veces
F5 Compilar, publicar y abrir BC con el debugger activo
Ctrl + Shift + B Solo compilar (sin publicar) — para detectar errores rápido
9

Probar en Business Central local

Tras publicar con Ctrl + F5, BC se abrirá automáticamente en el navegador. También puedes acceder manualmente:

🌐 http://bccontainer/BusinessCentral

Para encontrar los objetos del ejercicio directamente, usa la búsqueda universal de BC (Alt + Q o el icono 🔍 en la barra superior) y busca los nombres de tus páginas:

Busca en BC… Abre…
Socio List Lista de socios con FactBox de préstamos
Socio Card Ficha de socio con estadísticas y FlowFilter
Socios Morosos Informe con RequestPage de fechas

También puedes navegar directamente añadiendo el parámetro page a la URL:

Barra de direcciones del navegador
// Socio List (Page 50100)
http://bccontainer/BusinessCentral/?page=50100

// Socio Card (Page 50101)
http://bccontainer/BusinessCentral/?page=50101

// Report Socios Morosos (Report 50100)
http://bccontainer/BusinessCentral/?report=50100

✅ Qué verificar al probar

  • Al crear un socio nuevo, Fecha Alta no se rellena sola (es manual). Rellénala para el ejercicio.
  • Al crear un préstamo, Fecha Inicio se pone a Today automáticamente gracias al OnInsert.
  • Intenta poner una Fecha Devolución anterior a la de inicio: debe aparecer el mensaje de error del OnValidate.
  • Intenta borrar un préstamo en estado Activo: el OnDelete debe impedirlo.
  • En la Socio Card, el campo Total Prestamos debe actualizarse al añadir préstamos (es un FlowField: se recalcula al mostrar).
  • Filtra el campo Date Filter en la Card: Dias Totales Prestamo debe cambiar según el rango introducido.
  • En el FactBox lateral de la List, cambia de socio y comprueba que el historial se filtra automáticamente por el socio seleccionado (SubPageLink).

Paso 1 — Enum

Estado del Préstamo

Empezamos definiendo el Enum de estados. Al ser un objeto independiente, podremos reutilizarlo en cualquier tabla. Dejamos la posición 0 vacía como estado "sin seleccionar" y dejamos huecos entre posiciones para poder añadir valores en el futuro.

AL EstadoPrestamo.Enum.al
enum 50100 "Estado Prestamo"
{
    value(0;  " ")             { }
    value(10; "Activo")        { }
    value(20; "En Devolucion") { }
    value(30; "Devuelto")      { }
    value(40; "Vencido")       { }
}

Paso 2 — Tablas y Tipos de Datos

Tabla de Socios y Tabla de Libros

Definimos las dos tablas maestras del sistema. Usamos Code[20] para los identificadores (siempre en mayúsculas, sin espacios, búsqueda rápida), Text para descripciones libres y Date para las fechas. La propiedad NotBlank asegura que los campos clave no puedan quedar vacíos.

AL Socio.Table.al
table 50100 "Socio"
{
    fields
    {
        field(1;  "No.";          Code[20])   { NotBlank = true; }
        field(2;  "Nombre";       Text[100])  { }
        field(3;  "Email";        Text[100])
        {
            ExtendedDataType = EMail;
        }
        field(4;  "Telefono";     Text[20])   { }
        field(5;  "Fecha Alta";   Date)       { }
        field(6;  "Activo";       Boolean)
        {
            InitValue = true;
        }

        // FlowFields — se calculan bajo demanda, no se guardan en SQL
        field(20; "Total Prestamos"; Integer)
        {
            FieldClass  = FlowField;
            CalcFormula = count("Prestamo" where ("No. Socio" = field("No.")));
        }
        field(21; "Dias Totales Prestamo"; Integer)
        {
            FieldClass  = FlowField;
            CalcFormula = sum("Prestamo"."Dias Prestamo"
                              where ("No. Socio"   = field("No."),
                                     "Date Filter" = field("Date Filter")));
        }

        // FlowFilter — el usuario lo rellena en la Card para filtrar el FlowField de arriba
        field(30; "Date Filter"; Date)
        {
            FieldClass = FlowFilter;
        }
    }
    keys
    {
        key(PK; "No.")     { }
        key(SK1; "Nombre") { }
    }
}
AL Libro.Table.al
table 50101 "Libro"
{
    fields
    {
        field(1; "No.";      Code[20])  { NotBlank = true; }
        field(2; "Titulo";   Text[200]) { }
        field(3; "Autor";    Text[100]) { }
        field(4; "ISBN";     Code[20])  { }
        field(5; "Disponible"; Boolean)
        {
            InitValue = true;
        }

        // FlowField: ¿tiene préstamos activos?
        field(20; "Tiene Prestamos Activos"; Boolean)
        {
            FieldClass  = FlowField;
            CalcFormula = exist("Prestamo"
                                 where ("No. Libro" = field("No."),
                                        Estado       = const(Activo)));
        }
    }
    keys
    {
        key(PK; "No.")    { }
        key(SK1; "Autor") { }
        key(SK2; "ISBN")
        {
            Unique = true;
        }
    }
}

Paso 3 — Claves Primarias y TableRelations

Tabla de Préstamos

La tabla central del sistema. Usamos AutoIncrement en el campo Entry No. para la PK, garantizando un identificador único sin gestión manual. Los campos "No. Socio" y "No. Libro" llevan TableRelation para que BC genere automáticamente el icono de lupa y valide que los valores existan en sus tablas de origen.

AL Prestamo.Table.al
table 50102 "Prestamo"
{
    fields
    {
        field(1; "Entry No.";          Integer)
        {
            AutoIncrement = true;
        }
        field(2; "No. Socio";          Code[20])
        {
            TableRelation = "Socio";  // lupa automática + validación
        }
        field(3; "No. Libro";          Code[20])
        {
            TableRelation = "Libro";
        }
        field(4; "Fecha Inicio";       Date) { }
        field(5; "Fecha Devolucion";   Date)
        {
            trigger OnValidate()
            begin
                // Validamos que la devolución no sea anterior al inicio
                if Rec."Fecha Devolucion" < Rec."Fecha Inicio" then
                    Error('La fecha de devolución no puede ser anterior a la de inicio.');

                // Calculamos los días automáticamente
                Rec."Dias Prestamo" := Rec."Fecha Devolucion" - Rec."Fecha Inicio";
            end;
        }
        field(6; "Dias Prestamo";      Integer)  { }
        field(7; "Estado";             Enum "Estado Prestamo") { }
        field(8; "Modificado Por";    Code[50]) { }
    }
    keys
    {
        key(PK;  "Entry No.")             { }
        key(SK1; "No. Socio")
        {
            MaintainSiftIndex = true;  // acelera sumas por socio
        }
        key(SK2; "No. Libro")            { }
        key(SK3; "Fecha Inicio")         { }
    }

    // Triggers de tabla
    trigger OnInsert()
    begin
        Rec."Fecha Inicio" := Today;
        Rec.Estado        := Rec.Estado::Activo;
    end;

    trigger OnModify()
    begin
        Rec."Modificado Por" := UserId;
    end;

    trigger OnDelete()
    begin
        if Rec.Estado = Rec.Estado::Activo then
            Error('No se puede eliminar un préstamo activo.');
    end;

    trigger OnRename()
    begin
        Error('Los préstamos no pueden ser renombrados.');
    end;
}

Paso 4 — Pages, Parts y FactBoxes

Páginas de la interfaz

Creamos primero el ListPart de préstamos (que usaremos como FactBox), luego la List y la Card de socios. La Card incluye el FactBox lateral que se actualiza automáticamente al cambiar de socio gracias a SubPageLink.

ListPart de préstamos

AL PrestamoListPart.Page.al
page 50110 "Prestamo ListPart"
{
    PageType        = ListPart;
    ApplicationArea = All;
    SourceTable     = "Prestamo";

    layout
    {
        area(Content)
        {
            repeater(Lines)
            {
                field("Entry No.";        Rec."Entry No.")        { }
                field("No. Libro";         Rec."No. Libro")         { }
                field("Fecha Inicio";      Rec."Fecha Inicio")      { }
                field("Fecha Devolucion";  Rec."Fecha Devolucion")  { }
                field("Dias Prestamo";    Rec."Dias Prestamo")    { }
                field(Estado;           Rec.Estado)           { }
            }
        }
    }
}

List de socios

AL SocioList.Page.al
page 50100 "Socio List"
{
    PageType        = List;
    ApplicationArea = All;
    UsageCategory   = Lists;
    SourceTable     = "Socio";
    CardPageId      = "Socio Card";
    Editable        = false;

    layout
    {
        area(Content)
        {
            repeater(GroupName)
            {
                FreezeColumnID = "No.";  // columna fija al hacer scroll
                field("No.";              Rec."No.")              { }
                field("Nombre";           Rec.Nombre)            { }
                field("Email";            Rec.Email)             { }
                field("Fecha Alta";       Rec."Fecha Alta")       { }
                field("Total Prestamos";  Rec."Total Prestamos")  { }  // FlowField
            }
        }
        area(FactBoxes)
        {
            part(HistorialPrestamos; "Prestamo ListPart")
            {
                SubPageLink = "No. Socio" = field("No.");
            }
        }
    }
}

Card de socios

AL SocioCard.Page.al
page 50101 "Socio Card"
{
    PageType        = Card;
    ApplicationArea = All;
    UsageCategory   = Administration;
    SourceTable     = "Socio";

    layout
    {
        area(Content)
        {
            group(General)
            {
                field("No.";        Rec."No.")        { Importance = Promoted; }
                field(Nombre;     Rec.Nombre)     { Importance = Promoted; }
                field(Email;      Rec.Email)      { }
                field(Telefono;   Rec.Telefono)   { }
                field("Fecha Alta"; Rec."Fecha Alta") { }
                field(Activo;     Rec.Activo)     { }
            }
            group(Estadisticas)
            {
                field("Total Prestamos";     Rec."Total Prestamos")
                {
                    Importance = Promoted;
                    Editable   = false;  // FlowField: solo lectura
                }
                field("Dias Totales Prestamo"; Rec."Dias Totales Prestamo")
                {
                    Importance = Additional;
                    Editable   = false;
                }
                // FlowFilter: el usuario puede filtrar los días por período
                field("Date Filter"; Rec."Date Filter") { }
            }
        }
        area(FactBoxes)
        {
            part(HistorialPrestamos; "Prestamo ListPart")
            {
                SubPageLink = "No. Socio" = field("No.");
            }
        }
    }

    trigger OnAfterGetRecord()
    begin
        // Avisamos en el título si el socio tiene préstamos activos
        if Rec."Total Prestamos" > 0 then
            CurrPage.Caption := Rec.Nombre + ' — Préstamos activos'
        else
            CurrPage.Caption := Rec.Nombre;
    end;
}

Paso 5 — Query

Top socios por días de préstamo

Creamos una Query para calcular, de forma eficiente y en una sola petición SQL, cuántos días lleva prestando libros cada socio. Usamos LeftOuterJoin para incluir también los socios sin préstamos (aparecerán con 0 días).

AL DiasPorSocio.Query.al
query 50100 "Dias Totales por Socio"
{
    Caption   = 'Días Totales de Préstamo por Socio';
    QueryType = Normal;

    elements
    {
        dataitem(Socio; Socio)
        {
            // Sin Method → claves de agrupación
            column(Num_Socio;    "No.")  { Caption = 'Nº Socio'; }
            column(Nombre_Socio; Nombre) { Caption = 'Nombre'; }

            dataitem(Prestamo; "Prestamo")
            {
                DataItemLink = "No. Socio" = Socio."No.";
                SqlJoinType  = LeftOuterJoin;

                column(Total_Dias; "Dias Prestamo")
                {
                    Caption = 'Total Días';
                    Method  = Sum;    // SQL suma los días por socio
                }
                column(Num_Prestamos; "Entry No.")
                {
                    Caption = 'Nº Préstamos';
                    Method  = Count;  // SQL cuenta los préstamos por socio
                }
            }
        }
    }
}

// Uso desde código AL:
procedure MostrarTopSocios()
var
    Q: Query "Dias Totales por Socio";
begin
    Q.SetFilter(Num_Socio, 'S*');  // filtro ANTES de Open()
    Q.Open();
    while Q.Read() do
        Message('%1 | %2 | %3 días', Q.Num_Socio, Q.Nombre_Socio, Q.Total_Dias);
    Q.Close();
end;

Paso 6 — Report, Jerarquía y RequestPage

Informe de socios morosos

El informe final: lista todos los socios con préstamos vencidos, con sus días de retraso acumulados. La RequestPage permite al usuario filtrar por rango de fechas. La jerarquía de DataItems anida los préstamos dentro de cada socio. Los totales se acumulan con variables globales.

AL SociosMorosos.Report.al
report 50100 "Socios Morosos"
{
    Caption         = 'Listado de Socios Morosos';
    UsageCategory   = ReportsAndAnalysis;
    ApplicationArea = All;

    dataset
    {
        // ── NIVEL 1: SOCIO ─────────────────────────────────
        dataitem(Socio; Socio)
        {
            column(Socio_No;        "No.")          { }
            column(Socio_Nombre;    Nombre)         { }
            column(Socio_Email;     Email)          { }
            column(Socio_TotalDias; TotalDiasSocio) { }  // acumulado del hijo

            trigger OnPreDataItem()
            begin
                SetCurrentKey(Nombre);  // orden alfabético
            end;

            trigger OnAfterGetRecord()
            begin
                TotalDiasSocio := 0;   // reiniciamos al cambiar de socio
            end;

            // ── NIVEL 2: PRÉSTAMO ───────────────────────────────
            dataitem(Prestamo; "Prestamo")
            {
                DataItemLink        = "No. Socio" = FIELD("No.");
                DataItemTableFilter = Estado = FILTER(Vencido);  // solo vencidos

                column(Prest_No;     "Entry No.")       { }
                column(Prest_Libro;  "No. Libro")       { }
                column(Prest_Inicio; "Fecha Inicio")    { }
                column(Prest_Dev;    "Fecha Devolucion") { }
                column(Prest_Dias;   "Dias Prestamo")   { }

                trigger OnPreDataItem()
                begin
                    // Aplicamos los filtros de fecha de la RequestPage
                    if FechaDesde <> 0D then
                        SetFilter("Fecha Inicio", '>=%1', FechaDesde);
                    if FechaHasta <> 0D then
                        SetFilter("Fecha Inicio", '<=%1', FechaHasta);

                    SetCurrentKey("Fecha Inicio");
                    SetAscending("Fecha Inicio", false);  // más recientes primero
                end;

                trigger OnAfterGetRecord()
                begin
                    TotalDiasSocio += "Dias Prestamo";  // acumulamos en el padre
                end;
            }
        }
    }

    requestpage
    {
        SaveValues = true;
        layout
        {
            area(Content)
            {
                group(Fechas) { Caption = 'Filtrar por fecha de inicio';
                    field(Desde; FechaDesde) { ApplicationArea = All; Caption = 'Desde'; }
                    field(Hasta; FechaHasta) { ApplicationArea = All; Caption = 'Hasta'; }
                }
            }
        }
        trigger OnOpenPage()
        begin
            FechaDesde := DMY2Date(1, 1, Date2DMY(Today(), 3));  // 1 enero año actual
            FechaHasta := Today();
        end;
    }

    var
        TotalDiasSocio: Integer;
        FechaDesde    : Date;
        FechaHasta    : Date;

    rendering
    {
        layout(RDLCLayout)
        {
            Type       = RDLC;
            LayoutFile = 'src/layouts/SociosMorosos.rdlc';
        }
    }

    trigger OnPreReport()
    begin
        if (FechaDesde <> 0D) and (FechaHasta <> 0D) then
            if FechaDesde > FechaHasta then
                Error('La fecha "Desde" no puede ser posterior a la fecha "Hasta".');
    end;

    trigger OnPostReport()
    begin
        Message('Informe de morosos generado correctamente.');
    end;
}

Resumen de objetos creados

ID Tipo Nombre Concepto aplicado
50100 Enum Estado Prestamo Enum con huecos entre posiciones
50100 Table Socio Tipos de dato, FlowField (Count/Sum), FlowFilter
50101 Table Libro Tipos de dato, FlowField (Exist), clave Unique
50102 Table Prestamo PK AutoIncrement, TableRelation, Triggers, Enum
50100 Page (List) Socio List List, repeater, FreezeColumnID, FactBox
50101 Page (Card) Socio Card Card, groups, Importance, FactBox, OnAfterGetRecord
50110 Page (ListPart) Prestamo ListPart ListPart, SubPageLink
50100 Query Dias Totales por Socio DataItemLink, LeftOuterJoin, Method Sum/Count
50100 Report Socios Morosos Jerarquía, DataItemTableFilter, RequestPage, acumulación

💡 Claves del ejercicio

  • El Enum se define una sola vez y se reutiliza en la tabla y en el DataItemTableFilter del Report.
  • Los FlowFields no se guardan en SQL: se calculan bajo demanda con CalcFields o al mostrarlos en una Page.
  • El FlowFilter "Date Filter" permite que el usuario filtre los días totales por período desde la propia Card, sin tocar la base de datos.
  • Los Triggers de tabla garantizan la integridad siempre, independientemente de desde qué pantalla llegue la operación.
  • El SubPageLink conecta el FactBox con el registro seleccionado en la List o Card padre.
  • La Query calcula los totales en una sola petición SQL, sin bucles en AL.
  • En el Report, los filtros de la RequestPage se aplican siempre en OnPreDataItem, nunca en OnAfterGetRecord.
  • Al probar en BC, accede directamente por URL: http://bccontainer/BusinessCentral/?page=50100.
← Volver a Teoría