Saltearse al contenido

Estándares de Desarrollo | Documentación Técnica

La presente guía de estándares de desarrollo en AL para Virtual Office Group C.A. establece un marco técnico unificado que garantiza la calidad, escalabilidad y compatibilidad de nuestras soluciones dentro del ecosistema Microsoft Dynamics 365 Business Central.

Al adoptar las directrices oficiales de Microsoft, nuestra organización asegura el desarrollo de extensiones cloud-ready que minimizan conflictos técnicos y facilitan actualizaciones continuas. Este compromiso optimiza el mantenimiento y garantiza una experiencia de usuario coherente y profesional.

Se establece como norma obligatoria que la sintaxis y nomenclatura técnica dentro del código fuente se realice estrictamente en inglés.

Esta directriz abarca:

  • Identificadores de Objetos: (Tables, Pages, Codeunits, Reports, Enums, Queries).

  • Identificadores de Campos: En tablas, extensiones de tablas, paginas, extensiones de paginas, queries y reportes.

  • Lógica de Programación: Nombres de variables, constantes,Errores,Texto, y métodos (procedimientos).

  • Arquitectura de Eventos: Nombres de suscriptores y publicadores de eventos.

Propiedades Caption y ToolTip, y variables de tipo Label.

utilizar los comentarios de los marcadores de posición para identificar los valores a utilizar

PostConfirmQst: Label '¿Desea registrar la factura %1 del cliente %2?', Comment = '%1 = No. Factura, %2 = Nombre Cliente';

Se utiliza para notas técnicas sobre el propósito del campo, redactadas en español.

field(2; "Saldo Pendiente"; Decimal)
{
Description = 'Campo calculado para el total de la deuda';
}

Se permite el español para comentarios internos para facilitar la comunicación inmediata entre el equipo.

Para garantizar la mantenibilidad, los proyectos deben seguir una estructura jerárquica organizada. Se prohíbe el uso de estructuras planas en proyectos medianos o grandes.

El directorio raíz de la extensión debe contener los archivos de configuración y las carpetas principales de recursos:

.alpackages/: Dependencias y símbolos (símbolos).

src/: Directorio contenedor de todo el código fuente.

test/: Unidades de prueba (Test Codeunits).

app.json: Manifiesto obligatorio de la extensión.

logo.jpg: Imagen representativa de la extensión (formato 150x150 recomendado).

README.md: Documentación general del proyecto.

.gitignore: (Obligatorio) Configuración de exclusión para Git.

.vscode/: Carpeta que contiene la configuración del entorno (ej. launch.json y settings.json).

AppsourceCop.json: Archivo de configuración para validaciones .

Translations/: (Opciónal) Configuración de traducciones.

  • Directory.alpackages/
  • Directory.vscode/
    • launch.json
    • settings.json
  • package.json
  • Directorysrc/
  • Directorytest/
  • logo.jpg
  • app.json
  • .gitignore
  • README.md
  • AppsourceCop.json

Estructura de la Carpeta /src (Agrupación Funcional)

Sección titulada «Estructura de la Carpeta /src (Agrupación Funcional)»

Los objetos deben agruparse en subcarpetas dentro de src/ basándose en su area de negocio. Una estructura estándar es:

  • Directory.alpackages/
  • Directory.vscode/
  • Directorysrc
    • DirectoryBank
      • DirectoryPage
        • VOGBankCard.Page.al
        • VOGBankList.Page.al
      • DirectoryTable
        • VOGBank.Table.al
      • DirectoryPageExtension
        • VOGBankCard.PageExt.al
        • VOGBankList.PageExt.al
  • Directorytest/
  • logo.jpg
  • app.json
  • .gitignore
  • README.md
  • AppsourceCop.json
  • Directory.alpackages/
  • Directory.vscode/
  • Directorysrc
    • DirectoryOCR Document
      • DirectoryPage
        • IncomingDocumetList.Page.al
        • IncomingDocumentDetail.Page.al
      • DirectoryTableExtension
        • IncomingDocument.TableExt.al
      • DirectoryCodeunit
        • OCRSend.Codeunit.al
  • Directorytest/
  • logo.jpg
  • app.json
  • .gitignore
  • README.md
  • AppsourceCop.json

Los objetos dentro de cada módulo funcional se clasificarán en subcarpetas por tipo. Se requiere la siguiente estructura de directorios:

  • Table
  • TableExtension
  • Page
  • PageExtension
  • Codeunit
  • Report
  • ReportExtension
  • Query
  • QueryExtension
  • Enum

Los archivos de traducción deben residir en una carpeta llamada Translations en la raíz de tu proyecto.

  • Directory.alpackages/
  • Directory.vscode/
  • Directorysrc
    • DirectoryOCR Document
      • DirectoryPage
        • IncomingDocumetList.Page.al
        • IncomingDocumentDetail.Page.al
      • DirectoryTableExtension
        • IncomingDocument.TableExt.al
      • DirectoryCodeunit
        • OCRSend.Codeunit.al
  • Directorytest/
  • logo.jpg
  • DirectoryTranslation
    • example.es-ES.xlf
  • app.json
  • .gitignore
  • README.md
  • AppsourceCop.json

Todas las extensiones deben utilizar el prefijo VOG para garantizar la unicidad y evitar conflictos con objetos de terceros o de Microsoft.

El prefijo se aplicara de la siguiente manera:

//Enumeraciones
enum 50009 VOGTimeExecUpdateCurrency
{
}
// Pagina
page 50009 VOGConfUpdateCurrency
{
}
// Tabla
table 50009 VOGConfigUpdateCurrency
{
}
// Codeunit
codeunit 50009 VOGCurrencyUpdater
{
}
// reportes
report 50009 VOGCurrencyUpdateReport
{
}
//queries
query 50009 VOGCurrencyQuery
{
}
//Permisos
PermissionSet 50009 VOGCurrencyPermissionSet
{
}
// Extensiones de tablas
tableextension 50009 VOGCustomerExtension extends Customer
{
fields
{
field(50000; VOGPreferredLanguage; Code[10])
{
DataClassification = CustomerContent;
}
}
}
//Extensión de pagina
pageextension 50009 VOGCustomerCard extends CustomerCard
{
fields
{
field(50000; VOGPreferredLanguage; Code[10])
{
DataClassification = CustomerContent;
}
}
}
// Extensión de reporte
reportextension 50100 "StandardSalesInvoiceExt" extends "Standard Sales - Invoice"
{
dataset
{
// Agregamos un campo a la sección del encabezado (Header)
add(Header)
{
column(CustomTrackingNumber_Lbl; CustomTrackingNumberLbl) { }
column(CustomTrackingNumber; Rec."Custom Tracking Number") { }
}
}
}
var
VOGTempCustomer: Record Customer temporary;
VOGGenJnlPostLine: Codeunit "Gen. Jnl.-Post Line";
local procedure VOGCalculateDiscount(Amount: Decimal): Decimal
begin
end

El nombre del archivo debe coincidir exactamente con el nombre del objeto interno (excluyendo la extensión .al).

Cada nombre de archivo debe contener el prefijo establecido, nombre del objeto (usando solo los caracteres A-Z, a-z y 0-9), el tipo de objeto y la extensión del archivo.

Objetos NuevosExtensiones de objetos
VOG<nombre>.<NombreDeObjecto>.alVOG<nombre>.<NombreDeObjecto>Ext.al
ObjetosExtensiones de objeto
VOGCustomerCard.Page.alVOGCustomerCard.PageExt.al

Para el tipo de objeto en la nomenclatura del archivo, se deben utilizar las siguientes abreviaturas:

ObjetosAbbreviación
PagePage
Page ExtensionPageExt
Page CustomizationPageCust
CodeunitCodeunit
TableTable
Table ExtensionTableExt
XML PortXmlport
ReportReport
Request PageRequestPage
QueryQuery
EnumEnum
Enum ExtensionEnumExt
Control Add-insControlAddin
DotnetDotnet
ProfileProfile
InterfaceInterface
Permission SetPermissionSet
Permission Set ExtensionPermissionSetExt

En el caso de los objetos, estos ejemplos muestran cómo asignar un nombre a los archivos.

