Ejercicio Integrador
Biblioteca Municipal — Proyecto Completo
Dificultad: 10/10Construimos 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.
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 |
Arrancar el contenedor de Business Central
Abre PowerShell como administrador y ejecuta:
Start-BcContainer bccontainer
Espera hasta que el comando termine sin errores. Puedes verificar que está corriendo con
Get-BcContainers.
Crear el proyecto: AL: Go!
Abre la Paleta de comandos con Ctrl + Shift + P y escribe:
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:
📁 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)
Configurar app.json
Abre app.json y ajusta el rango de IDs para que coincida con los objetos de este
ejercicio (50100–50999):
{
"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": []
}
Configurar launch.json
Abre .vscode/launch.json y asegúrate de que "server" apunta a tu
contenedor local:
{
"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"
}]
}
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:
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.
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 |
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 |
Probar en Business Central local
Tras publicar con Ctrl + F5, BC se abrirá automáticamente en el navegador. También puedes acceder manualmente:
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:
// 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
OnDeletedebe 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.
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.
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") { }
}
}
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.
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
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
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
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).
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.
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
DataItemTableFilterdel Report. - Los FlowFields no se guardan en SQL: se calculan bajo demanda con
CalcFieldso 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 enOnAfterGetRecord. - Al probar en BC, accede directamente por URL:
http://bccontainer/BusinessCentral/?page=50100.