<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Blog · Cristian Martínez</title>
    <link>https://cristianmartinez.es/blog</link>
    <description>Artículos sobre desarrollo web, Microsoft Dynamics 365 Business Central, AL e integraciones.</description>
    <language>es-es</language>
    <atom:link href="https://cristianmartinez.es/rss.xml" rel="self" type="application/rss+xml"/>
    <lastBuildDate>Sun, 01 Nov 2026 00:00:00 GMT</lastBuildDate>
    <item>
      <title>Inserciones masivas en Business Central: cómo no romper el bulk insert</title>
      <link>https://cristianmartinez.es/blog/inserciones-masivas-bulk-insert-business-central</link>
      <guid isPermaLink="true">https://cristianmartinez.es/blog/inserciones-masivas-bulk-insert-business-central</guid>
      <pubDate>Sun, 01 Nov 2026 00:00:00 GMT</pubDate>
      <description>Business Central agrupa los Insert() y los envía a SQL Server de golpe. Qué lo vacía antes de tiempo, qué lo desactiva y cómo escribir bucles AL que lo aprovechen.</description>
      <category>business-central</category>
      <category>al</category>
      <category>rendimiento</category>
      <content:encoded><![CDATA[<p>Tienes un proceso que inserta miles de movimientos: un registro de diario, una importación, una migración. Funciona, pero tarda mucho más de lo que debería. Muchas veces la causa no está en SQL Server, sino en una línea del bucle que, sin que lo sepas, obliga a Business Central a enviar <strong>cada <code>Insert()</code> por separado</strong>.</p>
<h2 id="que-es-el-bulk-insert">Qué es el bulk insert</h2>
<p>Por defecto, el servidor de Business Central <strong>no ejecuta cada <code>Insert()</code> en el momento</strong>. Los guarda en un búfer y, cuando hace falta, los envía a SQL Server <strong>todos juntos</strong>.</p>
<p>Esto tiene dos ventajas:</p>
<ul>
<li><strong>Menos llamadas al servidor de base de datos.</strong> Mil inserciones agrupadas cuestan mucho menos que mil viajes de ida y vuelta.</li>
<li><strong>Menos tiempo con las tablas bloqueadas.</strong> La inserción real se retrasa hasta el último momento posible de la transacción, así que los bloqueos duran menos. Se nota sobre todo en tablas con <strong>índices SIFT</strong> (los que alimentan los <code>FlowField</code> de sumas), que se actualizan con cada fila.</li>
</ul>
<p>El bulk insert es automático: no hay que activarlo en el código. Lo que sí puede hacer el código es <strong>impedirlo</strong> sin querer.</p>
<h2 id="cuando-se-envia-el-bufer-a-sql-server">Cuándo se envía el búfer a SQL Server</h2>
<p>Los registros que esperan en el búfer se escriben en cuanto ocurre cualquiera de estas cosas:</p>
<ol>
<li>Se hace <strong><code>Commit()</code></strong> de la transacción.</li>
<li>Se llama a <strong><code>Modify()</code></strong> o <strong><code>Delete()</code></strong> sobre la tabla.</li>
<li>Se llama a cualquier método <strong><code>Find</code></strong> o <strong><code>Calc</code></strong> sobre la tabla: <code>Find()</code>, <code>FindFirst()</code>, <code>FindLast()</code>, <code>FindSet()</code>, <code>Get()</code>, <code>CalcFields()</code>, <code>CalcSums()</code>...</li>
</ol>
<p><img src="https://cristianmartinez.es/assets/blog/inserciones-masivas-bulk-insert-business-central/bufer.svg" alt="Ciclo del búfer: los Insert() se acumulan y se envían juntos al hacer Commit, Modify/Delete o Find/Calc sobre la misma tabla" loading="lazy" decoding="async"></p>
<p>La clave está en <strong>&quot;sobre la tabla&quot;</strong>: leer de <em>otra</em> tabla no vacía el búfer. Si dentro del bucle lees la misma tabla en la que estás insertando, cada vuelta envía lo acumulado y el búfer nunca llega a llenarse. Tienes el coste de un insert por fila, como si el bulk insert no existiera.</p>
<h2 id="cuando-no-se-usa-el-bufer">Cuándo no se usa el búfer</h2>
<p>Hay casos en los que Business Central ni siquiera intenta agrupar las inserciones:</p>
<table>
<thead>
<tr>
<th>Caso</th>
<th>Por qué</th>
</tr>
</thead>
<tbody><tr>
<td>El código <strong>usa el valor de retorno</strong> de <code>Insert()</code>, por ejemplo <code>if GLEntry.Insert() then</code></td>
<td>Para saber si ha ido bien, la fila tiene que escribirse en ese instante.</td>
</tr>
<tr>
<td>La tabla tiene campos <strong><code>Blob</code></strong></td>
<td>Estas tablas siempre se insertan fila a fila.</td>
</tr>
<tr>
<td>La tabla tiene campos con <strong><code>AutoIncrement = true</code></strong></td>
<td>Igual: no se agrupan.</td>
</tr>
</tbody></table>
<p>El primero es el más traicionero, porque el patrón <code>if not Rec.Insert() then Rec.Modify()</code> es muy común para &quot;insertar o actualizar&quot;. Es cómodo, pero en un bucle grande convierte cada fila en una llamada a SQL. Si sabes que el registro no existe, usa <code>Rec.Insert()</code> sin mirar el resultado; si no lo sabes, a menudo compensa comprobarlo antes en bloque.</p>
<h2 id="el-error-tipico-buscar-el-ultimo-numero-dentro-del-bucle">El error típico: buscar el último número dentro del bucle</h2>
<p>Este es el ejemplo que da Microsoft, adaptado a la sintaxis actual de AL. Se registran líneas de diario y cada movimiento necesita el siguiente <code>&quot;Entry No.&quot;</code>:</p>
<pre class="code-block" data-lang="al"><code class="language-al">// No aprovecha el bulk insert
GLEntry.LockTable();
if JnlLine.FindSet() then
    repeat
        GLEntry.FindLast();                         // Find sobre G/L Entry: vacía el búfer
        GLEntry.Init();
        GLEntry.&quot;Entry No.&quot; := GLEntry.&quot;Entry No.&quot; + 1;
        GLEntry.&quot;G/L Account No.&quot; := JnlLine.&quot;Account No.&quot;;
        GLEntry.Amount := JnlLine.Amount;
        GLEntry.Insert();
    until JnlLine.Next() = 0;
Commit();</code></pre>
<p>El <code>FindLast()</code> está sobre <strong>G/L Entry</strong>, la misma tabla en la que se inserta. En cada vuelta obliga a escribir lo que había en el búfer, así que todas las inserciones van una a una.</p>
<p>La corrección es buscar el último número <strong>una sola vez, antes del bucle</strong>, y llevar la cuenta en una variable:</p>
<pre class="code-block" data-lang="al"><code class="language-al">// Aprovecha el bulk insert
GLEntry.LockTable();
if GLEntry.FindLast() then
    NextEntryNo := GLEntry.&quot;Entry No.&quot; + 1
else
    NextEntryNo := 1;

if JnlLine.FindSet() then
    repeat
        GLEntry.Init();
        GLEntry.&quot;Entry No.&quot; := NextEntryNo;
        GLEntry.&quot;G/L Account No.&quot; := JnlLine.&quot;Account No.&quot;;
        GLEntry.Amount := JnlLine.Amount;
        GLEntry.Insert();
        NextEntryNo += 1;
    until JnlLine.Next() = 0;
Commit();                                           // aquí se hacen las inserciones</code></pre>
<p><code>JnlLine.Next()</code> sigue leyendo dentro del bucle, pero es <strong>otra tabla</strong>, así que no afecta al búfer de G/L Entry. Las inserciones se acumulan y se envían juntas en el <code>Commit()</code>.</p>
<p>El <code>LockTable()</code> antes del <code>FindLast()</code> es importante: bloquea la tabla para que ningún otro proceso coja el mismo número entre la lectura y la inserción.</p>
<h2 id="como-revisar-tus-propios-bucles">Cómo revisar tus propios bucles</h2>
<p>Antes de dar por bueno un proceso que inserta muchas filas, repasa el cuerpo del bucle con estas preguntas:</p>
<ol>
<li><strong>¿Hay algún <code>Get</code>, <code>Find*</code>, <code>CalcFields</code> o <code>CalcSums</code> sobre la tabla destino?</strong> Sácalo fuera del bucle o guarda el dato en una variable.</li>
<li><strong>¿Se hace <code>Modify()</code> o <code>Delete()</code> sobre la tabla destino entre inserciones?</strong> Si puedes, rellena el registro completo antes del <code>Insert()</code> en lugar de insertar y modificar después.</li>
<li><strong>¿Se usa el resultado de <code>Insert()</code>?</strong> Si no lo necesitas, quítalo.</li>
<li><strong>¿Hay un <code>Commit()</code> dentro del bucle?</strong> Además de vaciar el búfer en cada vuelta, rompe la atomicidad del proceso. Casi nunca es buena idea.</li>
<li><strong>¿La tabla tiene <code>Blob</code> o <code>AutoIncrement</code>?</strong> Si es una tabla tuya y vas a insertar en volumen, valora si de verdad hacen falta. Para imágenes y ficheros, los tipos <code>Media</code> y <code>MediaSet</code> guardan el contenido en otra tabla del sistema.</li>
</ol>
<p>Ojo también con los <strong>suscriptores de eventos</strong>. Si llamas a <code>Insert(true)</code>, se ejecutan el trigger <code>OnInsert</code> y los eventos asociados; si alguna extensión lee o modifica la tabla desde ahí, el búfer se vacía aunque tu bucle esté limpio.</p>
<h2 id="desactivarlo-para-depurar-bufferedinsertenabled">Desactivarlo para depurar: BufferedInsertEnabled</h2>
<p>Hay un efecto secundario del búfer que conviene conocer: como la fila no se escribe en el <code>Insert()</code>, <strong>un error de inserción</strong> (una clave duplicada, por ejemplo) puede aparecer más tarde, en el <code>Commit()</code> o en el siguiente <code>Find</code>, y no en la línea que lo provoca. Eso despista mucho al depurar.</p>
<p>Para estos casos, en <strong>instalaciones on-premise</strong> se puede desactivar el bulk insert en el fichero <code>CustomSettings.config</code> del servidor de Business Central:</p>
<pre class="code-block" data-lang="xml"><code class="language-xml">&lt;add key=&quot;BufferedInsertEnabled&quot; value=&quot;false&quot; /&gt;</code></pre>
<p>Hay que reiniciar la instancia del servicio para que se aplique. Con el búfer desactivado, el error aparece en el <code>Insert()</code> que lo causa. Una vez localizado el problema, vuelve a dejarlo como estaba: desactivarlo de forma permanente penaliza el rendimiento de todos los procesos.</p>
<p>En <strong>Business Central online</strong> no tienes acceso a la configuración del servidor, así que lo único que está en tu mano es escribir bien los bucles.</p>
<h2 id="resumen">Resumen</h2>
<ul>
<li>Business Central <strong>agrupa los <code>Insert()</code></strong> y los envía juntos a SQL Server: menos llamadas y bloqueos más cortos.</li>
<li>El búfer se vacía con <strong><code>Commit()</code></strong>, con <strong><code>Modify()</code>/<code>Delete()</code></strong> y con cualquier <strong><code>Find</code>/<code>Calc</code></strong> sobre <strong>la misma tabla</strong>.</li>
<li>No se agrupa si <strong>usas el resultado de <code>Insert()</code></strong> o si la tabla tiene campos <strong><code>Blob</code></strong> o <strong><code>AutoIncrement</code></strong>.</li>
<li>Calcula los números de movimiento <strong>antes del bucle</strong> y no leas la tabla destino dentro de él.</li>
<li>Para depurar errores de inserción on-premise, <strong><code>BufferedInsertEnabled = false</code></strong> en <code>CustomSettings.config</code>, y solo mientras depuras.</li>
</ul>
<p>Fuente: <a href="https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/administration/optimize-sql-bulk-inserts" target="_blank" rel="noopener noreferrer">Bulk Inserts – Microsoft Learn</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Una sola base de código AL para varias versiones de Business Central</title>
      <link>https://cristianmartinez.es/blog/varias-versiones-business-central-directivas-al</link>
      <guid isPermaLink="true">https://cristianmartinez.es/blog/varias-versiones-business-central-directivas-al</guid>
      <pubDate>Wed, 28 Oct 2026 00:00:00 GMT</pubDate>
      <description>Cómo mantener una extensión que compila para BC 22, BC 24 y BC 26 desde el mismo repositorio con directivas de preprocesador (#if,</description>
      <category>business-central</category>
      <category>al</category>
      <category>extensiones</category>
      <content:encoded><![CDATA[<p>Si tienes clientes de Business Central on-premise, sabes que no todos están en la misma versión. Uno sigue en la <strong>v22</strong>, otro acaba de subir a la <strong>v24</strong> y el de SaaS ya va por la <strong>v26</strong>. Y tu extensión tiene que funcionar en los tres.</p>
<p>La salida fácil es una rama de Git por versión. Funciona el primer mes; después, cada corrección hay que llevarla a mano a tres ramas, y alguna se olvida. La alternativa es <strong>una sola base de código</strong> que se compila distinta según la versión de destino, con las <strong>directivas de preprocesador de AL</strong>.</p>
<h2 id="que-son-las-directivas-de-preprocesador">Qué son las directivas de preprocesador</h2>
<p>Desde Business Central 2020 release wave 2 (v17), el compilador de AL entiende líneas que empiezan por <code>#</code>. No son código: se evalúan <strong>antes de compilar</strong> y deciden qué trozos llegan al compilador y cuáles se descartan.</p>
<table>
<thead>
<tr>
<th>Directiva</th>
<th>Para qué sirve</th>
</tr>
</thead>
<tbody><tr>
<td><code>#if</code></td>
<td>Empieza un bloque condicional: el código entra si la expresión es verdadera.</td>
</tr>
<tr>
<td><code>#elif</code></td>
<td>Otra condición, si ninguna anterior se ha cumplido.</td>
</tr>
<tr>
<td><code>#else</code></td>
<td>El código que entra si no se ha cumplido ninguna.</td>
</tr>
<tr>
<td><code>#endif</code></td>
<td>Cierra el bloque.</td>
</tr>
<tr>
<td><code>#define</code> / <code>#undef</code></td>
<td>Define o quita un símbolo, solo en ese fichero.</td>
</tr>
</tbody></table>
<p>Las condiciones admiten <code>and</code>, <code>or</code>, <code>not</code> y paréntesis. Se puede hacer condicional <strong>cualquier cosa</strong>: una línea dentro de un procedimiento, un procedimiento entero, un campo de una tabla o un objeto completo.</p>
<h2 id="los-simbolos-interruptores-encendidos-o-apagados">Los símbolos: interruptores encendidos o apagados</h2>
<p>Un <strong>símbolo</strong> es un nombre que está definido (vale <code>true</code>) o no (vale <code>false</code>). No tiene valor numérico: no existe <code>#if VERSION &gt;= 24</code>.</p>
<p>Los símbolos para toda la extensión se declaran en <code>app.json</code>:</p>
<pre class="code-block" data-lang="json"><code class="language-json">{
  &quot;preprocessorSymbols&quot;: [ &quot;BC22PLUS&quot;, &quot;BC24PLUS&quot; ]
}</code></pre>
<p>Y con <code>#define</code> se pueden añadir solo para un fichero, siempre antes del primer objeto:</p>
<pre class="code-block" data-lang="al"><code class="language-al">#define DEBUG

codeunit 50100 &quot;CM Diagnostics&quot;
{
    ...
}</code></pre>
<p>Un detalle importante: <strong>AL no trae símbolos de versión de serie</strong>. El compilador no sabe &quot;para qué versión estás compilando&quot; a efectos de <code>#if</code>; esos símbolos los defines tú.</p>
<h2 id="simbolos-acumulativos-el-truco-para-comparar-versiones">Símbolos acumulativos: el truco para comparar versiones</h2>
<p>Como no hay comparaciones, el patrón que mejor funciona es definir <strong>un símbolo por cada versión mínima que se cumple</strong>. Al compilar para la v26, se encienden también los de la v22 y la v24:</p>
<p><img src="https://cristianmartinez.es/assets/blog/varias-versiones-business-central-directivas-al/simbolos.svg" alt="Símbolos acumulativos por versión de destino: BC22PLUS, BC24PLUS y BC26PLUS" loading="lazy" decoding="async"></p>
<p>Así, <code>#if BC24PLUS</code> significa &quot;de la v24 en adelante&quot;, y <code>#if not BC24PLUS</code>, &quot;anterior a la v24&quot;. Es el mismo enfoque que usa Microsoft en la Base App con sus símbolos <code>CLEAN22</code>, <code>CLEAN23</code>... para el código que va a eliminar.</p>
<h2 id="un-ejemplo-real-las-series-numericas-en-la-v24">Un ejemplo real: las series numéricas en la v24</h2>
<p>En la <strong>v24 (2024 release wave 1)</strong>, Microsoft marcó como obsoleta la codeunit <code>NoSeriesManagement</code> y la sustituyó por la nueva <code>&quot;No. Series&quot;</code>. Si tu extensión numera documentos propios, en la v24 te salta el aviso de obsoleto y, cuando Microsoft la elimine, dejará de compilar. Pero en la v22 la codeunit nueva todavía no existe.</p>
<p>Con directivas, el mismo fichero sirve para todas:</p>
<pre class="code-block" data-lang="al"><code class="language-al">table 50100 &quot;CM Service Order&quot;
{
    ...
    trigger OnInsert()
    var
        ServiceSetup: Record &quot;CM Service Setup&quot;;
#if BC24PLUS
        NoSeries: Codeunit &quot;No. Series&quot;;
#else
        NoSeriesMgt: Codeunit NoSeriesManagement;
#endif
    begin
        if &quot;No.&quot; &lt;&gt; &#39;&#39; then
            exit;
        ServiceSetup.Get();
#if BC24PLUS
        &quot;No. Series&quot; := ServiceSetup.&quot;Service Order Nos.&quot;;
        &quot;No.&quot; := NoSeries.GetNextNo(&quot;No. Series&quot;, WorkDate());
#else
        NoSeriesMgt.InitSeries(ServiceSetup.&quot;Service Order Nos.&quot;, xRec.&quot;No. Series&quot;, WorkDate(), &quot;No.&quot;, &quot;No. Series&quot;);
#endif
    end;
}</code></pre>
<p>Al compilar para la v22, el compilador solo ve la rama de <code>NoSeriesManagement</code>; para la v24 o la v26, solo la de <code>&quot;No. Series&quot;</code>. Ninguna de las dos versiones del <code>.app</code> arrastra código que no entiende.</p>
<p>También se pueden condicionar <strong>campos</strong> y <strong>objetos enteros</strong>, por ejemplo una <code>pageextension</code> sobre una página que solo existe a partir de cierta versión:</p>
<pre class="code-block" data-lang="al"><code class="language-al">#if BC26PLUS
pageextension 50110 &quot;CM New Feature Page&quot; extends &quot;Some Page Added In 26&quot;
{
    ...
}
#endif</code></pre>
<h3 id="silenciar-avisos-en-la-rama-antigua">Silenciar avisos en la rama antigua</h3>
<p>Si una versión intermedia todavía tiene el objeto antiguo marcado como obsoleto, el compilador lanzará el aviso <strong>AL0432</strong>. Cuando conscientemente lo sigues usando en la rama de compatibilidad, <code>#pragma</code> lo silencia solo en ese trozo:</p>
<pre class="code-block" data-lang="al"><code class="language-al">#pragma warning disable AL0432
        NoSeriesMgt.InitSeries(...);
#pragma warning restore AL0432</code></pre>
<h2 id="como-cambiar-de-version-app-json-por-destino">Cómo cambiar de versión: app.json por destino</h2>
<p>El <code>app.json</code> no admite directivas, y es justo el fichero que más cambia entre versiones: <code>application</code>, <code>platform</code>, <code>runtime</code>, las dependencias y los símbolos.</p>
<p><img src="https://cristianmartinez.es/assets/blog/varias-versiones-business-central-directivas-al/flujo.svg" alt="Flujo: un repositorio con directivas compila tres .app distintos para BC 22, BC 24 y BC 26" loading="lazy" decoding="async"></p>
<p>La solución práctica es guardar los valores de cada destino y generar el <code>app.json</code> antes de compilar. Por ejemplo, con un pequeño script de PowerShell:</p>
<pre class="code-block" data-lang="powershell"><code class="language-powershell">param([ValidateSet(&#39;22&#39;, &#39;24&#39;, &#39;26&#39;)] [string] $Version = &#39;26&#39;)

$targets = @{
    &#39;22&#39; = @{ application = &#39;22.0.0.0&#39;; runtime = &#39;11.0&#39;; symbols = @(&#39;BC22PLUS&#39;) }
    &#39;24&#39; = @{ application = &#39;24.0.0.0&#39;; runtime = &#39;13.0&#39;; symbols = @(&#39;BC22PLUS&#39;, &#39;BC24PLUS&#39;) }
    &#39;26&#39; = @{ application = &#39;26.0.0.0&#39;; runtime = &#39;15.0&#39;; symbols = @(&#39;BC22PLUS&#39;, &#39;BC24PLUS&#39;, &#39;BC26PLUS&#39;) }
}
$t = $targets[$Version]

$app = Get-Content app.json -Raw | ConvertFrom-Json
$app.application = $t.application
$app.platform = $t.application -replace &#39;^(\d+)\..*&#39;, &#39;$1.0.0.0&#39;
$app.runtime = $t.runtime
$app.preprocessorSymbols = $t.symbols
$app | ConvertTo-Json -Depth 10 | Set-Content app.json -Encoding utf8</code></pre>
<p>En Visual Studio Code, usa además una carpeta de símbolos por versión (<code>.alpackages/bc22</code>, <code>.alpackages/bc24</code>...) con el ajuste <code>al.packageCachePath</code>, para no descargar los símbolos cada vez que cambias de destino.</p>
<p>Si compilas con <strong>AL-Go for GitHub</strong>, no hace falta el script: tiene el ajuste <code>preprocessorSymbols</code> en su <code>settings.json</code>, combinable con configuraciones condicionales para lanzar un build por versión.</p>
<h2 id="buenas-practicas">Buenas prácticas</h2>
<ol>
<li><strong>Compila todos los destinos en cada cambio.</strong> Una rama <code>#if</code> que nadie compila se rompe sin que te enteres. En el pipeline, un build por versión soportada.</li>
<li><strong>Pocas ramas y pequeñas.</strong> Condiciona la línea o el procedimiento que cambia, no medio fichero. Si un bloque crece, sácalo a un procedimiento propio con dos implementaciones.</li>
<li><strong>Nombres claros y documentados.</strong> <code>BC24PLUS</code> se entiende solo; <code>NEWNOSERIES</code>, no tanto. Apunta en el README qué símbolos existen y qué significan.</li>
<li><strong>Limpia cuando dejes una versión.</strong> Cuando ya no des soporte a la v22, busca <code>BC22PLUS</code>, quita las ramas <code>#else</code> y el símbolo. Las directivas son deuda técnica con fecha de caducidad.</li>
<li><strong>Las ramas de Git, para lo que no cabe.</strong> Si dos versiones divergen en casi todo (por ejemplo, NAV con C/AL frente a BC con AL), las directivas no son la herramienta: ahí sí tiene sentido separar.</li>
</ol>
<h2 id="directivas-o-ramas-de-git">Directivas o ramas de Git</h2>
<table>
<thead>
<tr>
<th></th>
<th>Ramas por versión</th>
<th>Una base con directivas</th>
</tr>
</thead>
<tbody><tr>
<td>Corregir un error</td>
<td>En cada rama, a mano</td>
<td>Una vez</td>
</tr>
<tr>
<td>Ver qué cambia entre versiones</td>
<td>Comparando ramas</td>
<td>En el propio código</td>
</tr>
<tr>
<td>Riesgo de que diverjan</td>
<td>Alto</td>
<td>Bajo</td>
</tr>
<tr>
<td>Complejidad del código</td>
<td>Baja</td>
<td>Algo mayor (bloques <code>#if</code>)</td>
</tr>
<tr>
<td>Build</td>
<td>Uno por rama</td>
<td>Uno por destino desde la misma rama</td>
</tr>
</tbody></table>
<p>Para una extensión que vive en varios clientes con versiones distintas, la base única gana casi siempre.</p>
<h2 id="resumen">Resumen</h2>
<ul>
<li>Las <strong>directivas</strong> (<code>#if</code>, <code>#elif</code>, <code>#else</code>, <code>#endif</code>) deciden qué código se compila.</li>
<li>Los <strong>símbolos</strong> se definen en <code>preprocessorSymbols</code> de <code>app.json</code> o con <code>#define</code>; AL no trae símbolos de versión.</li>
<li>Usa <strong>símbolos acumulativos</strong> (<code>BC22PLUS</code>, <code>BC24PLUS</code>...) para expresar &quot;de esta versión en adelante&quot;.</li>
<li>Genera el <strong><code>app.json</code> de cada destino</strong> con un script o con AL-Go, y compila todas las versiones en cada cambio.</li>
</ul>
<p>Fuente: <a href="https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/directives/devenv-directives-in-al" target="_blank" rel="noopener noreferrer">AL Preprocessor Directives Overview – Microsoft Learn</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>PHP2Shell: el fallo crítico de PHP que permite tomar el control de tu web y cómo protegerte</title>
      <link>https://cristianmartinez.es/blog/php2shell-vulnerabilidad-critica-php</link>
      <guid isPermaLink="true">https://cristianmartinez.es/blog/php2shell-vulnerabilidad-critica-php</guid>
      <pubDate>Wed, 21 Oct 2026 00:00:00 GMT</pubDate>
      <description>Qué son las vulnerabilidades CVE-2026-109618 y CVE-2026-109619 de PHP, a quién afectan, qué versiones las corrigen y qué hacer hoy mismo si tu web usa PHP o WordPress.</description>
      <category>seguridad</category>
      <category>php</category>
      <category>wordpress</category>
      <category>hosting</category>
      <content:encoded><![CDATA[<p>El 21 de octubre de 2026 el equipo de PHP publicó actualizaciones de emergencia para corregir dos vulnerabilidades críticas, bautizadas por los investigadores como <strong>PHP2Shell</strong>. Permiten a un atacante <strong>ejecutar código en tu servidor sin usuario ni contraseña</strong>, con una simple petición a tu web. Si tu web usa PHP, y eso incluye cualquier WordPress, PrestaShop o Moodle, te afecta.</p>
<h2 id="que-ha-pasado">Qué ha pasado</h2>
<p>Son dos fallos distintos, los dos con una puntuación de <strong>9,2 sobre 10</strong> (CVSS v4):</p>
<table>
<thead>
<tr>
<th>CVE</th>
<th>Qué falla</th>
<th>Resultado</th>
</tr>
</thead>
<tbody><tr>
<td><strong>CVE-2026-109618</strong></td>
<td>Cuando PHP se queda sin memoria a mitad de una petición, deja datos internos a medio inicializar y luego los usa como si fueran válidos.</td>
<td>Ejecución remota de código</td>
</tr>
<tr>
<td><strong>CVE-2026-109619</strong></td>
<td>Un proceso de PHP que atiende varias peticiones seguidas puede reutilizar memoria ya liberada de una petición anterior (<em>use-after-free</em>).</td>
<td>Ejecución remota de código</td>
</tr>
</tbody></table>
<p>En la práctica, el atacante envía un formulario o una subida de archivos (<em>multipart</em>) preparada para provocar el fallo. No necesita cuenta, ni que tu aplicación tenga ningún error propio: en la configuración más expuesta, <strong>el ataque se produce antes de que se ejecute una sola línea de tu web</strong>.</p>
<h2 id="una-filtracion-que-adelanto-el-parche">Una filtración que adelantó el parche</h2>
<p>La publicación estaba prevista para el jueves 22, pero el lunes 20 alguien publicó los detalles antes de tiempo. El post se borró, pero las capturas ya circulaban por redes. PHP respondió adelantando el parche al martes 21.</p>
<p>Al cierre de este artículo no hay exploit público ni ataques masivos confirmados, pero los investigadores coinciden en que <strong>es cuestión de días</strong>: varios equipos ya están reproduciendo el fallo y el propio aviso oficial confirma que existe un exploit funcional. El NCSC británico recomienda <strong>desconectar</strong> los sistemas que no se puedan actualizar ya.</p>
<h2 id="a-quien-afecta">A quién afecta</h2>
<p>A <strong>cualquier instalación de PHP que sirva páginas web</strong>, sea cual sea la forma de ejecutarlo:</p>
<ul>
<li><strong>Apache con mod_php</strong>: el caso más grave. El ataque funciona con una sola petición y sin tocar tu código.</li>
<li><strong>PHP-FPM</strong> (Nginx, la mayoría de hostings modernos): también vulnerable, con condiciones algo más difíciles de reunir.</li>
<li><strong>php-cgi, FrankenPHP y LiteSpeed</strong>: afectados.</li>
</ul>
<p>Lo que importa es la <strong>versión de PHP</strong>, no la aplicación. Un WordPress perfectamente actualizado sobre un PHP sin parchear sigue siendo vulnerable.</p>
<h2 id="que-versiones-estan-corregidas">Qué versiones están corregidas</h2>
<table>
<thead>
<tr>
<th>Rama</th>
<th>Versión segura</th>
<th>Soporte</th>
</tr>
</thead>
<tbody><tr>
<td>PHP 8.5</td>
<td><strong>8.5.12</strong> o superior</td>
<td>✅</td>
</tr>
<tr>
<td>PHP 8.4</td>
<td><strong>8.4.27</strong> o superior</td>
<td>✅</td>
</tr>
<tr>
<td>PHP 8.3</td>
<td><strong>8.3.36</strong> o superior</td>
<td>✅</td>
</tr>
<tr>
<td>PHP 8.2</td>
<td><strong>8.2.35</strong> o superior</td>
<td>⚠️ Fin de soporte el 31/12/2026</td>
</tr>
<tr>
<td>PHP 8.1, 8.0 y 7.4</td>
<td>Sin versión oficial</td>
<td>❌ Sin soporte</td>
</tr>
<tr>
<td>PHP 7.3 y anteriores</td>
<td>Sin parche</td>
<td>❌ Sin soporte</td>
</tr>
</tbody></table>
<p>Si estás en <strong>PHP 8.1 o anterior</strong>, no habrá actualización oficial. PHP ha publicado parches mínimos de código fuente para 7.4, 8.0 y 8.1, y algunas distribuciones Linux los incluirán en sus paquetes, pero lo sensato es <strong>subir a 8.3 o 8.4</strong> cuanto antes.</p>
<h2 id="que-hacer-hoy">Qué hacer hoy</h2>
<h3 id="1-comprueba-tu-version-de-php">1. Comprueba tu versión de PHP</h3>
<p>En un hosting compartido, la verás en el panel de control (normalmente en un apartado <em>Configuración de PHP</em>). En WordPress: <em>Herramientas &gt; Salud del sitio &gt; Información &gt; Servidor</em>. En un servidor propio:</p>
<pre class="code-block" data-lang="bash"><code class="language-bash">php -v</code></pre>
<p>Ojo: la versión de la línea de comandos puede no coincidir con la que usa la web. Si tienes dudas, crea un <code>info.php</code> con <code>&lt;?php phpinfo();</code>, ábrelo en el navegador y <strong>bórralo en cuanto lo veas</strong>.</p>
<h3 id="2-actualiza">2. Actualiza</h3>
<ul>
<li><strong>Hosting compartido o gestionado:</strong> los proveedores recibieron el parche antes de hacerse público y la mayoría ya lo está aplicando. Confirma con tu proveedor que tu versión es igual o posterior a las de la tabla. Si estás en PHP 8.1 o anterior, cambia de versión desde el panel.</li>
<li><strong>Servidor propio (Debian, Ubuntu, RHEL…):</strong> actualiza los paquetes de PHP de tu distribución y <strong>reinicia</strong> PHP-FPM o Apache. Sin reinicio, los procesos siguen ejecutando el código viejo.</li>
</ul>
<pre class="code-block" data-lang="bash"><code class="language-bash">sudo apt update &amp;&amp; sudo apt upgrade
sudo systemctl restart php8.3-fpm   # o apache2, según tu instalación</code></pre>
<p>Si usas el repositorio de <strong>Remi</strong> (<code>rpms.remirepo.net</code>), no tuvo los paquetes el día 21: pasa a los paquetes de tu distribución o compila desde el código fuente.</p>
<h3 id="3-si-no-puedes-actualizar-ya-mitigaciones-temporales">3. Si no puedes actualizar ya: mitigaciones temporales</h3>
<p>Reducen el riesgo, <strong>no lo eliminan</strong>. PHP lo deja claro: no hay mitigación completa, solo actualizar es seguro. Añádelas en el <code>php.ini</code> o en la configuración del pool de FPM (no en <code>.user.ini</code> ni con <code>ini_set()</code>, que llegan tarde) y reinicia todos los procesos:</p>
<pre class="code-block" data-lang="ini"><code class="language-ini">; CVE-2026-109618
memory_limit = -1
post_max_size = 8M          ; 32M si tu web necesita subir archivos
upload_max_filesize = 8M    ; siempre por debajo de post_max_size

; CVE-2026-109619
session.upload_progress.enabled = 0
fatal_error_backtraces = 0  ; solo PHP 8.5</code></pre>
<p>Además:</p>
<ul>
<li>Limita el tamaño de las peticiones en Nginx, Apache o tu proxy. Si tu web no tiene formularios con archivos, bloquea las peticiones <em>multipart</em> antes de que lleguen a PHP.</li>
<li>Si usas <strong>Cloudflare</strong>, ya tiene reglas de WAF para los vectores conocidos, también en el plan gratuito.</li>
<li><strong>No</strong> uses <code>RLIMIT_AS</code> ni <code>RLIMIT_DATA</code> para limitar memoria: provocan justo el fallo que se quiere evitar.</li>
</ul>
<h3 id="4-revisa-que-no-te-hayan-entrado">4. Revisa que no te hayan entrado</h3>
<p>Si tu web estuvo expuesta, vigila durante las próximas semanas: archivos <code>.php</code> nuevos o modificados en carpetas de subidas, usuarios administradores que no reconoces, tareas programadas extrañas o picos de tráfico saliente. Que no haya nada raro en los logs no garantiza que no te hayan atacado.</p>
<h2 id="resumen">Resumen</h2>
<ul>
<li><strong>PHP2Shell</strong> son dos fallos críticos (CVE-2026-109618 y CVE-2026-109619) que permiten ejecutar código en tu servidor sin autenticación.</li>
<li>Afecta a <strong>todo PHP que sirva webs</strong>, con o sin WordPress; el peor caso es Apache con mod_php.</li>
<li><strong>Versiones seguras:</strong> 8.5.12, 8.4.27, 8.3.36 y 8.2.35.</li>
<li><strong>PHP 8.1 o anterior:</strong> sin parche oficial; actualiza de rama.</li>
<li>Las mitigaciones ayudan mientras actualizas, pero <strong>solo el parche te protege</strong>.</li>
</ul>
<p>Si tienes una web y no sabes qué versión de PHP usa o cómo actualizarla, <a href="https://cristianmartinez.es/desarrollo-web">escríbeme</a> y lo revisamos.</p>
<p>Fuentes: <a href="https://www.php.net/" target="_blank" rel="noopener noreferrer">anuncio oficial de PHP</a>, avisos <a href="https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w" target="_blank" rel="noopener noreferrer">GHSA-w8hw-w9fx-j79w</a> y <a href="https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp" target="_blank" rel="noopener noreferrer">GHSA-6wmf-4rv5-p3rp</a>, <a href="https://www.rapid7.com/blog/post/etr-cve-2026-109618-and-cve-2026-109619-critical-unauthenticated-remote-code-execution-in-php/" target="_blank" rel="noopener noreferrer">Rapid7</a>, <a href="https://www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities" target="_blank" rel="noopener noreferrer">Huntress</a> y <a href="https://www.helpnetsecurity.com/2026/10/21/php2shell-php-vulnerabilities-patched-cve-2026-109618-cve-2026-109619/" target="_blank" rel="noopener noreferrer">Help Net Security</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Archivos .flf y .bclicense en Business Central: qué son y qué versión admite cada uno</title>
      <link>https://cristianmartinez.es/blog/licencias-flf-bclicense-business-central</link>
      <guid isPermaLink="true">https://cristianmartinez.es/blog/licencias-flf-bclicense-business-central</guid>
      <pubDate>Tue, 13 Oct 2026 00:00:00 GMT</pubDate>
      <description>Historia del archivo de licencia .flf de Navision y NAV, la llegada del .bclicense y una tabla y diagrama de compatibilidad por versión de Business Central on-premise.</description>
      <category>business-central</category>
      <category>licencias</category>
      <category>on-premise</category>
      <category>nav</category>
      <content:encoded><![CDATA[<p>Si alguna vez has instalado o migrado Dynamics NAV o Business Central on-premise, te ha tocado subir un archivo de licencia. Durante más de veinte años ese archivo fue un <code>.flf</code>; hoy es un <code>.bclicense</code>. Elegir el formato equivocado para tu versión significa que la licencia no se importa.</p>
<h2 id="un-poco-de-historia-el-flf">Un poco de historia: el .flf</h2>
<p>El <strong>.flf</strong> nace con <strong>Navision Financials</strong> a finales de los 90; de ahí el nombre que Microsoft sigue usando: <code>fin.flf</code> para las licencias de cliente y <code>CRONUS.flf</code> para la de demostración. Sobrevivió a Navision Attain, a todo Dynamics NAV hasta NAV 2018 y a las primeras versiones de Business Central on-premise.</p>
<p>Es un archivo binario firmado por Microsoft con el cliente, los módulos contratados (<em>granules</em>), los rangos de objetos autorizados y el número de usuarios. Hasta BC 14 (Spring 2019) se podía subir desde el entorno de desarrollo C/SIDE (<em>Herramientas &gt; Información de licencia &gt; Cargar</em>); siempre se ha podido subir también con PowerShell.</p>
<h2 id="el-relevo-bclicense">El relevo: .bclicense</h2>
<p>Con licencias cada vez más grandes, sobre todo por los rangos de objetos de las extensiones AL, el .flf empezó a dar problemas al importar. Microsoft creó el <strong>.bclicense</strong>: los mismos permisos y condiciones, en un formato preparado para licencias grandes.</p>
<p>Durante la transición, Microsoft entregaba un <strong>.zip con ambos archivos</strong> (<code>fin.flf</code> y <code>fin.bclicense</code>) con permisos idénticos. Desde la <strong>v22 (2023 release wave 1)</strong> el .flf está retirado y solo vale el .bclicense.</p>
<p>En <strong>Business Central online (SaaS)</strong> no hay archivo de licencia: se asignan licencias por usuario desde el centro de administración de Microsoft 365.</p>
<h2 id="compatibilidad-por-version">Compatibilidad por versión</h2>
<table>
<thead>
<tr>
<th>Versión</th>
<th>.flf</th>
<th>.bclicense</th>
</tr>
</thead>
<tbody><tr>
<td>Navision Financials / Attain / NAV (hasta NAV 2018)</td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td>Business Central 13 – 16</td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td>BC 17 (antes de 17.12) / BC 18 (antes de 18.7)</td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td><strong>BC 17.12+, 18.7+, 19.1+, 20 y 21</strong></td>
<td>✅</td>
<td>✅</td>
</tr>
<tr>
<td><strong>BC 22 (2023 wave 1) y posteriores</strong></td>
<td>❌ retirado</td>
<td>✅</td>
</tr>
<tr>
<td>Business Central online (SaaS)</td>
<td>—</td>
<td>—</td>
</tr>
</tbody></table>
<p><img src="https://cristianmartinez.es/assets/blog/licencias-flf-bclicense-business-central/compatibilidad.svg" alt="Diagrama de compatibilidad de los archivos .flf y .bclicense por versión de Business Central" loading="lazy" decoding="async"></p>
<p>Regla rápida para saber qué archivo te toca:</p>
<ul>
<li><strong>BC online:</strong> no necesitas archivo.</li>
<li><strong>NAV o BC 13–16:</strong> <code>.flf</code>.</li>
<li><strong>BC 17.12+, 18.7+, 19.1+, 20 o 21:</strong> cualquiera de los dos; mejor <code>.bclicense</code>.</li>
<li><strong>BC 22 o superior:</strong> solo <code>.bclicense</code>.</li>
</ul>
<h2 id="como-importarla">Cómo importarla</h2>
<p>Desde el Business Central Administration Shell, como administrador:</p>
<pre class="code-block" data-lang="powershell"><code class="language-powershell">Import-NAVServerLicense -ServerInstance BC -LicenseFile &quot;C:\licencias\fin.bclicense&quot;
Restart-NAVServerInstance -ServerInstance BC</code></pre>
<p>La licencia se sube una vez por base de datos, no por cada cliente.</p>
<h2 id="si-vas-a-migrar">Si vas a migrar</h2>
<ol>
<li>Antes de subir a la <strong>v22 o superior</strong>, pide a tu partner el <strong>.bclicense</strong>. Con solo un .flf no podrás importar la licencia.</li>
<li>Si estás en <strong>v17 o v18</strong>, actualiza al menos a la <strong>17.12</strong> o la <strong>18.7</strong> para poder usar el .bclicense.</li>
<li>Si vienes de NAV, revisa los <strong>rangos de objetos</strong> de la nueva licencia: tus extensiones AL tienen que caber en los IDs autorizados.</li>
</ol>
<h2 id="resumen">Resumen</h2>
<ul>
<li><strong>.flf</strong>: el formato histórico, de Navision a BC 21. Retirado en la v22.</li>
<li><strong>.bclicense</strong>: admitido desde 17.12, 18.7 y 19.1; el único válido desde la v22.</li>
<li><strong>SaaS</strong>: sin archivo de licencia.</li>
</ul>
<p>Fuente: <a href="https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/cside/cside-upload-license-file" target="_blank" rel="noopener noreferrer">Upload the License File – Microsoft Learn</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Cómo conectar tu web o tienda online con Business Central</title>
      <link>https://cristianmartinez.es/blog/conectar-web-tienda-online-business-central</link>
      <guid isPermaLink="true">https://cristianmartinez.es/blog/conectar-web-tienda-online-business-central</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate>
      <description>Arquitectura, qué sincronizar en cada sentido, cómo crear pedidos sin duplicados y cómo usar webhooks para integrar una web o una tienda online con Dynamics 365 Business Central.</description>
      <category>business-central</category>
      <category>integraciones</category>
      <category>api</category>
      <category>desarrollo-web</category>
      <content:encoded><![CDATA[<p>La petición suele ser la misma: que la web muestre los precios y el stock reales, que los pedidos entren solos en el ERP y que el cliente pueda ver sus facturas sin llamar a nadie. Todo eso se puede hacer con <strong>Business Central</strong>, pero hay una forma de montarlo que funciona y varias que acaban en pedidos duplicados, stock desfasado o integraciones bloqueadas por exceso de llamadas.</p>
<p>Este artículo es la versión práctica: arquitectura, qué sincronizar en cada sentido y el código de las partes delicadas.</p>
<h2 id="antes-de-programar-existe-ya-un-conector">Antes de programar: ¿existe ya un conector?</h2>
<p>Para algunas plataformas no hace falta empezar de cero:</p>
<ul>
<li><strong>Shopify</strong>: Business Central incluye un conector de Shopify publicado por Microsoft.</li>
<li><strong>WooCommerce, PrestaShop, Adobe Commerce (Magento)</strong> y otras: hay conectores de terceros en AppSource.</li>
</ul>
<p>Si tu tienda es estándar y el conector cubre tu proceso, suele ser la opción más barata. Una integración a medida tiene sentido cuando la web es propia, cuando hay reglas B2B específicas (tarifas por cliente, condiciones, aprobaciones) o cuando lo que quieres es un <strong>portal de clientes</strong> y no una tienda.</p>
<h2 id="la-arquitectura-que-funciona">La arquitectura que funciona</h2>
<pre class="code-block" data-lang="text"><code class="language-text">Navegador  →  tu backend (Node, PHP, .NET...)  →  API de Business Central
                     ↑
           webhooks de Business Central</code></pre>
<p>Tres reglas que no conviene saltarse:</p>
<ol>
<li><strong>El navegador nunca habla con Business Central.</strong> El secreto de la aplicación de Entra ID y los permisos viven en tu servidor. Si llamas desde el navegador, cualquiera puede ver tus credenciales.</li>
<li><strong>Tu backend es el que tiene el token</strong> y lo reutiliza hasta que caduca. Si no sabes cómo obtenerlo, lo explico en <a href="https://cristianmartinez.es/blog/odata-api-business-central">OData y API en Business Central</a>.</li>
<li><strong>Lo que se lee mucho, se cachea.</strong> El catálogo y los precios no cambian en cada visita: guárdalos en tu base de datos o en caché y actualízalos cuando Business Central avise de un cambio.</li>
</ol>
<h2 id="que-sincronizar-y-en-que-sentido">Qué sincronizar y en qué sentido</h2>
<table>
<thead>
<tr>
<th>Dato</th>
<th>Sentido</th>
<th>Cómo</th>
</tr>
</thead>
<tbody><tr>
<td>Artículos y descripciones</td>
<td>BC → web</td>
<td>Lectura incremental por <code>lastModifiedDateTime</code> o webhook</td>
</tr>
<tr>
<td>Precios</td>
<td>BC → web</td>
<td>El precio base está en <code>items</code>. Los precios por cliente o por cantidad viven en las listas de precios y suelen necesitar una API propia</td>
</tr>
<tr>
<td>Stock</td>
<td>BC → web</td>
<td><code>inventory</code> en <code>items</code> es el total. Si lo necesitas por almacén, API propia</td>
</tr>
<tr>
<td>Clientes</td>
<td>En los dos sentidos</td>
<td>Alta desde la web; datos maestros y condiciones desde BC</td>
</tr>
<tr>
<td>Pedidos</td>
<td>Web → BC</td>
<td><code>POST</code> a <code>salesOrders</code> con sus líneas</td>
</tr>
<tr>
<td>Estado, facturas y PDF</td>
<td>BC → web</td>
<td><code>salesOrders</code>, <code>salesInvoices</code> y su <code>pdfDocument</code></td>
</tr>
</tbody></table>
<p>La regla general: <strong>cada dato tiene un dueño</strong>. Los precios, el stock y las condiciones mandan en Business Central; la web los muestra. Si permites editarlos en los dos sitios, tarde o temprano no sabrás cuál es el bueno.</p>
<h2 id="crear-un-pedido-desde-la-web-sin-duplicados">Crear un pedido desde la web sin duplicados</h2>
<p>Primero, un pequeño cliente HTTP con reintentos, porque Business Central limita las peticiones por usuario y antes o después recibirás un <code>429</code>:</p>
<pre class="code-block" data-lang="js"><code class="language-js">async function bc(path, init = {}, retries = 3) {
  const res = await fetch(BASE_URL + path, {
    ...init,
    headers: {
      Authorization: `Bearer ${await getToken()}`,
      &#39;Content-Type&#39;: &#39;application/json&#39;,
      ...init.headers
    }
  });
  if ((res.status === 429 || res.status === 503) &amp;&amp; retries &gt; 0) {
    await new Promise(r =&gt; setTimeout(r, (4 - retries) * 2000)); // 2 s, 4 s, 6 s
    return bc(path, init, retries - 1);
  }
  if (!res.ok) throw new Error(`Business Central ${res.status}: ${await res.text()}`);
  return res.status === 204 ? null : res.json();
}</code></pre>
<p>Ahora el pedido. La API v2.0 permite crear la cabecera y las líneas en una sola llamada:</p>
<pre class="code-block" data-lang="js"><code class="language-js">async function createOrder(webOrder) {
  const ref = `WEB-${webOrder.id}`;

  // ¿Ya existe? Un reintento o un doble clic no debe crear otro pedido.
  const { value: existing } = await bc(
    `/companies(${COMPANY_ID})/salesOrders?$filter=externalDocumentNumber eq &#39;${ref}&#39;&amp;$select=id,number`
  );
  if (existing.length) return existing[0];

  return bc(`/companies(${COMPANY_ID})/salesOrders`, {
    method: &#39;POST&#39;,
    body: JSON.stringify({
      customerNumber: webOrder.customerNumber,
      externalDocumentNumber: ref,
      salesOrderLines: webOrder.lines.map(l =&gt; ({
        lineType: &#39;Item&#39;,
        lineObjectNumber: l.sku,
        quantity: l.quantity
      }))
    })
  });
}</code></pre>
<p>La clave es <strong><code>externalDocumentNumber</code></strong>: guardas ahí el número de pedido de la web y compruebas si ya existe antes de crearlo. Sin esa comprobación, un timeout seguido de un reintento te deja dos pedidos iguales en el ERP.</p>
<p>Fíjate también en lo que <strong>no</strong> se envía: el precio. Si la web manda el precio, Business Central lo acepta tal cual; si no lo manda, lo calcula con sus propias reglas. Decide quién manda (normalmente, el ERP) y sé coherente.</p>
<h2 id="enterarte-de-los-cambios-webhooks">Enterarte de los cambios: webhooks</h2>
<p>En lugar de preguntar cada cinco minutos si ha cambiado algo, puedes pedirle a Business Central que te avise. Te suscribes a una entidad:</p>
<pre class="code-block" data-lang="text"><code class="language-text">POST https://api.businesscentral.dynamics.com/v2.0/{tenant}/{entorno}/api/v2.0/subscriptions
{
  &quot;notificationUrl&quot;: &quot;https://tu-backend.com/bc/webhook&quot;,
  &quot;resource&quot;: &quot;/api/v2.0/companies({companyId})/items&quot;,
  &quot;clientState&quot;: &quot;un-secreto-compartido&quot;
}</code></pre>
<p>Antes de dar de alta la suscripción, Business Central llama a tu <code>notificationUrl</code> con un parámetro <code>validationToken</code>, y tienes que devolverlo tal cual con un <code>200</code>. Después, cada aviso llega a la misma URL:</p>
<pre class="code-block" data-lang="js"><code class="language-js">app.post(&#39;/bc/webhook&#39;, express.json(), (req, res) =&gt; {
  // Alta y renovación: devolver el token tal cual.
  if (req.query.validationToken) return res.type(&#39;text/plain&#39;).send(req.query.validationToken);

  // Avisos: responder rápido y quedarse solo con los de nuestra suscripción.
  // Un error 4xx haría que Business Central borrase la suscripción, así que se ignoran sin más.
  const notifications = (req.body.value || []).filter(n =&gt; n.clientState === process.env.BC_CLIENT_STATE);
  res.sendStatus(200);
  queue.push(...notifications); // procesar después: leer cada `resource` con la API
});</code></pre>
<p>Lo que conviene saber antes de fiarte de ellos:</p>
<ul>
<li><strong>El aviso no trae los datos</strong>: trae qué recurso ha cambiado (<code>created</code>, <code>updated</code> o <code>deleted</code>). Luego tienes que leerlo.</li>
<li><strong>No son instantáneos</strong>: Business Central espera unos 30 segundos para agrupar cambios. Si en ese tiempo cambian más de 1.000 registros, recibes un único aviso de tipo <code>collection</code> con un filtro para leerlos todos.</li>
<li><strong>Caducan a los tres días.</strong> Hay que renovarlos con un <code>PATCH</code>, que vuelve a pedir el <code>validationToken</code>. Programa la renovación a diario.</li>
<li><strong>Responde rápido y procesa después.</strong> Si tu servidor contesta <code>408</code>, <code>429</code> o un <code>5xx</code>, Business Central reintenta durante 36 horas. Con cualquier otro error, <strong>borra la suscripción</strong>.</li>
<li>Hay un máximo de <strong>200 suscripciones</strong> por entorno, y no todas las entidades admiten webhooks: artículos, clientes, pedidos y facturas sí, y también tus APIs propias si cumplen ciertas condiciones (clave de un solo campo, como <code>SystemId</code>; tabla no temporal; página y no consulta).</li>
</ul>
<p>Un buen esquema combina las dos cosas: <strong>webhooks</strong> para enterarte rápido y una <strong>sincronización incremental nocturna</strong> por <code>lastModifiedDateTime</code> para recuperar cualquier aviso que se haya perdido.</p>
<h2 id="errores-tipicos">Errores típicos</h2>
<ul>
<li><strong>Llamar a Business Central desde el navegador.</strong> Credenciales expuestas y permisos de más.</li>
<li><strong>Leerlo todo cada pocos minutos.</strong> Con 6.000 peticiones cada 5 minutos por usuario, una sincronización completa y frecuente agota el límite y bloquea al resto de integraciones.</li>
<li><strong>Pedidos duplicados</strong> por reintentos sin comprobar <code>externalDocumentNumber</code>.</li>
<li><strong>Integraciones sobre páginas de Microsoft publicadas como OData</strong>, que dejarán de funcionar en la versión 30. Usa la API v2.0 o una API propia.</li>
<li><strong>No registrar los errores.</strong> Una integración que falla en silencio es peor que una que no existe: guarda cada error con el pedido o el artículo afectado y avisa a alguien.</li>
</ul>
<h2 id="resumen">Resumen</h2>
<p>Una integración web con Business Central bien hecha tiene un backend en medio, sincroniza de forma incremental, se entera de los cambios con webhooks y crea pedidos que no se duplican. Si estás planteando una tienda, un portal B2B o quieres revisar una integración que da problemas, <a href="https://cristianmartinez.es/business-central">cuéntame tu caso</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Cómo depurar llamadas a la API de Business Central en SaaS y on-premises</title>
      <link>https://cristianmartinez.es/blog/depurar-llamadas-api-business-central</link>
      <guid isPermaLink="true">https://cristianmartinez.es/blog/depurar-llamadas-api-business-central</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate>
      <description>Del error HTTP al código AL. Cómo leer los errores de la API, aislar la llamada, usar la telemetría y adjuntar el depurador o hacer snapshot debugging en Business Central online y on-premises.</description>
      <category>business-central</category>
      <category>api</category>
      <category>integraciones</category>
      <category>al</category>
      <content:encoded><![CDATA[<p>Una integración que falla con un <code>400</code> y un mensaje críptico es de las cosas que más tiempo hacen perder en un proyecto de <strong>Business Central</strong>. La buena noticia es que casi siempre se resuelve siguiendo el mismo orden: <strong>leer bien el error, aislar la llamada, mirar la telemetría y, si hace falta, ver el código AL que se ejecuta</strong>.</p>
<p>Los cuatro pasos sirven igual para Business Central online (SaaS) y on-premises. Lo que cambia son las herramientas disponibles en cada caso, y te lo indico en cada paso.</p>
<h2 id="1-lee-bien-el-error">1. Lee bien el error</h2>
<p>El código HTTP te dice en qué capa está el problema:</p>
<table>
<thead>
<tr>
<th>Código</th>
<th>Qué suele significar</th>
</tr>
</thead>
<tbody><tr>
<td><code>400</code></td>
<td>La petición está mal formada: un campo que no existe, un tipo incorrecto, un filtro mal escrito...</td>
</tr>
<tr>
<td><code>401</code></td>
<td>Autenticación o permisos: token no válido, aplicación no registrada en Business Central o sin permisos suficientes.</td>
</tr>
<tr>
<td><code>404</code></td>
<td>El endpoint no existe: entorno, empresa o entidad mal escritos, o la extensión que publica esa API no está instalada.</td>
</tr>
<tr>
<td><code>405</code></td>
<td>Método no permitido (por ejemplo, un <code>POST</code> contra una consulta, que es de solo lectura). En on-premises, también si ese tipo de servicio está desactivado.</td>
</tr>
<tr>
<td><code>409</code></td>
<td>Otro usuario ha cambiado el registro entre tu lectura y tu escritura.</td>
</tr>
<tr>
<td><code>429</code>, <code>502</code>, <code>503</code></td>
<td>Demasiadas peticiones o servicio saturado. Reintenta con espera; con <code>502</code> y <code>503</code> el servidor envía una cabecera <code>retry-after</code>.</td>
</tr>
</tbody></table>
<p>Después, mira el <strong>cuerpo de la respuesta</strong>. Business Central devuelve un código de error cuyo prefijo, casi siempre, indica la categoría:</p>
<pre class="code-block" data-lang="json"><code class="language-json">{
  &quot;error&quot;: {
    &quot;code&quot;: &quot;Application_FieldValidationException&quot;,
    &quot;message&quot;: &quot;Customer Posting Group must have a value in Customer: No.=C00690. It cannot be zero or empty.&quot;
  }
}</code></pre>
<table>
<thead>
<tr>
<th>Prefijo</th>
<th>Qué ha pasado</th>
</tr>
</thead>
<tbody><tr>
<td><code>BadRequest_*</code></td>
<td>Error al construir la petición: URL, filtro, cuerpo o cabeceras.</td>
</tr>
<tr>
<td><code>Authentication_*</code></td>
<td>No ha podido autenticarse con esas credenciales.</td>
</tr>
<tr>
<td><code>Authorization_*</code></td>
<td>Se ha autenticado, pero no tiene permisos para eso.</td>
</tr>
<tr>
<td><code>Application_*</code></td>
<td>Un error de la <strong>lógica de negocio</strong>: el mismo que vería un usuario en pantalla.</td>
</tr>
<tr>
<td><code>Internal_*</code></td>
<td>Error interno o de integridad de datos en el servidor.</td>
</tr>
</tbody></table>
<p>Hay tres que conviene reconocer a la primera:</p>
<ul>
<li><strong><code>Application_DialogException</code></strong> o <strong><code>Application_FieldValidationException</code></strong>: es un <code>Error()</code> o un <code>TestField</code> del código AL. Si haces lo mismo desde la pantalla, verás el mismo mensaje. El problema está en los datos, no en la integración.</li>
<li><strong><code>BadRequest_InvalidToken</code></strong> (<em>Could not validate the client concurrency token</em>): falta la cabecera <code>If-Match</code> en un <code>PATCH</code> o <code>DELETE</code>, o la etag que envías no es válida.</li>
<li><strong><code>Request_EntityChanged</code></strong>: alguien modificó el registro entre tu lectura y tu escritura. Vuelve a leerlo y repite el cambio.</li>
</ul>
<h2 id="2-aisla-la-llamada">2. Aísla la llamada</h2>
<p>Antes de tocar código, reproduce la llamada fuera de tu integración, con un cliente REST como Bruno, Insomnia, la extensión REST Client de Visual Studio Code o simplemente <code>curl</code>. <strong>Mismo token, misma URL y mismo cuerpo</strong>:</p>
<pre class="code-block" data-lang="bash"><code class="language-bash">curl -s &quot;https://api.businesscentral.dynamics.com/v2.0/$TENANT/sandbox/api/v2.0/companies($COMPANY_ID)/salesOrders?\$top=1&quot; \
  -H &quot;Authorization: Bearer $TOKEN&quot; \
  -H &quot;client-request-id: $(uuidgen)&quot; \
  -H &quot;User-Agent: mi-integracion/1.4&quot;</code></pre>
<p>Tres comprobaciones que resuelven buena parte de los <code>401</code> y <code>404</code>:</p>
<ul>
<li><strong>Decodifica el token</strong> (por ejemplo, en <a href="https://jwt.ms" target="_blank" rel="noopener noreferrer">jwt.ms</a>, el decodificador de Microsoft). <code>aud</code> tiene que ser <code>https://api.businesscentral.dynamics.com</code>, <code>tid</code> el tenant correcto y, en un token de aplicación, <code>roles</code> debe incluir <code>API.ReadWrite.All</code>. Mira también <code>exp</code>: un token caducado da <code>401</code>.</li>
<li><strong>Consulta el <code>$metadata</code></strong> del endpoint (<code>.../api/v2.0/$metadata</code>, o <code>.../api/{publisher}/{grupo}/v1.0/$metadata</code> en una API propia). Ahí están los nombres reales de entidades y campos, y si tu API propia no aparece, la extensión no está instalada en ese entorno.</li>
<li><strong>Envía siempre <code>client-request-id</code></strong> con un GUID, y un <code>User-Agent</code> que identifique tu integración. Las dos cabeceras se guardan en la telemetría, y con el GUID encontrarás tu llamada exacta en el paso siguiente.</li>
</ul>
<h2 id="3-telemetria-lo-que-vio-el-servidor">3. Telemetría: lo que vio el servidor</h2>
<p>Si el entorno envía telemetría a <strong>Azure Application Insights</strong>, cada llamada que recibe Business Central queda registrada con el evento <strong><code>RT0008</code></strong>: endpoint, método, código HTTP, tiempo de ejecución, objeto AL y extensión que la atendieron y, desde la versión 22, el <strong>motivo del error</strong> (<code>failureReason</code>) tal como lo vio el servidor.</p>
<p>Cómo se activa:</p>
<ul>
<li><strong>SaaS</strong>: el administrador añade la cadena de conexión de Application Insights al entorno desde el <strong>Business Central Admin Center</strong>. Si publicas extensiones, también puedes ponerla en el <code>app.json</code> (<code>applicationInsightsConnectionString</code>) y recibir la telemetría de tus objetos.</li>
<li><strong>On-premises</strong>: la misma cadena de conexión se configura en la instancia del servidor (<code>ApplicationInsightsConnectionString</code>).</li>
</ul>
<p>Esta consulta KQL lista las llamadas fallidas del último día con lo necesario para entender qué ha pasado:</p>
<pre class="code-block" data-lang="kql"><code class="language-kql">traces
| where timestamp &gt; ago(1d)
| where customDimensions has &quot;RT0008&quot;
| where customDimensions.eventId == &quot;RT0008&quot;
| where toint(customDimensions.httpStatusCode) &gt;= 400
| project timestamp
, endpoint = tostring(customDimensions.endpoint)
, httpMethod = tostring(customDimensions.httpMethod)
, httpStatusCode = tostring(customDimensions.httpStatusCode)
, failureReason = tostring(customDimensions.failureReason)
, alObjectName = tostring(customDimensions.alObjectName)
, extensionName = tostring(customDimensions.extensionName)
, executionTimeMs = toreal(totimespan(customDimensions.serverExecutionTime)) / 10000
| order by timestamp desc</code></pre>
<p>Para encontrar una llamada concreta, filtra por el GUID que enviaste añadiendo <code>| where tostring(customDimensions.httpHeaders) has &quot;tu-guid&quot;</code> <strong>antes</strong> del <code>project</code> (después, <code>customDimensions</code> ya no existe).</p>
<p>Otras dimensiones que ayudan:</p>
<ul>
<li><strong><code>requestQueueTime</code></strong>: cuánto esperó la petición en cola antes de ejecutarse. Si sube, estás rozando los límites de peticiones simultáneas.</li>
<li><strong><code>sqlExecutes</code></strong> y <strong><code>sqlRowsRead</code></strong>: si una llamada lee cientos de miles de filas, falta un filtro o sobra un campo calculado.</li>
<li><strong><code>extensionPublisher</code></strong>: te dice de quién es el código que atendió la llamada. Muy útil cuando el error viene de una app de terceros.</li>
</ul>
<p>Ten en cuenta que la telemetría llega a Application Insights con unos minutos de retraso, y que las llamadas SOAP no registran ni el código HTTP ni el motivo del error.</p>
<h2 id="4-el-depurador-ver-el-codigo-al-que-se-ejecuta">4. El depurador: ver el código AL que se ejecuta</h2>
<p>Cuando el error viene de la lógica AL (los <code>Application_*</code>), lo más rápido es ver el código mientras atiende la llamada. Aquí es donde más cambia la cosa entre entornos.</p>
<h3 id="sandbox-online-adjuntar-a-la-siguiente-llamada">Sandbox online: adjuntar a la siguiente llamada</h3>
<p>En un sandbox puedes <strong>adjuntar el depurador de Visual Studio Code</strong> y decirle que se detenga en la siguiente sesión de servicio web:</p>
<pre class="code-block" data-lang="json"><code class="language-json">{
  &quot;name&quot;: &quot;Attach: siguiente llamada web service (sandbox)&quot;,
  &quot;type&quot;: &quot;al&quot;,
  &quot;request&quot;: &quot;attach&quot;,
  &quot;environmentType&quot;: &quot;Sandbox&quot;,
  &quot;environmentName&quot;: &quot;sandbox&quot;,
  &quot;breakOnNext&quot;: &quot;WebServiceClient&quot;,
  &quot;breakOnError&quot;: true
}</code></pre>
<ol>
<li>Publica la versión actual de la extensión (<code>Ctrl+F5</code>) para que el código local coincida con el del servidor.</li>
<li>Pon los puntos de interrupción y pulsa <code>F5</code> para adjuntarte.</li>
<li>Lanza la llamada desde tu cliente REST. El depurador se detiene en tus puntos de interrupción y, con <code>breakOnError</code>, también en la línea que lanza el error.</li>
</ol>
<p>Por defecto, <strong>solo puedes depurar las llamadas que haga tu propio usuario</strong>, así que lanza la llamada con un token de tu usuario. Si la que falla es la de una aplicación de Entra (servidor a servidor), añade <code>&quot;userId&quot;</code> con el id del usuario que Business Central crea para esa aplicación (lo encuentras en la tabla de usuarios). Para depurar sesiones de otro usuario, el tuyo necesita el conjunto de permisos <strong>D365 ATTACH DEBUG</strong>.</p>
<h3 id="produccion-online-snapshot-debugging">Producción online: snapshot debugging</h3>
<p>En producción no puedes adjuntar el depurador, pero sí <strong>grabar una ejecución</strong> y depurarla después sin conexión. Es el <em>snapshot debugging</em>:</p>
<pre class="code-block" data-lang="json"><code class="language-json">{
  &quot;name&quot;: &quot;Snapshot: siguiente llamada web service (producción)&quot;,
  &quot;type&quot;: &quot;al&quot;,
  &quot;request&quot;: &quot;snapshotInitialize&quot;,
  &quot;environmentType&quot;: &quot;Production&quot;,
  &quot;environmentName&quot;: &quot;production&quot;,
  &quot;breakOnNext&quot;: &quot;WebServiceClient&quot;,
  &quot;userId&quot;: &quot;id-del-usuario-de-la-aplicacion&quot;
}</code></pre>
<ol>
<li>Marca los <em>snappoints</em>: se colocan igual que los puntos de interrupción, pero no detienen nada. Guardan el estado de las variables en ese punto. Los errores AL se graban siempre, aunque no pongas ninguno.</li>
<li>Pulsa <code>F7</code> para iniciar la grabación y reproduce la llamada que falla.</li>
<li>Pulsa <code>Alt+F7</code> para terminar. Visual Studio Code descarga el fichero <code>.snapshot</code>, que puedes depurar paso a paso como si fuera una sesión normal.</li>
</ol>
<p>Lo que conviene saber:</p>
<ul>
<li>Tu usuario necesita el conjunto de permisos <strong>D365 Snapshot Debug</strong>.</li>
<li>Los <strong>símbolos tienen que coincidir</strong> con los de producción. Si no, los puntos caen en líneas que no son.</li>
<li>Si nadie hace la llamada en <strong>30 minutos</strong>, la sesión se cierra; una vez empieza a grabar, hay que terminarla en <strong>10 minutos</strong>.</li>
<li>El snapshot puede contener <strong>datos personales</strong> de clientes. Trátalo como tal y bórralo cuando acabes.</li>
</ul>
<p>Desde la versión 29 existe también, en preview, un servidor MCP de snapshot debugging para que un agente de IA prepare la grabación y la recoja por ti. El mecanismo de fondo es el mismo.</p>
<h3 id="on-premises-adjuntar-a-tu-servidor">On-premises: adjuntar a tu servidor</h3>
<p>En on-premises tienes las dos opciones (adjuntar y snapshot) contra tu propia instancia. La configuración cambia el entorno por el servidor:</p>
<pre class="code-block" data-lang="json"><code class="language-json">{
  &quot;name&quot;: &quot;Attach: siguiente llamada web service (on-premises)&quot;,
  &quot;type&quot;: &quot;al&quot;,
  &quot;request&quot;: &quot;attach&quot;,
  &quot;server&quot;: &quot;https://bc-server&quot;,
  &quot;serverInstance&quot;: &quot;BC&quot;,
  &quot;port&quot;: 7049,
  &quot;authentication&quot;: &quot;UserPassword&quot;,
  &quot;breakOnNext&quot;: &quot;WebServiceClient&quot;,
  &quot;breakOnError&quot;: true
}</code></pre>
<p>El puerto es el del endpoint de desarrollo de la instancia (7049 por defecto), que tiene que estar habilitado. Si el servidor usa autenticación de Windows, no puedes indicar <code>userId</code>: solo adjuntarte a la siguiente sesión o a una sesión concreta.</p>
<h3 id="cuando-el-depurador-no-se-detiene">Cuando el depurador no se detiene</h3>
<p>Hace poco me tocó depurar con un compañero una integración <em>intercompany</em>: al crear líneas en un pedido de venta de una empresa, Business Central llamaba a su propio servicio web para modificar el pedido de compra de otra empresa del mismo entorno. El servicio se ejecutaba (las líneas aparecían en la otra empresa, pero mal), y el depurador no se detenía en ningún punto. Estos fueron los problemas, por orden:</p>
<ul>
<li><strong><code>breakOnNext</code> tiene que ser <code>WebServiceClient</code>.</strong> Con <code>WebClient</code> te adjuntas a la siguiente sesión de la interfaz web, y con <code>Background</code> a una sesión en segundo plano. Si el valor se ha cambiado sin querer, el depurador se queda esperando una sesión que nunca llega. Borra el valor y pulsa <code>Ctrl+Espacio</code> para ver las opciones.</li>
<li><strong>La sesión del servicio web no es la tuya.</strong> Aunque tú lances el proceso desde la pantalla, la llamada la hace el <strong>usuario del servicio web</strong> (el de las credenciales que usa el código, a menudo guardadas en una tabla de configuración). Y por defecto solo te adjuntas a sesiones de tu propio usuario.</li>
<li><strong><code>userId</code> no existe en versiones antiguas.</strong> Adjuntarse a la sesión de otro usuario llegó en versiones recientes (hacia la 22). Aquella instalación era una 18, así que la propiedad no hacía nada. La solución fue <strong>autenticarse en Visual Studio Code con el usuario del servicio web</strong>: ejecutar <em>AL: Clear credentials cache</em>, pulsar <code>F5</code> e introducir sus credenciales. A partir de ahí, la siguiente llamada se detuvo en el punto de interrupción.</li>
<li><strong>Adjuntarse no publica.</strong> <code>F5</code> con una configuración <code>attach</code> solo se engancha al servidor; no despliega tu código. Si lo que hay publicado no coincide con tu código local, los puntos de interrupción caen en líneas que no son o no se activan.</li>
<li><strong>Sin punto de interrupción ni error, no se para nada.</strong> Pon el punto en la primera línea del método que expone el servicio, no en el código que hace la petición: ese se ejecuta en otra sesión.</li>
<li><strong><code>breakOnError: &quot;ExcludeTry&quot;</code></strong> evita que el depurador se detenga en los errores capturados por funciones <code>[TryFunction]</code>, que si no te frenan a cada paso. En versiones antiguas puede que no lo respete; en ese caso, dale a continuar.</li>
</ul>
<p>Un detalle más: en la barra del depurador, en una sesión <code>attach</code>, el botón de parar es en realidad <strong>desconectar</strong>. La sesión del usuario sigue viva.</p>
<h2 id="5-lo-que-cambia-en-on-premises">5. Lo que cambia en on-premises</h2>
<p>Además del depurador, en on-premises hay errores que en SaaS no existen, porque el servidor lo administras tú:</p>
<ul>
<li><p><strong>Los servicios tienen que estar activos.</strong> OData viene activado por defecto, pero <strong>las APIs no</strong>. Si una llamada a <code>/api/</code> devuelve <code>405</code>, empieza por aquí:</p>
<pre class="code-block" data-lang="powershell"><code class="language-powershell">Set-NAVServerConfiguration BC -KeyName ApiServicesEnabled -KeyValue true
Restart-NAVServerInstance BC</code></pre>
<p>El cambio no se aplica en caliente: hay que reiniciar la instancia.</p>
</li>
<li><p><strong>La URL es la de tu servidor</strong>: <code>https://bc-server:7048/BC/api/v2.0/companies(...)</code>, con el puerto de OData (7048 por defecto) abierto en el firewall. Si hay un proxy o un nombre público delante, revisa <code>PublicODataBaseUrl</code>: es la URL base que el servidor publica para sus servicios OData.</p>
</li>
<li><p><strong>La autenticación depende de la instalación</strong>: con NavUserPassword sigue existiendo la clave de acceso a servicios web como contraseña; con Microsoft Entra ID, el token funciona igual que en SaaS.</p>
</li>
<li><p><strong>El Visor de eventos de Windows</strong> es tu primera fuente de errores: <em>Registros de aplicaciones y servicios → Microsoft → DynamicsNAV → Server → Admin</em>. Desde PowerShell:</p>
<pre class="code-block" data-lang="powershell"><code class="language-powershell">Get-WinEvent -LogName &#39;Microsoft-DynamicsNAV-Server/Admin&#39; -MaxEvents 50 -FilterXPath &quot;*[System[(Level=2)]]&quot; |
  Format-List TimeCreated, Message</code></pre>
<p>Si necesitas más detalle, el registro <em>Debug</em> de ese mismo canal recoge llamadas a servicios, SQL y trazas AL. Viene desactivado y oculto por defecto: actívalo solo mientras investigas, porque crece muy rápido.</p>
</li>
<li><p><strong>Los límites son tuyos</strong>: el tiempo máximo de una petición, el tamaño de página y el resto de límites se configuran en la instancia, así que un <code>408</code> en on-premises puede ser una configuración demasiado estricta.</p>
</li>
</ul>
<h2 id="6-y-cuando-la-llamada-la-hace-business-central">6. Y cuando la llamada la hace Business Central</h2>
<p>A veces el problema va en el otro sentido: es tu extensión la que llama a un servicio externo con <code>HttpClient</code> y falla.</p>
<ul>
<li><p><strong>En un sandbox, las llamadas salientes están bloqueadas por defecto.</strong> Hay que permitirlas extensión a extensión: <em>Administración de extensiones → Configurar → Permitir solicitudes de HttpClient</em> (<em>Allow HttpClient Requests</em>). Incluye las extensiones de librería y, ojo, <strong>al copiar un entorno el permiso vuelve a desactivarse</strong>. Durante la ejecución de tests en un sandbox, las llamadas HTTP están bloqueadas para todas las extensiones.</p>
</li>
<li><p><strong>La telemetría registra cada llamada saliente</strong> con el evento <strong><code>RT0019</code></strong>: endpoint (solo la URL base), método, código HTTP, tiempo, la pila AL que hizo la llamada y, desde la versión 29, <code>httpRequestError</code>, que distingue un fallo de DNS, de conexión o de certificado.</p>
</li>
<li><p><strong>Comprueba siempre la respuesta en el código.</strong> Que <code>Get</code> o <code>Send</code> devuelvan <code>true</code> solo significa que hubo respuesta, no que fuera buena:</p>
<pre class="code-block" data-lang="al"><code class="language-al">procedure CallExternalService(Url: Text)
var
    Client: HttpClient;
    Response: HttpResponseMessage;
    Body: Text;
begin
    if not Client.Get(Url, Response) then
        Error(&#39;No se pudo conectar con %1: %2&#39;, Url, GetLastErrorText());
    Response.Content.ReadAs(Body);
    if not Response.IsSuccessStatusCode then
        Error(&#39;El servicio respondió %1: %2&#39;, Response.HttpStatusCode, CopyStr(Body, 1, 250));
end;</code></pre>
</li>
</ul>
<p>Con el código de estado y el principio del cuerpo en el mensaje, el error se entiende desde la pantalla, desde la telemetría y desde el depurador.</p>
<h2 id="funciona-en-sandbox-pero-no-en-produccion">Funciona en sandbox pero no en producción</h2>
<p>Es el caso más habitual, y casi siempre es uno de estos:</p>
<ul>
<li>La aplicación de Entra <strong>no está dada de alta en la página <em>Aplicaciones de Microsoft Entra</em> de producción</strong>, o tiene otros conjuntos de permisos. Cada entorno tiene la suya.</li>
<li>La <strong>URL apunta a otro entorno</strong> o usa un id de empresa que en producción es distinto.</li>
<li>La <strong>extensión que publica la API propia</strong> no está instalada en producción, o está en otra versión (de ahí los <code>404</code>).</li>
<li>En el sentido contrario (funciona en producción y no en el sandbox), casi siempre es el permiso de <strong>HttpClient</strong> del apartado anterior.</li>
</ul>
<h2 id="resumen">Resumen</h2>
<ol>
<li><strong>Código HTTP y código de error</strong>: te dicen en qué capa mirar.</li>
<li><strong>Reproduce la llamada aislada</strong> y revisa el token y el <code>$metadata</code>.</li>
<li><strong>Telemetría</strong>: <code>RT0008</code> para lo que entra y <code>RT0019</code> para lo que sale. Envía siempre <code>client-request-id</code>.</li>
<li><strong>Depurador</strong>: adjuntar en sandbox y on-premises, snapshot en producción.</li>
</ol>
<p>Si quieres repasar cómo autenticarte y construir las llamadas, lo tienes en <a href="https://cristianmartinez.es/blog/odata-api-business-central">OData y API en Business Central</a>. Y si tienes una integración que falla y no hay manera de ver por qué, <a href="https://cristianmartinez.es/business-central">cuéntamelo</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Extensiones AL en Business Central: qué son y cómo se hacen bien</title>
      <link>https://cristianmartinez.es/blog/extensiones-al-business-central</link>
      <guid isPermaLink="true">https://cristianmartinez.es/blog/extensiones-al-business-central</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate>
      <description>Qué es una extensión de Business Central, cómo se engancha al estándar con eventos, PTE o AppSource, y qué hace que sobreviva a las actualizaciones de Microsoft.</description>
      <category>business-central</category>
      <category>al</category>
      <category>extensiones</category>
      <content:encoded><![CDATA[<p>En Navision, personalizar significaba <strong>modificar el código de Microsoft</strong>. Funcionaba, pero cada actualización obligaba a revisar a mano todas esas modificaciones, y por eso tantas empresas se quedaban años en la misma versión.</p>
<p>En <strong>Business Central</strong> eso ya no se hace (en online, directamente no se puede). Todo lo que añades va en una <strong>extensión</strong>: un paquete aparte que se engancha al estándar sin tocarlo. Es la diferencia entre actualizar con miedo y actualizar dos veces al año sin que nadie se entere.</p>
<h2 id="que-es-una-extension">Qué es una extensión</h2>
<p>Una extensión es un proyecto en <strong>AL</strong> (el lenguaje de Business Central) que se compila en un fichero <code>.app</code>. Dentro puede haber:</p>
<ul>
<li><strong>Objetos nuevos</strong>: tablas, páginas, codeunits (lógica), informes, consultas, enums, APIs...</li>
<li><strong>Extensiones de objetos estándar</strong>: <code>tableextension</code> para añadir campos a una tabla de Microsoft, <code>pageextension</code> para mostrarlos en sus pantallas, <code>reportextension</code> para ampliar sus informes.</li>
<li><strong>Suscriptores de eventos</strong>: código que se ejecuta cuando el estándar &quot;avisa&quot; de que algo pasa.</li>
<li><strong>Conjuntos de permisos</strong> para que cada usuario vea solo lo que debe.</li>
</ul>
<p>Las extensiones a medida usan el rango de identificadores <strong>50.000 a 99.999</strong>. Se instalan, actualizan y desinstalan por separado del estándar.</p>
<h2 id="como-se-engancha-al-estandar-un-ejemplo">Cómo se engancha al estándar: un ejemplo</h2>
<p>Un caso típico: cada cliente tiene unas <strong>instrucciones de entrega</strong> y quieres que pasen solas a sus pedidos. Primero, el campo en el cliente y en la cabecera de venta:</p>
<pre class="code-block" data-lang="al"><code class="language-al">tableextension 50100 &quot;CM Customer&quot; extends Customer
{
    fields
    {
        field(50100; &quot;CM Delivery Instructions&quot;; Text[250])
        {
            Caption = &#39;Instrucciones de entrega&#39;;
            DataClassification = CustomerContent;
        }
    }
}

tableextension 50101 &quot;CM Sales Header&quot; extends &quot;Sales Header&quot;
{
    fields
    {
        field(50100; &quot;CM Delivery Instructions&quot;; Text[250])
        {
            Caption = &#39;Instrucciones de entrega&#39;;
            DataClassification = CustomerContent;
        }
    }
}</code></pre>
<p>Después, mostrarlo en la ficha del cliente (y del mismo modo en el pedido):</p>
<pre class="code-block" data-lang="al"><code class="language-al">pageextension 50100 &quot;CM Customer Card&quot; extends &quot;Customer Card&quot;
{
    layout
    {
        addlast(Shipping)
        {
            field(&quot;CM Delivery Instructions&quot;; Rec.&quot;CM Delivery Instructions&quot;)
            {
                ApplicationArea = All;
                ToolTip = &#39;Instrucciones que se copian a los pedidos de este cliente.&#39;;
            }
        }
    }
}</code></pre>
<p>Y por último, la lógica. En lugar de modificar el código que rellena el pedido, nos <strong>suscribimos al evento</strong> que se dispara cuando se elige el cliente:</p>
<pre class="code-block" data-lang="al"><code class="language-al">codeunit 50100 &quot;CM Sales Subscribers&quot;
{
    [EventSubscriber(ObjectType::Table, Database::&quot;Sales Header&quot;, &#39;OnAfterValidateEvent&#39;, &#39;Sell-to Customer No.&#39;, false, false)]
    local procedure CopyDeliveryInstructions(var Rec: Record &quot;Sales Header&quot;; var xRec: Record &quot;Sales Header&quot;; CurrFieldNo: Integer)
    var
        Customer: Record Customer;
    begin
        if not Customer.Get(Rec.&quot;Sell-to Customer No.&quot;) then
            exit;
        Rec.&quot;CM Delivery Instructions&quot; := Customer.&quot;CM Delivery Instructions&quot;;
    end;
}</code></pre>
<p>Cuatro objetos, cero líneas de Microsoft modificadas. Cuando llegue la siguiente versión, el estándar se actualiza por debajo y la extensión sigue enganchada al mismo evento.</p>
<p>Un detalle: el prefijo <code>CM</code> en nombres y campos. Evita choques con otras extensiones que añadan un campo con el mismo nombre a la misma tabla, y es una buena práctica que conviene seguir desde el primer objeto. En las apps que se publican en el marketplace de Microsoft, además, es obligatorio: el prefijo se registra y tiene al menos tres caracteres.</p>
<h2 id="extension-a-medida-o-app-de-appsource">Extensión a medida o app de AppSource</h2>
<p>Hay dos tipos de extensión:</p>
<ul>
<li><strong>PTE (per-tenant extension)</strong>: se hace para una empresa concreta y solo se instala en su Business Central. Es lo que se usa para adaptar el ERP a un proceso propio.</li>
<li><strong>App de AppSource</strong>: un producto publicado en el marketplace de Microsoft (AppSource, hoy integrado en Microsoft Marketplace), que pasa su validación técnica y cualquiera puede instalar.</li>
</ul>
<p>Antes de desarrollar una PTE, merece la pena buscar en AppSource: si ya hay una app que resuelve el caso (conectores de bancos, de transportistas, EDI, captura de documentos...), suele salir más a cuenta. Muchas veces la solución es mixta: una app de AppSource y una extensión pequeña que la adapta a tu forma de trabajar.</p>
<h2 id="lo-que-hace-que-una-extension-sobreviva-a-las-actualizaciones">Lo que hace que una extensión sobreviva a las actualizaciones</h2>
<p>Microsoft publica <strong>dos versiones mayores al año</strong> (en primavera y en otoño) y <strong>actualizaciones menores cada mes</strong>. Que una extensión las aguante sin problemas no es suerte:</p>
<ul>
<li><strong>Usa eventos y procedimientos públicos</strong>, no copias del código estándar. Si copias una función de Microsoft para cambiarle una línea, te quedas con una versión que ya no se actualiza.</li>
<li><strong>Atiende a los avisos de obsoleto.</strong> Antes de retirar algo, Microsoft lo marca como <code>ObsoleteState = Pending</code> y lo mantiene así alrededor de un año. El compilador te avisa: esas advertencias son tareas pendientes, no ruido.</li>
<li><strong>Prueba en un sandbox con la versión preview.</strong> Microsoft publica la siguiente versión mayor en preview antes de lanzarla. Compilar y probar ahí la extensión te dice con margen si algo va a romperse.</li>
<li><strong>Ten tests automatizados</strong> de los procesos críticos. AL tiene codeunits de test (<code>Subtype = Test</code>) que puedes ejecutar en cada compilación.</li>
<li><strong>Control de versiones y compilación automática.</strong> El código en Git y un pipeline que compila y prueba cada cambio. Microsoft mantiene plantillas para esto (AL-Go for GitHub).</li>
</ul>
<h2 id="como-se-publica">Cómo se publica</h2>
<p>En <strong>Business Central online</strong>, las extensiones a medida se instalan desde el <strong>Business Central Admin Center</strong> (o su API), donde además puedes programar cuándo se actualizan. Desde la versión 28, subirlas desde la página <em>Administración de extensiones</em> dentro del propio Business Central se está retirando: en 2027 dejará de estar disponible para eso.</p>
<p>En <strong>on-premises</strong>, se publican con las herramientas de administración del servidor (PowerShell).</p>
<p>En los dos casos, el camino es el mismo: primero al <strong>sandbox</strong>, se prueba, y después a <strong>producción</strong>.</p>
<h2 id="resumen">Resumen</h2>
<ul>
<li>En Business Central no se modifica el estándar: se <strong>extiende</strong>.</li>
<li>Una extensión bien hecha usa <strong>eventos</strong>, prefijos propios y el rango de identificadores reservado.</li>
<li>Antes de programar, busca en <strong>AppSource</strong>.</li>
<li>La compatibilidad con las actualizaciones se trabaja: <strong>avisos de obsoleto, sandbox con preview y tests</strong>.</li>
</ul>
<p>Si tienes un proceso que el estándar no cubre o una personalización de Navision que hay que pasar a extensión, te cuento <a href="https://cristianmartinez.es/business-central/extensiones">cómo trabajo el desarrollo de extensiones</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>OData y API en Business Central: cuál usar y cómo conectarte</title>
      <link>https://cristianmartinez.es/blog/odata-api-business-central</link>
      <guid isPermaLink="true">https://cristianmartinez.es/blog/odata-api-business-central</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate>
      <description>API v2.0, APIs propias o servicios web OData. Cuándo usar cada uno en Business Central, cómo autenticarte con OAuth y qué cambia cuando Microsoft retire OData en sus páginas.</description>
      <category>business-central</category>
      <category>api</category>
      <category>odata</category>
      <category>integraciones</category>
      <content:encoded><![CDATA[<p>Si vas a conectar una web, un CRM o un informe con <strong>Business Central</strong>, la primera decisión es por qué puerta entrar. Hay tres, las tres hablan OData v4 y se parecen mucho, pero no son intercambiables: una de ellas, además, tiene fecha de caducidad para buena parte de sus usos.</p>
<p>En este artículo te explico las tres, cómo autenticarte y las consultas que vas a usar a diario.</p>
<h2 id="las-tres-formas-de-leer-y-escribir-datos">Las tres formas de leer y escribir datos</h2>
<table>
<thead>
<tr>
<th>Puerta</th>
<th>Qué es</th>
<th>Cuándo usarla</th>
</tr>
</thead>
<tbody><tr>
<td><strong>API estándar v2.0</strong></td>
<td>Endpoints que publica Microsoft para clientes, artículos, pedidos, facturas...</td>
<td>Siempre que cubra lo que necesitas: es estable y está versionada.</td>
</tr>
<tr>
<td><strong>APIs propias</strong></td>
<td>Páginas o consultas de tipo <code>API</code> que programas en una extensión AL</td>
<td>Cuando necesitas campos o tablas propias, o datos que la API estándar no expone.</td>
</tr>
<tr>
<td><strong>Servicios web OData</strong></td>
<td>Páginas y consultas publicadas desde la página <em>Servicios web</em></td>
<td>Casos puntuales. Para páginas de Microsoft, con los días contados (lo vemos abajo).</td>
</tr>
</tbody></table>
<p>La diferencia de fondo: una <strong>API</strong> es un contrato. Microsoft no cambia la API v2.0 sin versionarla, y tus APIs propias solo cambian cuando tú quieres. Una <strong>página publicada como servicio web</strong> es una pantalla, y las pantallas cambian en cada versión sin que eso se considere un cambio que rompa nada.</p>
<h2 id="las-urls">Las URLs</h2>
<p>Todas cuelgan del mismo dominio. Necesitas el <strong>id del tenant</strong> (o su dominio), el <strong>nombre del entorno</strong> (<code>production</code>, <code>sandbox</code>...) y la empresa:</p>
<pre class="code-block" data-lang="text"><code class="language-text"># API estándar v2.0
https://api.businesscentral.dynamics.com/v2.0/{tenant}/{entorno}/api/v2.0/companies({companyId})/customers

# API propia: publisher, grupo y versión de tu página API
https://api.businesscentral.dynamics.com/v2.0/{tenant}/{entorno}/api/{publisher}/{grupo}/v1.0/companies({companyId})/{entitySetName}

# Servicio web OData publicado
https://api.businesscentral.dynamics.com/v2.0/{tenant}/{entorno}/ODataV4/Company(&#39;{nombre}&#39;)/{nombreServicio}</code></pre>
<p>El <code>companyId</code> es un GUID. Lo obtienes con una primera llamada a <code>.../api/v2.0/companies</code>, que devuelve todas las empresas del entorno con su <code>id</code>.</p>
<h2 id="autenticacion-oauth-2-0-y-nada-mas">Autenticación: OAuth 2.0 y nada más</h2>
<p>Desde el <strong>1 de octubre de 2022</strong>, Business Central online no acepta las claves de acceso a servicios web (la antigua autenticación básica con usuario y clave). La única opción es <strong>OAuth 2.0</strong> con Microsoft Entra ID.</p>
<p>Para una integración de servidor a servidor (tu backend hablando con Business Central, sin usuario delante), el flujo es <em>client credentials</em>:</p>
<ol>
<li><strong>Registra una aplicación</strong> en Microsoft Entra ID (portal de Azure) y créale un secreto o, mejor, un certificado.</li>
<li>En <strong>Permisos de API</strong>, añade <em>Dynamics 365 Business Central</em> con el permiso de aplicación <code>API.ReadWrite.All</code> y concede el consentimiento de administrador.</li>
<li>En Business Central, abre la página <strong>Aplicaciones de Microsoft Entra</strong>, da de alta el id de cliente de la aplicación y asígnale <strong>solo los conjuntos de permisos que necesite</strong> (Business Central ni siquiera permite asignar <code>SUPER</code> a una aplicación).</li>
<li>Pide el token y úsalo en cada llamada:</li>
</ol>
<pre class="code-block" data-lang="js"><code class="language-js">const res = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
  method: &#39;POST&#39;,
  body: new URLSearchParams({
    grant_type: &#39;client_credentials&#39;,
    client_id: clientId,
    client_secret: clientSecret,
    scope: &#39;https://api.businesscentral.dynamics.com/.default&#39;
  })
});
const { access_token, expires_in } = await res.json();

const customers = await fetch(`${baseUrl}/companies(${companyId})/customers`, {
  headers: { Authorization: `Bearer ${access_token}` }
}).then(r =&gt; r.json());</code></pre>
<p>Guarda el token en caché hasta que caduque (<code>expires_in</code>, en segundos) en lugar de pedir uno nuevo en cada llamada. Y todo esto vive <strong>en tu servidor</strong>: el secreto nunca debe llegar al navegador.</p>
<h2 id="consultas-que-vas-a-usar-a-diario">Consultas que vas a usar a diario</h2>
<p>La API habla OData v4, así que filtras, eliges campos y expandes relaciones con parámetros en la URL:</p>
<pre class="code-block" data-lang="text"><code class="language-text">GET .../companies({companyId})/salesOrders
    ?$filter=status eq &#39;Open&#39; and orderDate ge 2026-01-01
    &amp;$select=number,customerName,orderDate,totalAmountIncludingTax
    &amp;$expand=salesOrderLines
    &amp;$orderby=orderDate desc
    &amp;$top=50</code></pre>
<ul>
<li><strong><code>$filter</code></strong>: filtra en el servidor. No te traigas 20.000 registros para quedarte con 10.</li>
<li><strong><code>$select</code></strong>: pide solo los campos que vas a usar. Las respuestas pesan menos y llegan antes.</li>
<li><strong><code>$expand</code></strong>: trae las líneas con la cabecera en una sola llamada.</li>
<li><strong>Paginación</strong>: si hay más resultados, la respuesta incluye <code>@odata.nextLink</code>. Síguelo hasta que no aparezca.</li>
<li><strong>Sincronización incremental</strong>: casi todas las entidades tienen <code>lastModifiedDateTime</code>. Filtra por la fecha de tu última sincronización (<code>$filter=lastModifiedDateTime gt 2026-10-01T00:00:00Z</code>) en lugar de leerlo todo cada vez.</li>
<li><strong>Ojo con <code>status</code></strong>: los valores de la API no coinciden con los de la pantalla. Un pedido <em>Abierto</em> en Business Central sale como <code>Draft</code>, y <code>Open</code> es un pedido ya <em>Lanzado</em> (o pendiente de prepago). El filtro del ejemplo trae, por tanto, los pedidos lanzados.</li>
</ul>
<p>Para modificar o borrar (<code>PATCH</code>, <code>DELETE</code>), Business Central exige la cabecera <strong><code>If-Match</code></strong> con el <code>@odata.etag</code> del registro que leíste. Es control de concurrencia: si alguien lo ha cambiado entretanto, la llamada falla en lugar de pisar su cambio.</p>
<h2 id="limites-el-429-te-va-a-llegar">Límites: el 429 te va a llegar</h2>
<p>Business Central online limita las peticiones <strong>por usuario</strong> (y una aplicación de Entra cuenta como un usuario más):</p>
<table>
<thead>
<tr>
<th>Límite</th>
<th>Valor</th>
<th>Si lo superas</th>
</tr>
</thead>
<tbody><tr>
<td>Peticiones en una ventana de 5 minutos</td>
<td>6.000</td>
<td><code>429 Too Many Requests</code></td>
</tr>
<tr>
<td>Peticiones procesándose a la vez</td>
<td>5 (el resto espera en cola)</td>
<td><code>503</code> si espera más de 8 minutos</td>
</tr>
<tr>
<td>Peticiones simultáneas, en proceso y en cola</td>
<td>100</td>
<td><code>429</code></td>
</tr>
<tr>
<td>Registros por respuesta</td>
<td>20.000</td>
<td><code>413</code></td>
</tr>
<tr>
<td>Operaciones en un <code>$batch</code></td>
<td>100</td>
<td>-</td>
</tr>
<tr>
<td>Duración de una petición</td>
<td>8 minutos</td>
<td><code>408</code></td>
</tr>
</tbody></table>
<p>En la práctica: implementa <strong>reintentos con espera creciente</strong> para <code>429</code> y <code>503</code>, usa <code>$batch</code> cuando tengas que mandar muchas operaciones pequeñas y no hagas lecturas completas cada pocos minutos. Si una integración es muy intensiva, Microsoft recomienda repartir la carga entre varios usuarios o aplicaciones.</p>
<h2 id="lo-que-cambia-en-2026-y-2027">Lo que cambia en 2026 y 2027</h2>
<p>Esto es lo que más integraciones va a romper en los próximos meses:</p>
<ul>
<li><strong>SOAP en páginas de Microsoft</strong>: desde la versión 29 (2026 release wave 2, la actual) ya no se pueden publicar páginas de Microsoft como servicio SOAP.</li>
<li><strong>OData en páginas de Microsoft</strong>: a partir de la <strong>versión 30 (2027 release wave 1, primavera de 2027)</strong> tampoco se podrán publicar como servicio OData. Si tu integración lee, por ejemplo, la página <em>Ficha cliente</em> publicada en <em>Servicios web</em>, dejará de funcionar.</li>
<li><strong>API v1.0</strong>: se retiró en la versión 28. Si algo sigue llamando a <code>/api/v1.0/</code>, ya está roto o lo estará al actualizar.</li>
<li><strong>Delta links</strong>: retirados desde la versión 24. Para saber qué ha cambiado, usa webhooks o <code>lastModifiedDateTime</code>.</li>
</ul>
<p>Microsoft lo justifica así: una página es una pantalla, no una API, y cambiarla no se considera un cambio incompatible. Las alternativas son pasar a la <strong>API v2.0</strong>, o <strong>copiar la página a una extensión propia</strong> o, mejor, crear una <strong>API propia</strong>.</p>
<h2 id="una-api-propia-en-al">Una API propia en AL</h2>
<p>Cuando la API estándar no trae lo que necesitas, una página de tipo <code>API</code> en una extensión es la solución limpia. Este ejemplo expone el stock y el precio de los artículos para una tienda online:</p>
<pre class="code-block" data-lang="al"><code class="language-al">page 50100 &quot;CM Item Stock API&quot;
{
    PageType = API;
    APIPublisher = &#39;cristianmartinez&#39;;
    APIGroup = &#39;shop&#39;;
    APIVersion = &#39;v1.0&#39;;
    EntityName = &#39;itemStock&#39;;
    EntitySetName = &#39;itemStocks&#39;;
    SourceTable = Item;
    ODataKeyFields = SystemId;
    Editable = false;
    DataAccessIntent = ReadOnly;

    layout
    {
        area(Content)
        {
            repeater(Records)
            {
                field(id; Rec.SystemId) { }
                field(number; Rec.&quot;No.&quot;) { }
                field(description; Rec.Description) { }
                field(unitPrice; Rec.&quot;Unit Price&quot;) { }
                field(inventory; Rec.Inventory) { }
                field(lastModifiedDateTime; Rec.SystemModifiedAt) { }
            }
        }
    }
}</code></pre>
<p>Se consulta en <code>.../api/cristianmartinez/shop/v1.0/companies({companyId})/itemStocks</code>. Tres detalles importantes:</p>
<ul>
<li><strong><code>ODataKeyFields = SystemId</code></strong>: la clave es un GUID inmutable, como en la API estándar: no cambia aunque alguien renumere el artículo.</li>
<li><strong><code>DataAccessIntent = ReadOnly</code></strong>: las lecturas van a la réplica de solo lectura, cuando está disponible, y no compiten con los usuarios que están trabajando.</li>
<li><strong>La versión en la URL</strong> (<code>v1.0</code>): cuando tengas que cambiar el contrato, publicas una <code>v2.0</code> al lado y no rompes a quien ya consume la primera.</li>
</ul>
<h2 id="resumen">Resumen</h2>
<ul>
<li>¿Lo cubre la <strong>API v2.0</strong>? Úsala.</li>
<li>¿Necesitas campos o lógica propios? <strong>API propia</strong> en una extensión.</li>
<li>¿Tienes integraciones sobre <strong>páginas de Microsoft publicadas como OData</strong>? Revísalas antes de la versión 30.</li>
<li>Autenticación: <strong>OAuth con una aplicación de Entra ID</strong> y los permisos justos.</li>
<li>Diseña pensando en los <strong>límites</strong>: filtros, sincronización incremental y reintentos.</li>
</ul>
<p>Si quieres ver cómo encaja todo esto en una integración completa, lo explico en <a href="https://cristianmartinez.es/blog/conectar-web-tienda-online-business-central">cómo conectar tu web o tienda online con Business Central</a>. Y si necesitas una API propia o revisar una integración antes de la versión 30, <a href="https://cristianmartinez.es/business-central">hablemos</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Power BI con Business Central: apps, conector y APIs propias</title>
      <link>https://cristianmartinez.es/blog/power-bi-business-central</link>
      <guid isPermaLink="true">https://cristianmartinez.es/blog/power-bi-business-central</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 GMT</pubDate>
      <description>Las tres formas de llevar los datos de Business Central a Power BI, cuándo usar cada una y cómo evitar informes lentos que además compiten con tus usuarios.</description>
      <category>business-central</category>
      <category>power-bi</category>
      <category>informes</category>
      <content:encoded><![CDATA[<p>Conectar <strong>Power BI</strong> con <strong>Business Central</strong> es fácil. Lo difícil es elegir bien desde dónde leer los datos para que el informe sea rápido, no se rompa en la siguiente actualización y no frene a quien está trabajando en el ERP.</p>
<p>Hay tres caminos. Van de menos a más trabajo, y conviene agotar cada uno antes de pasar al siguiente.</p>
<h2 id="1-las-apps-de-power-bi-de-microsoft">1. Las apps de Power BI de Microsoft</h2>
<p>Microsoft publica apps de Power BI listas para usar por área funcional: <strong>finanzas, ventas, compras, inventario, valoración de inventario, proyectos y fabricación</strong>, y en las últimas versiones también sostenibilidad y facturación de suscripciones. Llegaron en la versión 25.1 y sustituyen a las antiguas apps de CRM, Finance y Sales, que se retiraron en 2026.</p>
<ul>
<li><strong>Se instalan desde Power BI</strong> (<em>Aplicaciones → Obtener aplicaciones</em>) o desde Microsoft Marketplace, y se conectan indicando el <strong>nombre completo de la empresa</strong> (el de la página <em>Empresas</em>, no el nombre para mostrar) y el <strong>entorno</strong>.</li>
<li><strong>Funcionan por empresa</strong>: si tienes varias, instalas y configuras una por cada una, cada una en su área de trabajo.</li>
<li><strong>Se integran en Business Central</strong>: los informes aparecen dentro del propio ERP, en la página de inicio de cada rol.</li>
<li><strong>Leen de la Analytics API</strong>, una API de solo lectura pensada para informes (<code>/api/microsoft/analytics/v1.0/</code>).</li>
</ul>
<p>Las apps no tienen coste. Lo que necesitas son licencias de Power BI Pro (o capacidad Premium) para quien instale la app, actualice los datos y vea los informes, además de su licencia de Business Central; revisa qué incluye tu tenant.</p>
<p>Si tus indicadores son los habituales (ventas por cliente, márgenes, antigüedad de saldos, rotación de stock...), <strong>empieza aquí</strong>. Te ahorras semanas de modelado y Microsoft las mantiene con cada versión.</p>
<h2 id="2-power-bi-desktop-con-el-conector-de-business-central">2. Power BI Desktop con el conector de Business Central</h2>
<p>Cuando quieres tu propio modelo o combinar Business Central con otras fuentes:</p>
<ol>
<li>En Power BI Desktop, <strong>Obtener datos → Servicios en línea → Dynamics 365 Business Central</strong>.</li>
<li>Inicia sesión con tu cuenta de organización.</li>
<li>En el navegador aparecen tus <strong>entornos</strong>, dentro de cada uno sus <strong>empresas</strong>, y dentro de cada empresa tres carpetas: <em>Advanced APIs</em>, <em>Standard APIs v2.0</em> y los servicios web publicados (<em>legacy</em>).</li>
</ol>
<p>Microsoft recomienda usar las <strong>APIs</strong> y no los servicios web. Hay un motivo más: a partir de la <strong>versión 30 (primavera de 2027)</strong> ya no se podrán publicar páginas de Microsoft como servicio OData, así que un informe construido sobre, por ejemplo, la página de movimientos de clientes publicada en <em>Servicios web</em> dejará de actualizarse. Lo explico con más detalle en <a href="https://cristianmartinez.es/blog/odata-api-business-central">OData y API en Business Central</a>.</p>
<h2 id="3-apis-propias-para-informes">3. APIs propias para informes</h2>
<p>Cuando necesitas campos de tus extensiones, tablas propias o datos agregados que la API estándar no trae, la herramienta adecuada es una <strong>consulta de tipo API</strong> en AL. A diferencia de una página, una consulta puede <strong>unir tablas y agregar en el servidor</strong>: Power BI recibe el resultado ya resumido en lugar de millones de movimientos.</p>
<pre class="code-block" data-lang="al"><code class="language-al">query 50110 &quot;CM Sales By Customer API&quot;
{
    QueryType = API;
    APIPublisher = &#39;cristianmartinez&#39;;
    APIGroup = &#39;reporting&#39;;
    APIVersion = &#39;v1.0&#39;;
    EntityName = &#39;customerSale&#39;;
    EntitySetName = &#39;customerSales&#39;;
    DataAccessIntent = ReadOnly;

    elements
    {
        dataitem(CustLedgerEntry; &quot;Cust. Ledger Entry&quot;)
        {
            column(customerNumber; &quot;Customer No.&quot;) { }
            column(postingYear; &quot;Posting Date&quot;) { Method = Year; }
            column(postingMonth; &quot;Posting Date&quot;) { Method = Month; }
            column(salesAmount; &quot;Sales (LCY)&quot;) { Method = Sum; }
        }
    }
}</code></pre>
<p>Esta consulta devuelve las ventas por cliente, año y mes ya sumadas. Una vez instalada la extensión, la ves en el navegador del conector de Power BI, en <em>Advanced APIs</em>, bajo el <code>APIPublisher</code> y el <code>APIGroup</code> que hayas definido.</p>
<p><strong><code>DataAccessIntent = ReadOnly</code></strong> indica que la consulta puede leer de la <strong>réplica de solo lectura</strong> de la base de datos, así que un informe pesado no compite con los usuarios que están registrando pedidos. El conector de Power BI ya pide esa réplica por defecto, pero dejarlo declarado en la consulta protege también al resto de clientes que la usen.</p>
<h2 id="que-el-informe-no-tarde-una-eternidad">Que el informe no tarde una eternidad</h2>
<ul>
<li><strong>Trae solo las columnas que usas.</strong> Quita en Power Query todo lo que no vaya al modelo, lo antes posible.</li>
<li><strong>Filtra por fecha.</strong> Los movimientos de cinco años atrás rara vez hacen falta en el informe diario.</li>
<li><strong>Agrega en Business Central</strong>, con consultas API, en lugar de traerte todos los movimientos para sumarlos en Power BI.</li>
<li><strong>Usa la actualización incremental</strong> de Power BI en las tablas grandes de movimientos: solo se recargan los últimos días.</li>
<li><strong>Ojo con los límites.</strong> Power BI hace peticiones como cualquier otra integración y cuenta para el límite de 6.000 peticiones cada 5 minutos por usuario. Si programas muchos informes a la misma hora con el mismo usuario, repártelos.</li>
</ul>
<h2 id="que-opcion-elegir">Qué opción elegir</h2>
<ul>
<li><strong>KPIs estándar</strong> de finanzas, ventas, compras o inventario → apps de Power BI de Microsoft.</li>
<li><strong>Modelo propio con datos estándar</strong> → Power BI Desktop con las <em>Standard APIs v2.0</em>.</li>
<li><strong>Campos de extensiones, tablas propias o grandes volúmenes</strong> → consultas API propias con <code>DataAccessIntent = ReadOnly</code>.</li>
<li><strong>Informes sobre páginas de Microsoft publicadas como servicio web</strong> → toca migrarlos antes de la versión 30.</li>
</ul>
<p>Si necesitas una consulta API a medida o revisar por qué un informe tarda tanto, <a href="https://cristianmartinez.es/business-central">escríbeme</a>.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>