Nombre del objetoNombre de archivo
codeunit 70000000 VOGSalespersonVOGSalesPerson.Codeunit.al
páge 70000000 VOGVendorVOGVendor.Page.al
pageextension 70000000 VOGSalesperson extends “Vendor Card”VOGSalesperson.PageExt.al
  • Utilice todas las letras minúsculas para las palabras clave del idioma reservado.
  • Utilice 4 espacios para la sangría.
  • Los corchetes siempre deben comenzar en una nueva línea. Si solo hay una propiedad, se puede colocar en una sola línea.
  • Escribir en PascalCase para nombres de objetos, variables y métodos.
page 123 PageName
{
actions
{
area(Processing)
{
action(ActionName)
{
trigger OnAction()
begin
end;
}
}
}
var
VOGTempCustomer: Record Customer temporary;
[EventSubscriber(ObjectType::Page, Page::"Item Card", 'OnAfterGetCurrRecordEvent', '', false, false)]
local procedure VOGOnOpenItemCard(var rec: Record Item)
var
OnRecord: Option " ", Item, Contact;
begin
EnablePictureAnalyzerNotification(rec."No.", OnRecord::Item);
end;
}

Dentro de un archivo de código .al, la estructura de todos los objetos debe seguir la secuencia:

  1. Propiedades del objeto
  2. Las construcciones específicas del objeto, como
    • Campos de tabla
    • Diseño de página
    • Acciones
    • Desencadenantes (triggers)
  3. Variables globales
    • Etiquetas
    • Variables globales
  4. Métodos u Procedimientos

Todos los objetos deben declararse dentro de un Namespace. Esta práctica es obligatoria para:

  • Evitar conflictos de nombres con desarrollos estándar o extensiones de terceros (AppSource).
  • Optimizar la mantenibilidad, agrupando los objetos de forma lógica según su funcionalidad o área de negocio.”
namespace <Empresa>.<Proyecto>.<Modulo>
- Empresa: Prefijo de la orginanización (VOG). Por defecto el valor siempre sera VOG.
- Proyecto: Nombre del proyecto o solución al que pertenece la extensión.
- Modulo: (Opcional) Nombre del módulo o área funcional dentro del proyecto.
namespace VOG.OfficeManagement.RoomBooking
table 50100 VOGOfficeRoom
{
DataClassification = CustomerContent;
fields
{
field(1;RoomName; Text[100])
{
DataClassification = CustomerContent;
}
field(2;Capacity; Integer)
{
DataClassification = CustomerContent;
MinValue = 1;
BlankZero = true;
}
}
}

Cuando se utilicen objetos de otros namespaces o dependencias externas, se debe emplear la directiva using al inicio del archivo. Esto permite referenciar objetos externos de manera clara.

namespace VOG.OfficeManagement.RoomBooking;
using VOG.Common.Utilities;
using VOG.Customer.Management;
table 50100 VOGOfficeRoom
{
fields
{
field(1;RoomName; Text[100])
{
DataClassification = CustomerContent;
}
field(2;Capacity; Integer)
{
DataClassification = CustomerContent;
MinValue = 1;
BlankZero = true;
}
}
var
VOGTempCustomer: Record Customer;
}

En el caso anterior, se declaran una dependencia externas que se utilizarara para el objeto. (Record Customer);

  • Todas los campos en extensiones de tabla deben utilizar el prefijo VOG.
  • Un campo debe comenzar con una letra mayúscula.
  • Se deben omitir los espacios en blanco, los puntos, caracteres numericos y otros caracteres (como paréntesis) que harían necesarias las comillas alrededor de una variable.
  • Si un campo es un compuesto de dos o más palabras o abreviaturas, cada palabra o abreviatura debe comenzar con una letra mayúscula
  • No utilizar nombre dentros de comillas.
// Uso de nomenclatura correcta en campos de tabla
table 50100 VOGOfficeRoom
{
DataClassification = CustomerContent;
fields
{
field(1;RoomName; Text[100])
{
DataClassification = CustomerContent;
}
field(2;Capacity; Integer)
{
DataClassification = CustomerContent;
MinValue = 1;
BlankZero = true;
}
}
}
table 50101 VOGMeetingRoomExtension extends "Meeting Room"
{
fields
{
field(50000;VOGAvailableEquipment; Text[250])
{
DataClassification = CustomerContent;
}
}
}
// Uso de nomenclatura incorrecta en campos de tabla
table 50100 VOGOfficeRoom
{
DataClassification = CustomerContent;
fields
{
// Se utilizo caracteres especiales
field(1;room_name; Text[100])
{
DataClassification = CustomerContent;
}
// No se comenzo con letra mayuscula
field(2;capacity; Integer)
{
DataClassification = CustomerContent;
MinValue = 1;
BlankZero = true;
}
}
}
table 50101 VOGMeetingRoomExtension extends "Meeting Room"
{
fields
{
// No se uso el prefijo VOG
field(50000;AvailableEquipment; Text[250])
{
DataClassification = CustomerContent;
}
}
}
  • Todas las variables globales o protegidas deben utilizar el prefijo VOG. Se exceptuan las variables locales dentro de los métodos u procedimientos.
  • Las variables que hacen referencia a un objeto AL deben contener el nombre del objeto, abreviado cuando sea necesario.
  • Una variable debe comenzar con una letra mayúscula.
  • Se deben omitir los espacios en blanco, los puntos y otros caracteres (como paréntesis) que harían necesarias las comillas alrededor de una variable.
  • Si una variable es un compuesto de dos o más palabras o abreviaturas, cada palabra o abreviatura debe comenzar con una letra mayúscula
  • No utilizar nombre dentros de comillas.
  • El nombre de las variables global y locales no deben ser identicos.
  • Evite repetir el nombre en campos,metodos o acciones.
VOGWIPBuffer: Record "Job WIP Buffer"
VOGPostline: Codeunit "Gen. Jnl.-Post Line";
VOGGenJnlPostLine: Codeunit "Gen. Jnl.-Post Line";
VOGAmountLCY: Decimal;

Para las variables que hacen referencia a objetos temporales, utilice el sufijo Temporary después del nombre de la variable. En el nombre de la variable debe incluirse el prefijo Temp.

VOGCustomer: Record Customer temporary; // Variable de objeto temporal. Debe incluir la palabra `Temp` en el nombre.
TempVOGCustomer: Record Customer ; // Variable de objeto persistente. No debe incluir la palabra `Temp` en el nombre.
TempCustomer: Decimal // No debe incluir la palabra `Temp` en el nombre, no es una variable de un objeto.
VOGTempCustomer: Record Customer temporary; // Variable de objeto temporal
VOGCustomer: Record Customer ; // Variable de objeto persistente

Los comentarios deben utilizarse para explicar el propósito y la lógica del código. Se establecera comentarios XML con las siguiente directrices

  • Se utilizaran las etiquetas <summary>, <param>,<returns> y <remarks> para detallar la funciones o procedimientos.
  • Evitar el uso de comentarios en funcionalidades redundantes o evidentes.
  1. summary (Resumen) Es la etiqueta más importante. Define de forma breve y concisa qué es el objeto o qué hace el procedimiento. Es lo primero que aparece en el IntelliSense de VS Code.

  2. param (Parámetro) Se utiliza para documentar cada una de las variables de entrada de un procedimiento. Requiere el atributo name, que debe coincidir exactamente con el nombre de la variable en la firma de la función.

  3. returns (Retorno) Se utiliza exclusivamente en funciones que devuelven un valor.

  4. remarks (Observaciones / Comentarios adicionales) Es una etiqueta para información extendida que no cabe en el resumen.

// Este procedimiento calcula el descuento basado en la cantidad - Uso de comentario simple documentar
local procedure VOGCalculateDiscountForQuantity(Amount: Decimal): Decimal
begin
// Lógica de cálculo de descuento
end
/// <summary>
/// Calcula el descuento por cantidad
/// </summary>
/// <param name="Amount">Monto a calcular</param>
/// <returns>El descuento total calculado como Decimal.</returns>
/// <remarks>
/// Este método configuración de ventas
/// </remarks>
local procedure VOGCalculateDiscountForQuantity(Amount: Decimal): Decimal
begin
end
  • Los mensajes de error, advertencia e informacion se deben establecer con las siguientes propiedades:

  • Todos los mensajes deben comenzar con el prefijo VOG.

  • Debe utilizar la nomenclatura Error, Warning o Information seguidas del texto descriptivo.

  • El texto se debe crear en una variable de tipo Label para facilitar la traducción y mantenimiento.

  • Utiliza el sufijo Lbl en la variales que contienen mensajes.

  • En el caso de incluir se debe documentar los marcadores de posición.

var
VOGErrorCustomerNotFoundMessageLbl: Label 'El cliente %1 no fue encontrado en el sistema.'; // No establecio comentario su placeholder
VOGErrorCustomerNotFoundMessage: Label 'El cliente %1 no fue encontrado en el sistema.',Comment = '%1 - Número de cliente que no se encontró'; ; // No utili el sufijo Lbl
VOGErrorLbl: Label 'El cliente %1 no fue encontrado en el sistema.',Comment = '%1 - Número de cliente que no se encontró'; ; // No tiene nombre descriptivo del mensaje
var
VOGErrorCustomerNotFoundMessageLbl: Label 'El cliente %1 no fue encontrado en el sistema.',Comment = '%1 - Número de cliente que no se encontró'; ;

Para declarar un método:

  • El nombre del procedimiento deben utilizar el prefijo VOG.
  • Incluir un espacio después de un punto y coma al declarar varios argumentos.
  • El punto y coma se debe utilizar al final del end.
  • Los métodos deben nombrarse usando PascalCase, como las variables.
  • Debe haber una línea en blanco entre las declaraciones de método.
  • Evite asignar el mismo nombre que campos u acciones en el mismo ambito.
local procedure VOGMyProcedure(Customer: Record Customer; Int: Integer)
begin
end;
// Blank line between methods
local procedure VOGMyProcedure2(Customer: Record Customer; Int: Integer)
begin
end;

Al llamar a un método, incluya un espacio después de cada comando si va a pasar varios parámetros.

Los paréntesis deben especificarse cuando se realiza una llamada al método o al sistema, como:

VOGMyProcedure();
VOGMyProcedure(1);
VOGMyProcedure(1, 2);

Declaración de publicador de eventos (EventPublisher)

Sección titulada «Declaración de publicador de eventos (EventPublisher)»

para declarar un publicador de eventos:

  • Todo evento debe estar precedido por el atributo [IntegrationEvent(false, false)]
  • Los eventos deben declararse como local procedure a menos que se requiera acceso desde fuera del objeto.
  • El cuerpo del procedimiento debe estar siempre vacío (begin end;). No se permite lógica dentro de un publisher.
  • La nomenclatura debe comenzar con el prefijo VOG seguido de una descripción del evento que se está publicando. La descripcion debe indicar el momento en que se produce el evento. Ejemplo: VOGOnBeforeValidateCustomerNo, VOGOnAfterInsertSalesHeader, etc.
codeunit 50105 VOGBookingEvents
{
// Este publicador se ejecuta cuando se valida el número de cliente en una cabecera de ventas
[EventPublisher(ObjectType::Table, Database::"Sales Header", 'OnAfterValidateEvent', 'Sell-to Customer No.', false, false)]
procedure VOGOnAfterValidateSellToCustomerNo(var Rec: Record "Sales Header"; var xRec: Record "Sales Header")
begin
end;
}

Declaración de suscriptor de eventos (EventSubscriber)

Sección titulada «Declaración de suscriptor de eventos (EventSubscriber)»

Para declara un suscriptor de eventos:

  • Nombrar con el prefijo VOG seguido de una descripción del evento al que se está suscribiendo.
  • Los procedimientos de eventos deben ser locales (local procedure). No hay razón para que otra Codeunit llame manualmente a un suscriptor de eventos.
codeunit 50105 VOGBookingEvents
{
// Este suscriptor se ejecuta cuando se valida el número de cliente en una cabecera de ventas
[EventSubscriber(ObjectType::Table, Database::"Sales Header", 'OnAfterValidateEvent', 'Sell-to Customer No.', false, false)]
local procedure VOGOnAfterValidateSellToCustomerNo(var Rec: Record "Sales Header"; var xRec: Record "Sales Header")
var
OfficeMgt: Codeunit VOGOfficeManagement;
begin
// La lógica debe estar encapsulada o llamar a otros métodos para mantener el suscriptor limpio
if Rec."Sell-to Customer No." <> '' then
OfficeMgt.CheckCustomerOfficeAvailability(Rec."Sell-to Customer No.");
end;
}

Los permisos en AL permiten a los usuarios dar permiso a otros usuarios en función de sus necesidades particulares.

Valores de PermisosRepresentacionDescripción
R o rLeerR para letura directa, r para acceso de lectura indirecta.
I o iInsertarI para permiso de insercion directa, i para permiso de insercion indirecta.
M o mModificarM para permiso de modificacion directa, m para permiso de modificacion indirecta.
D o dEliminarD para permiso de eliminacion directa, d para permiso de eliminacion indirecta.
X or xExecute (Run)X para ejecutar permisos directamente, x para permisos de ejecucion indirecta.

Ejemplo de uso correcto

PermissionSet 50145 VOGPermissionSet
{
Permissions = TableData "VOGOfficeRoom" = RIMD,
TableData "VOGMeetingRoomExtension" = RIMD;
}

Las páginas de API son diferentes de las páginas de interfaz de usuario. Requieren diferentes propiedades y no se comportan de la misma manera. Dado que las páginas de API se utilizan para la integración con aplicaciones externas, deben tratarse como contratos. Para lograr esto, los siguientes temas son importantes.

  • Aplicación de API independiente
  • Propiedades de la página
  • Control de versiones
  • Propiedades de campo
  • Campos predeterminadoS

Es una buena práctica desarrollar páginas de API en una aplicación independiente en lugar de combinarlas en una solución. Al hacerlo, proporciona una mejor capacidad de mantenimiento y es una buena manera de separar las preocupaciones

Las propiedades que se deben definir son:

El nombre del editor de la API suele ser la empresa que crea la API. Es la primera parte personalizada de la dirección URL de un punto de conexión determinado. El valor no distingue entre mayúsculas y minúsculas.

APIPublisher = 'contoso';

Establece el grupo del punto de conexión de la API en el que se expone la página o la consulta. En la dirección URL, APIGroup viene después de APIPublisher. Se puede usar para distinguir diferentes aplicaciones de API o grupos de API entre sí. El valor no distingue entre mayúsculas y minúsculas.

APIGroup = 'app1';

Establece las versiones del punto de conexión de la API en el que se expone la página o la consulta. Esta propiedad no es obligatoria. Si no se especifica, las API se expondrán como versión ‘beta’.

La APIVersion se puede establecer en ‘beta’ o tener el formato ‘vx.y’. Ejemplo:

APIVersion = 'beta';

Nunca debe romper las versiones existentes. Cualquier cambio importante requiere la creación de una nueva versión.

Es posible exponer una API en varias versiones:

APIVersion = 'beta', 'v1.0';

Esto permite publicar una nueva versión de una aplicación API sin copiar todos los objetos individuales y actualizar los números de versión. Solo es necesario copiar los objetos de API que se modifican en una nueva versión. Los demás objetos solo necesitan una adición a la propiedad APIVersion para estar disponibles en el punto de conexión de la nueva versión

EntitySetName es el nombre de la entidad en plural. Piense en ello como el nombre de la colección de entidades. Se recomienda utilizar camelCasing para esta propiedad. ¡El valor distingue entre mayúsculas y minúsculas!

EntitySetName = 'itemCategories';

EntityName establece el nombre de entidad singular para la página o consulta de la API. Este nombre no se utiliza en la URL. En su lugar, se usa EntityName en la información de metadatos. Se recomienda utilizar camelCasing para esta propiedad.

EntityName = 'itemCategory';

Esta propiedad es necesaria en una página de API editable. No se aplica a un objeto de consulta de API. Si se establece en la página de la API, DelayedInsert no es necesario. Todas las páginas de API aplican el comportamiento para especificar primero todos los valores de campo y, a continuación, insertar el registro a la vez.Editable = false

DelayedInsert = true;
PageType = API;
APIPublisher = 'contoso';
APIGroup = 'app1';
APIVersion = 'v1.0';
EntitySetName = 'itemCategories';
EntityName = 'itemCategory';
DelayedInsert = true;

La estructura base de una página de API es similar a una página de lista de interfaz de usuario

layout
{
area(Content)
{
repeater(records)
{
...
}
}
}

Al especificar los campos, hay que tener en cuenta algunas consideraciones.

  • No hay propiedades obligatorias. La propiedad no desempeña ningún papel en las páginas de la API, por lo que se puede omitir.
  • La propiedad también es opcional y solo se debe usar en caso de que la aplicación externa requiera subtítulos y el título debe ser diferente del título estándar.
  • El nombre del campo, debe definirse en camelCasing. No puede contener espacios, puntos u otros caracteres especiales
  • SystemId
    • Este campo debe exponerse con el nombre id
  • SystemModifiedAt
    • Este campo debe exponerse con el nombre . Si eliges un nombre diferente, la funcionalidad del webhook no funcionará correctamente.
layout
{
area(Content)
{
repeater(records)
{
field(id; Rec.SystemId) { }
field(lastModifiedDateTime; Rec.SystemModifiedAt) { }
}
}
}

Accesibilidad y analisis de buenas practicas.

Sección titulada «Accesibilidad y analisis de buenas practicas.»

Los siguientes puntos deben ser consideradas para el desarrollo de extensiones en AL.

Clasificacion de campos con DataClassification

Sección titulada «Clasificacion de campos con DataClassification»

Debido a los requisitos de las leyes y normativas de privacidad, los campos de la clase de campo deben usar la propiedad DataClassification  y su valor debe ser diferente de ToBeClassified. Esto se aplica a los campos de las tablas y las extensiones de tabla.

En la tabla siguiente se describen los niveles de confidencialidad de datos que se pueden asignar.

SensibilidadDescripción
AccountDataInformación de facturación del cliente e información del instrumento de pago, incluida la información de contacto del administrador, como el nombre, la dirección o el número de teléfono del administrador del inquilino.
CustomerContentContenido proporcionado o creado directamente por administradores y usuarios. El valor predeterminado es
SystemMetadataDatos generados mientras se ejecuta el servicio o programa que no se pueden vincular a un usuario o inquilino.
EndUserIdentifiableInformation(EUII) Datos que identifican o podrían utilizarse para identificar al usuario de un servicio de Microsoft. EUII no incluye contenido del Cliente.
EndUserPseudonymousIdentifiers(EUPI) Un identificador creado por Microsoft y vinculado al usuario de un servicio de Microsoft. Al combinarse con otra información, como una tabla de mapeo, EUPI identifica al usuario final. EUPI no contiene información cargada ni creada por el cliente (contenido del cliente o EUII).
table 50100 VOGOfficeRoom
{
fields
{
field(1;RoomName; Text[100])
{
DataClassification = ToBeClassified;
}
}
}
tableextension 50100 VOGOfficeRoom extends "VOG Office Room"
{
fields
{
field(;RoomName; Text[100])
{
DataClassification = ToBeClassified;
}
}
}
table 50100 VOGOfficeRoom
{
fields
{
field(1;RoomName; Text[100])
{
DataClassification = AccountData;
}
}
}
tableextension 50100 VOGOfficeRoom extends "VOG Office Room"
{
fields
{
field(50100;RoomName; Text[100])
{
DataClassification = AccountData;
}
}
}

Ningún campo o acción es visible en Business Central sin la propiedad ApplicationArea. Se debe configurar como All por defecto, a menos que el requerimiento especifique lo contrario.

Se aplica a:

  • Page Label
  • Page Field
  • Page Part
  • Page System Part
  • Page Chart Part
  • Page Action
  • Page Custom Action
  • Page File Upload Action
  • Page User Control
  • Page
  • Report
ValoresDescripcion
AllSe va a aplicar a todas las versiones
BasicSolo se Aplica a la version basica de business central
SuiteSolo se Aplica a la version premium de business central
field(VOGModel; Rec."Model ID")
{
Caption = 'Modelo ID';
Editable = false;
ApplicationArea = All;
}

Para que una página o un reporte sea accesible a través del buscador de Dynamics 365 Business Central (conocido como Tell Me), es obligatorio declarar la propiedad UsageCategory.

Existen los siguientes valores para la propiedad UsageCategory:

ValoresDescripcion
NoneLa página, el informe o la consulta no se incluyen en una búsqueda.
ListsLa pagina, el informa o la consulta se enumeran como Listas, en la categoria Paginas y Tareas
TasksLa pagina, el informa o la consulta aparecen como Tareas, en la categoria Paginas y Tareas
ReportsAndAnalysisLa pagina, el informa o la consulta aparecen como Informes, en la categoria Informes y analisis
DocumentsLa pagina, el informa o la consulta aparecen como Informes y analisis, en la categoria Informes y analisis
HistoryLa pagina, el informe o la consulta aparecen como Archivo en la categoria Informes y analisis
AdministrationLa consulta aparece como Administracion en la categoria Pagina y tareas

Ejemplo de uso correcto

page 50100 VOGOfficeRoomList
{
PageType = List;
UsageCategory = Lists;
ApplicationArea = All;
}

Se debe establecer el uso de la propiedad ToolTip en todos los campos, acciones y partes de una página. Esta propiedad proporciona una descripción clara de la función o el propósito del elemento.

  • Campos de página
  • Acciones de página
  • Parametro de Reportes;
field(VOGModel; Rec."Model ID")
{
Caption = 'Modelo ID';
Editable = false;
ApplicationArea = All;
ToolTip = 'Identificador unico del modelo del producto.';
}

Al referenciar variables globales dentro de métodos o procedimientos, utilice siempre la palabra clave this para mejorar la claridad del código y evitar ambigüedades.

var
TotalAmount: Decimal;
LineAmount: Decimal;
local procedure VOGCalculateTotal()
begin
TotalAmount := TotalAmount + LineAmount;
end;
var
TotalAmount: Decimal;
LineAmount: Decimal;
local procedure VOGCalculateTotal()
begin
this.TotalAmount := this.TotalAmount + this.LineAmount;
end;

Para todo texto estático que deba aparecer en el diseño del reporte (títulos de columnas, nombres de campos, leyendas), es obligatorio el uso de las variables Labels en el objeto Report en lugar de variables enviadas al dataset.

  • a diferencia de la variable tipo Text se envían una sola vez en la estructura del reporte, reduciendo drásticamente el peso del dataset.
  • Están diseñados para ser capturados por los archivos de traducción (.xlf).
column(InvoiceNoCaption; 'Nº Factura') // Esto se repite en cada fila
{
}
report 50100 "StandardInvoice"
{
dataset
{
dataitem(Header; "Sales Invoice Header")
{
column(No_; "No.") { }
}
}
labels
{
InvoiceTitleCaption = 'Factura', Comment = 'Título del reporte';
CustomerNoCaption = 'Nº Cliente';
PageCaption = 'Página';
}
}

Todos los campos que se muestran en un objeto de página deben tener definida la propiedad Caption

Ejemplo de uso incorrecto

page 50100 MyCustomerPage
{
layout
{
area(content)
{
field(CustomerName; Customer.Name)
{
ApplicationArea = All;
// Falta Caption → advertencia AA0225
}
}
}
}

Ejemplo de uso correcto

page 50100 MyCustomerPage
{
layout
{
area(content)
{
field(CustomerName; Customer.Name)
{
Caption = 'Customer Name';
ApplicationArea = All;
}
}
}
}

Se debe aplicar la propiedad AutoFormatType, la cual determina el formato de visualización de los datos decimales en la interfaz de usuario. Trabaja en conjunto con AutoFormatExpression para aplicar reglas de redondeo y símbolos de moneda.

El tipo de dato AutoFormatType solo se aplica a campos de tipo Decimal.

field(VOGUnitPrice; Rec."Unit Price")
{
Caption = 'Precio Unitario';
AutoFormatType = 1;
AutoFormatExpression = '0.00';
ApplicationArea = All;
}

Solo use begin.. end para encerrar instrucciones compuestas

// Ejemplo de uso incorrecto. El uso de begin.. end no es necesario para una sola instrucción
if FindSet() then begin
repeat
...
until next() = 0;
end;
// Ejemplo de uso correcto.
if FindSet() then
repeat
...
until next() = 0;

Excepción del caso

if X then begin
if Y then
//Hacer Algo
end else
(not X)

No utilice líneas en blanco:

  • al principio o al final de cualquier función (después y antes de begin end)
  • dentro de la expresión multilínea
  • después de las líneas en blanco

Comience siempre los comentarios con // seguido de un carácter de espacio

RowNo += 1000; //Move way below the budget
RowNo += 1000; // Move way below the budget

Una acción CASE debe comenzar en una línea después de la posibilidad.

case Letter of
'A': Letter2 := '10';
'B': Letter2 := '11';
end;
case Letterof
'A':
Letter2:= '10';
'B':
Letter2:= '11';end;

Las declaraciones de variables deben ordenarse por tipo. En general, los tipos de objetos y variables complejas se enumeran primero, seguidos de las variables simples. El orden debe ser:

  • Record
  • Report
  • Codeunit
  • XmlPort
  • Page
  • Query
  • Notification
  • BigText
  • DateFormula
  • RecordId
  • RecordRef
  • FieldRef
  • FilterPageBuilder
StartingDateFilter: Text;
Vendor: Record Vendor;
//Incorrecto
Vendor: Record Vendor;
StartingDateFilter: Text;
//Correcto

No utilice palabras clave o innecesariamente si la expresión ya es una expresión lógica.true / false

if IsPositive() = true then //Incorrecto
if IsPositive() then //Correcto
if Complete <> true then //Incorrecto
if not Complete then //Correcto

Al llamar a un objeto de forma estática, utilice el nombre del objeto, no el identificador del objeto

Page.RunModal(525, SalesShptLine); //Incorrecto
Page.RunModal(Page::"Posted Sales Shipment Lines", SalesShptLine); //Correcto

Cuando se pasa una variable por referencia a un procedimiento, utilice siempre la palabra clave var en la declaración del procedimiento.

local procedure VOGUpdateCustomer(Customer: Record Customer)
begin
Customer.Name := 'New Name';
Customer.Modify();
end;
local procedure VOGUpdateCustomer(var Customer: Record Customer)
begin
Customer.Name := 'New Name';
Customer.Modify();
end;

No utilice la concatenación de cadenas para crear constantes. En su lugar, utilice placeholders para insertar valores dinamicos.

// Uso de concatenación para crear un mensaje de error. No esta permitido.
procedure VOGShowCustomerMessage(CustomerName: Text; Balance: Decimal)
begin
Error('El cliente ' + CustomerName + ' tiene un saldo de ' + Format(Balance));
end;
// Uso de una variable Label con placeholders para crear un mensaje de error.
var
CustomerMessageLbl: Label 'El cliente %1 tiene un saldo de %2';
procedure VOGShowCustomerMessage(CustomerName: Text; Balance: Decimal)
begin
Error(CustomerMessageLbl, CustomerName, Balance);
end;

Cuando se utilice un objeto/campo obsoleto, se debe agregar la Propiedades:

  • obsoleteState: con un valor (Pending|Removed)
  • obsoleteReason: con un comentario que explique por qué se está utilizando y cuándo se eliminará el uso del objeto obsoleto.
tableextension 50100 VOGCustomerExt extends Customer
{
fields
{
field(50100; VOGOldField; Text[50])
{
Caption = 'Old Field';
ObsoleteState = Pending;
ObsoleteReason = 'Este campo se reemplaza por NewField';
ObsoleteTag = 'v20.0'; // Campo Opcional
}
}
}
field(50100; OldField; Text[50])
{
Caption = 'Old Field';
ObsoleteState = Pending;
// Falta ObsoleteReason → Error AA0213
}

El cumplimiento de los siguientes puntos es obligatorio para garantizar la escalabilidad y eficiencia de las extensiones. Estas prácticas minimizan la carga en el Service Tier (NST),optimizar las consultas hacia el SQL Server y el consumo de recursos.

Cuando realizas un DeleteAll en una tabla vacía, se produce un bloqueo de tabla. Por lo tanto, es buena práctica comprobar siempre si la tabla está vacía al realizar un DeleteAll.

EmptyTableWLD.SetRange(Code, 'AJ');
EmptyTableWLD.DeleteAll(true);
EmptyTableWLD.SetRange(Code, 'AJ');
if not EmptyTableWLD.isEmpty() then
EmptyTableWLD.DeleteAll(true);

En el caso de cumplir

Para el rendimiento del código, es importante que utilice SetLoadFields tanto como sea posible.

Si desea recuperar un registro de la base de datos para comprobar si el registro está disponible, utilice siempre SetLoadFields en los campos de clave principal de la tabla, de modo que solo se recuperen esos campos de la base de datos.

if not Item.Get(ItemNo) then //Incorrecto
exit();
Item.SetLoadFields("No.");
if not Item.Get(ItemNo) then //Correcto
exit();

El uso de los triggers OnFindRecord y OnNextRecord a nivel de página es una de las prácticas más peligrosas para el rendimiento en Business Central si no se maneja con extremo cuidado.

Estos triggers deben considerarse como el último recurso. Su implementación incorrecta anula todas las optimizaciones nativas de SQL (como el paginado y el almacenamiento en caché) y puede congelar la interfaz de usuario.

avegación del usuario en una lista.

Problema: Business Central carga registros en “bloques” (por ejemplo, de 50 en 50). Al escribir código en OnFindRecord o OnNextRecord, obligas al servidor a ejecutar lógica personalizada cada vez que el usuario hace scroll.

Impacto: Si la lógica interna incluye búsquedas en otras tablas o cálculos, el desplazamiento por la lista tendrá un retraso (lag) perceptible, aumentando el consumo de CPU en el NST.

Solo se permite su uso en los siguientes casos:

Páginas basadas en Tablas Temporales: Cuando los datos no residen en SQL sino que se generan al vuelo (ej. una integración con un API externo que se muestra en una lista).

Buffers de integración: Cuando se necesita filtrar datos de una manera que el motor de SQL no puede procesar nativamente.

Uso de ReadIsolation en consultas de lecturas

Sección titulada «Uso de ReadIsolation en consultas de lecturas»

Al realizar operaciones de lectura en registros, es fundamental utilizar la propiedad ReadIsolation para garantizar la consistencia y la integridad de los datos. Esta práctica ayuda a evitar problemas relacionados con lecturas sucias o inconsistentes, especialmente en entornos concurrentes donde múltiples procesos pueden acceder y modificar los mismos datos simultáneamente.

Se debe utilizar ReadIsolation siempre que se realice una lectura de datos que no tenga intención de modificar registros inmediatamente.

Nivel de AislamientoDescripciónCasos de Uso Recomendados
DefaultEl sistema utiliza el aislamiento por defecto de Business Central (generalmente ReadCommitted).Consultas estándar sin requisitos especiales.
ReadCommittedGarantiza que la transacción solo lea datos que han sido confirmados (commit). Evita “lecturas sucias”.Operaciones financieras o contables donde la precisión es obligatoria.
ReadUncommittedEl nivel más permisivo. No emite bloqueos compartidos y no respeta bloqueos exclusivos. Puede leer datos modificados pero no confirmados.Dashboards, Estadísticas y Reports. Ideal para contar registros disponibles sin bloquear el procesos.
RepeatableReadAsegura que si se lee un registro dos veces en la misma transacción, los datos serán iguales. Bloquea los registros leídos.Procesos de validación complejos donde los datos no deben cambiar durante el cálculo.
UpdLockLee el registro y coloca un bloqueo de actualización inmediato. Ninguna otra transacción puede obtener un bloqueo de actualización o exclusivo. Prevención de condiciones de carrera.Úsalo antes de un Modify para asegurar que nadie toque el registro mientras lo procesas.
// Estándar para consultas de solo lectura (Reporting/Dashboards)
procedure VOGGetTotalAvailableRooms(): Integer
var
OfficeRoom: Record "VOF Office Room";
begin
// Evita bloqueos en la tabla mientras otros usuarios modifican
OfficeRoom.ReadIsolation := IsolationLevel::ReadUncommitted;
OfficeRoom.SetRange(Status, OfficeRoom.Status::Available);
exit(OfficeRoom.Count());
end;
// Estándar para preparación de actualización
procedure VOGMarkRoomAsOccupied(RoomNo: Code[20])
var
OfficeRoom: Record "VOF Office Room";
begin
// Bloquea el registro específicamente para esta transacción
OfficeRoom.ReadIsolation := IsolationLevel::UpdLock;
if OfficeRoom.Get(RoomNo) then begin
OfficeRoom.Status := OfficeRoom.Status::Occupied;
OfficeRoom.Modify(true);
end;
end;

Uso lockTable en la modificación de registros

Sección titulada «Uso lockTable en la modificación de registros»

El método LockTable se utiliza para asegurar que los datos no cambien entre el momento en que se leen y el momento en que se modifican. Su uso debe ser cuidadoso y seguir el principio de “bloquear lo más tarde posible”.

Debe seguir los siguientes lineamientos al utilizar LockTable:

  • Siempre debe llamarse antes de la función de lectura (Get, Find, FindFirst). Si se llama después de leer, el bloqueo no garantiza que los datos leídos sigan siendo los mismos.
  • El bloqueo dura hasta que la transacción termina. Por ello, se prohíbe poner un LockTable al inicio de un proceso largo si no es estrictamente necesario.
  • No usar LockTable en tablas maestras (como Customer o Item) a menos que sea estrictamente necesario, ya que detiene el trabajo de todos los usuarios de la empresa.
  • Nunca mostrar un Confirm o un Error largo después de un LockTable. La tabla quedará bloqueada para el resto de la empresa.
  • Antes de usar LockTable, considera si puedes usar ReadIsolation := IsolationLevel::UpdLock, que es más moderno y preciso.
// Estándar para actualización de estado de oficina
procedure VOGFinalizeVofReservation(RoomNo: Code[20])
var
OfficeRoom: Record VOGOfficeRoom;
begin
// 1. Declarar el bloqueo antes de la lectura
OfficeRoom.LockTable();
// 2. Obtener el registro (ahora bloqueado para otros)
if OfficeRoom.Get(RoomNo) then begin
// 3. Lógica de modificación
OfficeRoom.Status := OfficeRoom.Status::Occupied;
OfficeRoom.Modify(true);
end;
// El bloqueo se libera automáticamente al terminar el método (End)
end;

El método CalcFields puede ser costoso en términos de rendimiento, especialmente si se utiliza en grandes conjuntos de datos o dentro de bucles. Por ello, su uso debe ser limitado a situaciones donde sea absolutamente necesario calcular campos específicos.

El estándar moderno exige sustituirlo por técnicas de carga anticipada.

Se deben seguir las siguientes directrices:

  • Utilice SetAutoCalFields para especificar qué campos calculados se deben cargar antes de realizar una operación de lectura masiva (FindSet). Esto reduce la cantidad de llamadas a la base de datos.
  • Si un campo calculado se consulta con demasiada frecuencia y afecta al rendimiento, se debe evaluar convertirlo en un campo físico que se actualice mediante lógica de eventos (reduciendo la carga de cálculo en tiempo real).
  • Está estrictamente prohibido usar CalcFields dentro de un bucle repeat until. En su lugar, carga los campos necesarios fuera del bucle.
// Realiza múltiples llamadas a SQL por cada registro
if OfficeRoom.FindSet() then
repeat
OfficeRoom.CalcFields(VOGOccupancy Rate); // Llamada extra por cada iteración
TotalRate += OfficeRoom.VOGOccupancyRate;
until OfficeRoom.Next() = 0;

Ejemplo 2: Uso recomendado con SetLoadFields

Sección titulada «Ejemplo 2: Uso recomendado con SetLoadFields»
// Optimiza la lectura en una sola consulta SQL
OfficeRoom.SetAutoCalFields(VOGOccupancyRate,VOGRoomName);
if OfficeRoom.FindSet() then
repeat
// El campo ya está cargado en memoria desde el FindSet
TotalRate += OfficeRoom.VOGOccupancyRate;
until OfficeRoom.Next() = 0;
// Optimiza la lectura en una sola consulta SQL
page 50100 "VOGCustomerBalanceList"
{
PageType = List;
ApplicationArea = All;
UsageCategory = Lists;
SourceTable = Customer;
Caption = 'Lista de Balance de Cliente';
layout
{
area(Content)
{
repeater(GroupName)
{
field("No."; Rec."No.") { ApplicationArea = All; }
field(Balance; Rec.Balance) { ApplicationArea = All; }
}
}
}
trigger OnOpenPage()
begin
// Preparamos al registro anl momento de ingresar en la pagina
Rec.SetAutoCalcFields(Balance);
end;
}

Los métodos FindSet() o Find() deben usarse solo en relación con el método Next().

No se debe usar el valor Get() FindFirst() o FindLast() con el metodo Next() .Si no, estás desperdiciando CPU y ancho de banda porque se cargan varios registros pero solo usas uno.

Ejemplo incorrecto

codeunit 1 MyCodeunit
{
var
VOGcustomer: Record Customer;
procedure VOGFoo()
begin
if customer.FindFirst() then
repeat
...
until customer.Next() = 0;
end;
}

Ejemplo correcto

codeunit 1 MyCodeunit
{
var
VOGcustomer: Record Customer;
procedure VOGFoo()
begin
if customer.FindSet() then
repeat
...
until customer.Next() = 0;
end;
}

A diferencia de otros métodos de búsqueda, Get accede directamente a la clave primaria, lo que permite al motor de SQL optimizar la búsqueda al máximo.

Es obligatorio utilizar el método Get siempre que se conozca la clave primaria del registro. Se debe evitar el uso de filtros (SetRange/SetFilter) seguidos de FindFirst para recuperar un único registro conocido.

// Busquedad por llave primaria
GLEntry(1234) //Excelente
// Busqueda por indice
GLEntry.SetRange("Document No",'INV11020') //Buena
GLEntry.FindFirst();
// Escaneo de indice
GLEntry.SetRange("Global Dimensión 2 Code",'INV11020') //Deficiente
GLEntry.FindFirst();
// Escaneo de la tabla
GLEntry.SetRange("FA Entry No.",1234) //Deficiente
GLEntry.FindFirst();
  • No cambiar filtros/loadfields/autocalfields una vez haya comenzado la interación
  • evitar SetCurrentKey en los campos siendo modficados
  • Mejorar la actualización de los registro iterando
procedure Slow()
var
rec,rec2: Record Mytable
begin
if rec.findSet() then
repeat
rec2 := rec;
rec.MyField := rec2.MyField + 1
rec.Modify();
until rec.next() = 0
end
procedure Fast()
var
rec,rec2: Record Mytable
begin
if rec.findSet() then
repeat
rec.MyField := rec.MyField + 1
rec.Modify();
until rec.next() = 0
end

Puedes aumentar potencialmente el rendimiento si los campos usados en FlowFields se añaden a SumIndexedFields de la clave correspondiente.

Cuando hay problemas de rendimiento en las páginas de lista, la causa raíz suele ser que muestran FlowFields definidos sobre tablas que no están suficientemente indexadas.

table 18 Customer
{...
fields
{...
field(97; "Debit Amount"; Decimal)
{
...
CalcFormula = Sum ("Detailed Cust. Ledg. Entry"."Debit Amount" WHERE("Customer No." = FIELD("No."),
"Entry Type" = FILTER(<> Application),
"Initial Entry Global Dim. 1" = FIELD("Global Dimension 1 Filter"),
"Initial Entry Global Dim. 2" = FIELD("Global Dimension 2 Filter"),
"Posting Date" = FIELD("Date Filter"),
"Currency Code" = FIELD("Currency Filter")));
FieldClass = FlowField;
...
}
...
}
keys { ... }
...
}
table 379 "Detailed Cust. Ledg. Entry"
{...
fields { ... }
keys
{
key(Key1; "Entry No.")
{
Clustered = true; // Sin indexar campo "Debit Amount"
}
}
...
}
table 18 Customer
{...
fields
{...
field(97; "Debit Amount"; Decimal)
{
...
CalcFormula = Sum ("Detailed Cust. Ledg. Entry"."Debit Amount" WHERE("Customer No." = FIELD("No."),
"Entry Type" = FILTER(<> Application),
"Initial Entry Global Dim. 1" = FIELD("Global Dimension 1 Filter"),
"Initial Entry Global Dim. 2" = FIELD("Global Dimension 2 Filter"),
"Posting Date" = FIELD("Date Filter"),
"Currency Code" = FIELD("Currency Filter")));
FieldClass = FlowField;
...
}
...
}
keys { ... }
...
}
table 379 "Detailed Cust. Ledg. Entry"
{...
fields { ... }
keys
{
key(Key1; "Entry No.")
{
Clustered = true;
}
key(Key2; "Customer No.", "Entry Type", "Initial Entry Global Dim. 1", "Initial Entry Global Dim. 2", "Posting Date", "Currency Code")
{
SumIndexFields = "Debit Amount" // Declaracion del campo indexado
}
}
...
}

Evitar bloqueadores de operaciones masivas:

La presencia de suscriptores a eventos, disparadores de base de datos o filtros de seguridad puede convertir operaciones rápidas de ModifyAll o DeleteAll en procesos lentos fila por fila

// Uso de Triggers (Disparadores)
// Si la tabla "Item" tiene mucho código en su trigger OnModify...
Item.SetRange("Item Category Code", 'HARDWARE');
// El 'true' hará que por cada producto se ejecute TODA la lógica de la tabla Item.
Item.ModifyAll("Safety Lead Time", '2D', true);
// Uso de Suscriber Event
[EventSubscriber(ObjectType::Table, Database::"Sales Line", 'OnAfterModifyEvent', '', false, false)]
local procedure OnAfterModifySalesLine(var Rec: Record "Sales Line")
begin
// Este código se ejecutará 10,000 veces si modificas 10,000 líneas.
// SQL no puede hacer un UPDATE masivo porque tiene que enviarle cada 'Rec' a AL.
VOGLogMgt.LogChange(Rec."Document No.", Rec."Line No.");
end;
SalesLine.SetRange("Document Type", SalesLine."Document Type"::Order);
SalesLine.ModifyAll("Shipment Date", Today, false); // <--- DEGRADADO A FILA POR FILA

Si estás diseñando una tabla en tu extensión y sabes que los usuarios buscarán descripciones largas, debes habilitar la propiedad OptimizeForTextSearch

field(20; Description; Text[100])
{
// Optimiza el campo para búsquedas de texto parcial en SQL
OptimizeForTextSearch = true;
}

El método Query.SaveAsJson en AL es una función eficiente para exportar datos estructurados directamente desde un objeto Query de Business Central a un formato JSON.

A diferencia de recorrer un Record y construir un JSON manualmente con JsonObject.Add, esta función delega el trabajo al servidor, lo que la hace mucho más rápida para grandes volúmenes de datos.

Query.SaveAsJson(OutStream);

  • OutStream: Es el flujo de salida donde se escribirá el contenido JSON.

  • Retorno: Devuelve un booleano (True si tuvo éxito).

codeunit 50100 "ExportDataToJSON"
{
procedure ExportCustomers()
var
MyCustQuery: Query "My Customer Query"; // Tu objeto Query
TempBlob: Codeunit "Temp Blob";
OutStr: OutStream;
InStr: InStream;
FileName: Text;
begin
FileName := 'ClientesExport.json';
// 1. Crear el OutStream usando Temp Blob
TempBlob.CreateOutStream(OutStr);
// 2. Ejecutar y guardar el Query como JSON
if MyCustQuery.SaveAsJson(OutStr) then begin
// 3. Descargar el archivo para el usuario
TempBlob.CreateInStream(InStr);
DownloadFromStream(InStr, 'Descargar JSON', '', '', FileName);
end else
Error('No se pudo generar el JSON.');
end;
}

Usar colecciones nativas (Listas/Diccionarios) en cambio de tablas temporales

Sección titulada «Usar colecciones nativas (Listas/Diccionarios) en cambio de tablas temporales»

El uso de estructuras de datos en memoria debe priorizar el rendimiento y la simplicidad. Se debe evitar la creación de tablas temporales.

  • No usar tablas temporales en para operaciones que pueden resolverse con tipos de datos nativos (List\Disctionary).
  • No Usar tablas temporarias para copiar grandes sets de datos de una tabla física a menos que el tamaño del conjunto esté estrictamente controlado y justificado.
  • Antes de usar una tabla temporal para “clonar” datos, evalúe si puede filtrar el registro original o utilizar un objeto Query para procesar la información directamente desde SQL.

El objeto DataTransfer debe utilizarse obligatoriamente cuando se requiera mover o copiar datos entre tablas. Por ejemplo, de una tabla a otra o de un campo a otro en una actualización de extensión,sin necesidad de aplicar lógica de validación de AL.

Migración de datos entre tablas con la misma estructura o actualización masiva de campos tras un cambio de esquema.

internal procedure MigrarDatosEficientemente()
var
DataTransfer: DataTransfer;
Origen: Record "Old Table";
Destino: Record "New Table";
begin
// Definimos el mapeo de campos
DataTransfer.SetTables(Database::"Old Table", Database::"New Table");
DataTransfer.AddFieldValue(Origen.FieldNo("Old Code"), Destino.FieldNo("New Code"));
DataTransfer.AddFieldValue(Origen.FieldNo("Old Name"), Destino.FieldNo("New Name"));
// Filtramos si es necesario
DataTransfer.AddSourceFilter(Origen.FieldNo(Status), '=%1', Origen.Status::Active);
// Ejecución en SQL (sin triggers de AL)
DataTransfer.CopyRows();
end;

Uso de Funciones Vinculadas a Campos (Expression Fields)

Sección titulada «Uso de Funciones Vinculadas a Campos (Expression Fields)»

Se debe priorizar el uso de funciones directamente vinculadas, en lugar de realizar cálculos masivos en el trigger OnAfterGetRecord.

Mostrar un dato calculado (ej. un estado de crédito dinámico o un semáforo de colores) en una lista.

  • Mala Práctica: Calcular el valor para todos los registros dentro de OnAfterGetRecord y guardarlo en una variable global de la página.

  • Impacto: Business Central ejecuta OnAfterGetRecord para cada fila que se procesa en el dataset, incluso si el usuario no ha llegado a ver esas filas en el scroll. Esto consume ciclos de CPU del NST de forma innecesaria.

trigger OnAfterGetRecord()
begin
// Esto se ejecuta para CADA fila del dataset al cargar
EstadoCliente := CustomLogic.CalcularEstado(Rec);
end;
  • Solución: Crear una función local que devuelva el valor deseado y asignarla directamente al campo en el diseño de la página.

  • Justificación: Al vincular una función a un campo, el cliente de Business Central solo invoca la función para los registros que son actualmente visibles en la pantalla del usuario. Si el usuario no hace scroll, el cálculo para los registros inferiores nunca se ejecuta.

field(Estado; VOGObtenerEstadoDinamico()) // La función se vincula directamente
{
ApplicationArea = All;
Caption = 'Estado de Crédito';
}
// En la sección de procedimientos:
local procedure VOGObtenerEstadoDinamico(): Text
begin
// Este código SOLO se ejecuta para las filas visibles en pantalla
exit(CustomLogic.CalcularEstado(Rec));
end;
  • Variables Globales: El uso de funciones en SourceExpr reduce la necesidad de declarar variables globales en la página, limpiando la memoria de la sesión.

  • Propiedad Editable: Los campos vinculados a funciones deben tener siempre la propiedad Editable = false.

  • Cálculos Pesados: Si la función vinculada requiere consultas SQL complejas, se debe asegurar que los campos necesarios para esa consulta hayan sido cargados previamente mediante SetLoadFields o SetAutoCalcFields.

Evitar logica pesada en los suscriptores de OnOpenCompany

Sección titulada «Evitar logica pesada en los suscriptores de OnOpenCompany»

Se prohíbe la inclusión de lógica de negocio compleja, procesos de sincronización masiva o cálculos de datos pesados dentro del trigger OnOpenCompany. Este trigger debe reservarse exclusivamente para verificaciones de configuración mínimas y ligeras.

Si el trigger contiene consultas a tablas con millones de registros, bucles de validación o llamadas a APIs externas, el usuario verá una pantalla en blanco o el círculo de carga durante varios segundos (o incluso minutos) antes de poder trabajar.

Si 100 usuarios inician sesión al mismo tiempo, el servidor intentará ejecutar 100 veces esa lógica pesada, saturando los recursos de CPU y memoria de la instancia.

  • ¿Hay llamadas externas? (HTTP Requests): Prohibido. El inicio de sesión no debe depender de la disponibilidad de un servicio externo.

  • ¿Hay bucles repeat..until? (Iteración de tablas): Prohibido. Cualquier búsqueda debe ser por clave primaria (Get).

  • ¿Se modifican registros? (Escrituras en base de datos): Evitar. Las escrituras durante el login pueden causar bloqueos de tabla (Table Locking) si muchos usuarios entran a la vez.

Usar PageBackground Tasks para calculos pesados

Sección titulada «Usar PageBackground Tasks para calculos pesados»

El uso de Page Background Tasks (PBT) permite que la interfaz de usuario permanezca interactiva mientras el servidor realiza cálculos complejos en segundo plano.

Una Role Center o una Ficha de Cliente que muestra KPIs complejos (ej. “Total de Ventas año actual vs año anterior”).

  • El Problema: Si estos cálculos se ejecutan en el OnAfterGetRecord o OnOpenPage, el usuario no puede hacer clic en nada ni navegar por la página hasta que el cálculo termine.

  • La Solución: PBT dispara una sesión hija asíncrona que realiza el cálculo. El usuario puede empezar a trabajar inmediatamente, y los resultados aparecen en pantalla en cuanto el proceso termina.

//Esta Codeunit hace el "trabajo sucio". No puede modificar datos, solo calcular y devolver resultados.
codeunit 50100 "CustomerKPIProcessor"
{
trigger OnRun()
var
CustNo: Code[20];
Results: Dictionary of [Text, Text];
TotalSales: Decimal;
AvgDays: Decimal;
begin
// Leer parámetros enviados desde la página
CustNo := Page.GetBackgroundParameters().Get('CustNo');
// Lógica pesada: Simulación de cálculos complejos
TotalSales := CalculateLastYearSales(CustNo);
AvgDays := CalculateAvgPaymentDays(CustNo);
// Empaquetar resultados
Results.Add('TotalSales', Format(TotalSales, 0, 9));
Results.Add('AvgDays', Format(AvgDays, 0, 9));
Page.SetBackgroundTaskResult(Results);
end;
local procedure CalculateLastYearSales(CustNo: Code[20]): Decimal
var
CustLedgerEntry: Record "Cust. Ledger Entry";
begin
CustLedgerEntry.SetRange("Customer No.", CustNo);
CustLedgerEntry.SetRange("Document Type", CustLedgerEntry."Document Type"::Invoice);
CustLedgerEntry.SetRange("Posting Date", CalcDate('<-1Y>', Today), Today);
CustLedgerEntry.CalcSums("Sales (LCY)");
exit(CustLedgerEntry."Sales (LCY)");
end;
local procedure CalculateAvgPaymentDays(CustNo: Code[20]): Decimal
begin
// Aquí iría una lógica compleja de iteración...
Sleep(2000); // Simulamos un retraso de 2 segundos
exit(45.5);
end;
}
// Aquí disparamos la tarea y recibimos los datos sin bloquear al usuario.
pageextension 50101 "CustomerKPICardExt" extends "Customer Card"
{
layout
{
addlast(General)
{
group(PerformanceKPIs)
{
Caption = 'Indicadores de Rendimiento (Segundo Plano)';
field(AvgPaymentDays; AvgPaymentDaysVar)
{
ApplicationArea = All;
Caption = 'Promedio Días Pago';
Editable = false;
}
field(LastYearSales; LastYearSalesVar)
{
ApplicationArea = All;
Caption = 'Ventas Año Pasado';
Editable = false;
}
}
}
}
var
AvgPaymentDaysVar: Decimal;
LastYearSalesVar: Decimal;
trigger OnAfterGetCurrRecord()
var
TaskParameters: Dictionary of [Text, Text];
begin
// Reiniciar variables para que el usuario vea que se están cargando
AvgPaymentDaysVar := 0;
LastYearSalesVar := 0;
TaskParameters.Add('CustNo', Rec."No.");
// Encolar la tarea
CurrPage.EnqueueBackgroundTask(Codeunit::"CustomerKPIProcessor", TaskParameters, 10000, PageBackgroundTaskPriority::Normal);
end;
trigger OnPageBackgroundTaskCompleted(TaskId: Integer; Results: Dictionary of [Text, Text])
var
ResultValue: Text;
begin
// Recuperar y evaluar los resultados del diccionario
if Results.Get('AvgDays', ResultValue) then
Evaluate(AvgPaymentDaysVar, ResultValue);
if Results.Get('TotalSales', ResultValue) then
Evaluate(LastYearSalesVar, ResultValue);
end;
trigger OnPageBackgroundTaskError(TaskId: Integer; ErrorCode: Text; ErrorText: Text; ErrorCallStack: Text; var IsHandled: Boolean)
begin
// Si algo falla, evitamos que el error bloquee la página
IsHandled := true;
Message('No se pudieron cargar los KPIs: %1', ErrorText);
end;
}

Aquí disparamos la tarea y recibimos los datos sin bloquear al usuario.

Sección titulada «Aquí disparamos la tarea y recibimos los datos sin bloquear al usuario.»

Se prohíbe realizar llamadas externas mediante HttpClient de forma sincrónica dentro de triggers que afecten la interfaz de usuario (como OnOpenPage, OnValidate o OnAfterGetRecord).

Estas llamadas deben delegarse a procesos asíncronos para proteger la disponibilidad del servicio.

El sistema consulta un servicio de logística externo para obtener el estado de un paquete al abrir la Factura de Venta.

  • El Problema: Si el servicio externo tarda 10 segundos en responder (o está caído), la sesión del usuario en Business Central se congela totalmente durante ese tiempo.

  • Impacto en el servidor: El servidor de Business Central tiene un número limitado de hilos (threads). Si muchos usuarios ejecutan llamadas sincrónicas lentas simultáneamente, se puede agotar la capacidad del servidor, provocando una caída del servicio para todos los usuarios.

A. Uso de Page Background Tasks (PBT) - Para Consultas Visuales Si el dato externo solo se necesita para visualizarlo en pantalla (ej. consultar un saldo en un portal externo), utiliza un PBT como vimos anteriormente.

El archivo README.md en la raíz del proyecto debe contener una descripción clara y concisa de la extensión, incluyendo su propósito, funcionalidades principales, requisitos previos y cualquier otra información relevante para los desarrolladores y usuarios finales.

La estructura recomendada para el archivo README.md es la siguiente:

Debe ser el título principal del archivo README.md

Una breve descripción de la extensión, su propósito y las funcionalidades que ofrece.

El rango de id de los objetos utilizados en la extensión.

Una lista de los objetos principales incluidos en la extensión, con una breve descripción de cada uno. En el caso de las tabla y las paginas, incluir una lista de los campos principales y su descripción.

Instrucciones sobre los requisitos previos necesarios para instalar y utilizar la extensión, como versiones específicas de Business Central o dependencias de otras extensiones. Esta sección es opcional.

Instrucciones sobre cómo utilizar la extensión, incluyendo ejemplos de uso si es necesario.

Se puede encontrar un ejemplo de archivo README.md siguiendo este estándar en el siguiente repositorio de GitHub: