Saltar a contenido

Requerimiento: Foliado de Facturas Tributarias

Módulo: Facturación / OMS Integración: SAP Business One — Service Layer (OData) Stack: NestJS (backend) · Next.js (frontend) · Keycloak (OIDC) Estado: Modelado para desarrollo

Decisión clave (actualización): Se probó asignar el folio en el POST /Invoices de creación, pero SAP no incrementa NNM1.NextFolio en ese camino, lo que provoca un error de bloqueo en el uso de los números de folio (la siguiente factura reintenta el mismo folio). El camino que funciona es crear primero y luego PATCH para asignar el folio. Por eso se adopta el endpoint $batch, empaquetando POST (crear) + PATCH (foliar) en un único changeset atómico: una sola petición, sin ida y vuelta para leer el DocEntry, y con rollback completo si el folio colisiona.

El folio (FolioPref/NextFolio) se obtiene en una consulta previa (GetFoliosNNM1) y se inyecta como valores literales en el PATCH del batch — el content-ID referencing del Service Layer solo pasa la referencia de la entidad creada ($1), no valores de una consulta.


1. Resumen del requerimiento (refinado)

El sistema debe asignar automáticamente un número de folio tributario a cada factura en el momento de su generación, utilizando la serie de numeración SAP correspondiente al punto de expedición de la sucursal. La factura se crea y se folia en una sola petición $batch (POST crear + PATCH foliar, atómica). Una vez creada y foliada, queda habilitada para impresión.

El folio es la secuencia de numeración tributaria habilitada para el establecimiento y punto de expedición; en SAP se modela como una serie (NNM1) que expone el próximo folio disponible (NextFolio) y su prefijo (FolioPref).

Alcance actual: un (1) punto de expedición por sucursal → la serie se determina de forma unívoca a partir de la sucursal de la factura.


2. Glosario

Término Significado
Folio Número tributario secuencial asignado a la factura. Compuesto por prefijo (FolioPref) + número (NextFolio).
Serie Serie de numeración SAP (Series en NNM1) ligada a establecimiento + punto de expedición.
Punto de expedición Punto desde el que se emiten comprobantes. Hoy: 1 por sucursal.
Foliada Estado inferido: la factura tiene FolioNumber asignado y no está cancelada.
Cancelada / Anulada Factura con Cancelled = "tYES". No se folia.

3. Historia de usuario

Como Cajero, quiero que al generar una factura se le asigne automáticamente el siguiente número de folio disponible de la serie tributaria de mi sucursal, para poder imprimir un comprobante tributariamente válido sin gestionar manualmente la numeración.

Criterios de aceptación

Escenario: Foliado exitoso al generar la factura
  Dado que soy Cajero y genero una factura en una sucursal
  Cuando el sistema determina la serie de esa sucursal
  Y consulta el siguiente folio disponible de la serie
  Entonces envía un $batch que crea la factura y le asigna el folio (PATCH)
  Y la factura queda creada, foliada y habilitada para impresión

Escenario: Solo el Cajero puede generar/foliar
  Dado que tengo un rol distinto de Cajero
  Cuando intento generar o foliar una factura
  Entonces la operación es rechazada por autorización

Escenario: No se folian facturas canceladas o anuladas
  Dado que una factura tiene Cancelled = "tYES"
  Cuando se ejecuta el proceso de foliado
  Entonces no se asigna ningún folio

Escenario: Número de folio ya en uso (reintento)
  Dado que el folio consultado fue tomado por otro proceso
  Cuando el PATCH del batch responde error 10000510 (Folio number already in use)
  Entonces el changeset hace rollback completo (la factura no se crea)
  Y el sistema vuelve a consultar el siguiente folio disponible
  Y reintenta el batch, hasta un máximo de N intentos

Escenario: Idempotencia
  Dada una factura que ya tiene FolioNumber asignado
  Cuando vuelve a ejecutarse el foliado
  Entonces no se re-folia (se omite la asignación)

4. Reglas de negocio

ID Regla
BR1 Solo el rol Cajero puede generar facturas (y por ende foliarlas).
BR2 El folio se asigna en el momento de generación de la factura.
BR3 No se folian facturas canceladas o anuladas (Cancelled = "tYES").
BR4 La serie deriva de la sucursal (1 punto de expedición por sucursal en el alcance actual).
BR5 El número de folio debe ser único; la unicidad la valida SAP (error 10000510).
BR6 Una factura solo es imprimible si tiene folio asignado.
BR7 El foliado es idempotente: si la factura ya tiene FolioNumber, no se reasigna.

5. Modelo de la solución técnica

5.1 Determinación de la serie

El folio se consulta antes de enviar el batch (para inyectar los valores en el PATCH), por lo que la serie debe conocerse antes de crear la factura.

  • Fuente autoritativa: tabla de configuración Sucursal → Series en el OMS. Como hoy hay 1 punto de expedición por sucursal, la relación es 1:1.
  • La serie resuelta se usa para (a) consultar el folio (GetFoliosNNM1) y (b) opcionalmente fijar Series explícitamente en el POST de creación del batch.

Diseñar la config como tabla para soportar a futuro N puntos de expedición por sucursal.

5.2 Secuencia de foliado

sequenceDiagram
    actor Cajero
    participant OMS as OMS (NestJS)
    participant SL as SAP Service Layer
    Cajero->>OMS: Generar factura (desde pedido/entrega)
    OMS->>OMS: Resolver Series (config Sucursal->Series)
    loop hasta éxito o maxIntentos
        OMS->>SL: POST /SQLQueries('GetFoliosNNM1')/List (Series)
        SL-->>OMS: NextFolio, FolioPref
        Note over OMS,SL: $batch (1 changeset atómico)
        OMS->>SL: POST /Invoices (líneas base)  [Content-ID 1]<br/>+ PATCH $1 {FolioPrefixString, FolioNumber}
        alt changeset OK
            SL-->>OMS: 201 (factura) + 204 (folio asignado)
        else error 10000510 (folio en uso)
            SL-->>OMS: rollback completo -> re-consultar
        end
    end
    OMS-->>Cajero: Factura creada y foliada (imprimible)

5.3 Contrato API — Consulta del siguiente folio

POST {{SAP_SL_URL}}/SQLQueries('GetFoliosNNM1')/List

Body

{
  "ParamList": "Series='{$SeriesDeLaFactura}'"
}

Respuesta (200)

{
  "value": [
    {
      "Series": 164,
      "SeriesName": "AVIA-003",
      "NextFolio": 6899,
      "FolioPref": "FV"
    }
  ]
}

Campos relevantes: NextFolio (número a asignar) y FolioPref (prefijo).

5.4 Contrato API — $batch: crear factura + asignar folio

Un único changeset atómico: POST /Invoices (crea, Content-ID: 1) seguido de PATCH $1 que asigna el folio sobre la factura recién creada. Las líneas se copian del documento base (pedido/entrega) vía BaseType / BaseEntry / BaseLine.

POST {{SAP_SL_URL}}/$batch
Content-Type: multipart/mixed;boundary=batch_folio

Body

--batch_folio
Content-Type: multipart/mixed;boundary=changeset_folio

--changeset_folio
Content-Type: application/http
Content-Transfer-Encoding: binary
Content-ID: 1

POST Invoices HTTP/1.1
Content-Type: application/json

{
  "DocumentLines": [
    { "BaseType": 17, "BaseEntry": {$DocEntryBase}, "BaseLine": 0 }
  ]
}

--changeset_folio
Content-Type: application/http
Content-Transfer-Encoding: binary
Content-ID: 2

PATCH $1 HTTP/1.1
Content-Type: application/json

{
  "FolioPrefixString": "{$FolioPref}",
  "FolioNumber": {$NextFolio}
}

--changeset_folio--
--batch_folio--

BaseType: 17 = Pedido (Sales Order) · 15 = Entrega (Delivery). $1 referencia la factura creada en Content-ID: 1. FolioPref y NextFolio provienen de la consulta previa (§5.3).

Respuestas

Resultado Código Significado
Éxito 200 OK (envuelve 201 de la creación + 204 del PATCH) Factura creada y foliada en una sola petición.
Falla (folio en uso) changeset devuelve -10 con mensaje 10000510 - Folio number already in use Rollback completo: la factura NO se crea. Re-consultar folio y reenviar el batch.
Otra falla en el changeset -10 u otro Rollback completo; no reintentar por defecto; propagar el error.
{
  "error": {
    "code": "-10",
    "message": "10000510 - Folio number already in use; in \"Folio Number\" field, enter unique number"
  }
}

Importante 1 — por qué batch y no folio-en-creación: asignar el folio en el POST de creación no avanza NNM1.NextFolio y bloquea la numeración. El PATCH posterior sí funciona; el batch lo hace atómico.

Importante 2 — atomicidad: ante 10000510, el changeset hace rollback y no queda factura a medias. No hay ventana de "factura sin folio" en el flujo normal.

Importante 3 — disparador del reintento: matchear el código de mensaje 10000510, no el genérico -10 (distintos errores comparten -10).

Gotchas multipart: el boundary= del header debe coincidir exacto con los marcadores del body; respetar las líneas en blanco (tras Content-ID y tras el Content-Type interno); si falla con 400, forzar CRLF.

5.5 Manejo de errores y reintentos

function crearFacturaFoliada(docBase, series):
    intentos = 0
    folioPrevio = null
    while intentos < MAX_INTENTOS:          # p.ej. MAX_INTENTOS = 5
        intentos += 1
        folio = consultarSiguienteFolio(series)        # GetFoliosNNM1 (call previo)

        # Guardia anti-loop: si SAP devuelve el mismo folio que ya colisionó
        if folio.NextFolio == folioPrevio:
            esperar(backoff(intentos))                 # dar tiempo a que avance la serie
            continue

        try:
            # $batch: POST /Invoices (Content-ID 1) + PATCH $1 {FolioPref, NextFolio}
            factura = enviarBatchCrearYFoliar(docBase, folio.FolioPref, folio.NextFolio)
            return factura                              # changeset OK (creada + foliada)
        catch error:
            if esFolioEnUso(error):                     # mensaje 10000510 -> rollback, nada creado
                folioPrevio = folio.NextFolio
                esperar(backoff(intentos))
                continue
            else:
                throw error                             # no reintentable
    throw FolioNoAsignadoError(docBase)                 # alerta

Notas: - Backoff corto (p.ej. 100–300 ms incremental) entre reintentos. - El changeset es atómico: ante 10000510 el rollback deja todo sin crear — el reintento reenvía el batch con un folio fresco. No quedan facturas a medias. - Idempotencia de creación: proteger contra doble-submit del mismo pedido/entrega (p.ej. lock por docBase) para no crear facturas duplicadas si el cliente reintenta.

5.6 Concurrencia — serialización por serie

La colisión 10000510 ocurre cuando dos cajas folian la misma serie en paralelo. Para minimizarla:

  • Serializar el foliado por Series dentro del OMS (mutex/cola con clave = Series), de modo que solo una asignación por serie ocurra a la vez.
  • Esto reduce drásticamente los reintentos sin afectar throughput entre series distintas.
  • El reintento (§5.5) sigue siendo necesario como red de seguridad ante procesos externos a la app que también folian en SAP.

5.7 Estado inferido (alineado con el principio de inferencia)

El estado "foliada" no se persiste como flag manual: se infiere del dato en SAP. Como el batch crea y folia en un changeset atómico, en el flujo normal una factura activa nace foliada — "pendiente de foliar" pasa a ser una anomalía (no parte del happy path).

Estado inferido Condición
Foliada / imprimible FolioNumber asignado Y Cancelled = "tNO"
No foliable Cancelled = "tYES"
Pendiente de foliar (anomalía) FolioNumber vacío/null Y Cancelled = "tNO" — no debería ocurrir en flujo normal; detectar y alertar (§9)

6. Flujo de entidades

flowchart LR
    P[Pedido] --> R{Resolver serie + consultar folio}
    OE[Orden de entrega] --> R
    R --> B["$batch: crear factura + PATCH folio<br/>(POST Invoices + PATCH $1)"]
    B --> FO[Factura creada y foliada / Imprimible]

Ambos orígenes (Pedido y Orden de entrega) resuelven serie y consultan folio, y luego un $batch crea la factura y le asigna el folio en un changeset atómico.


7. Casos borde y excepciones

Caso Manejo esperado
Folio en uso (10000510) El changeset hace rollback (nada se crea); re-consultar NextFolio y reenviar el batch hasta MAX_INTENTOS.
Re-consulta devuelve el mismo folio que ya colisionó Backoff + reintento; si persiste, agotar intentos y alertar.
Factura cancelada/anulada (Cancelled = "tYES") No aplica al folio (ya nació foliada); excluir de re-foliado y de la búsqueda de anomalías.
Doble-submit del mismo pedido/entrega Idempotencia de creación (lock por docBase) para evitar facturas duplicadas.
Rol distinto de Cajero Rechazar por autorización (guard Keycloak).
Serie sin folios disponibles / config faltante Error de negocio explícito antes de enviar el batch; no se postea.
Factura sin folio detectada (anomalía) No debería ocurrir con el changeset atómico; si aparece, alertar y revisar (§9).
Error de formato del multipart (400) Revisar boundaries, líneas en blanco y CRLF (ver §5.4).
Error SL no relacionado al folio Rollback del changeset; propagar sin reintentar.

8. Consideraciones de implementación (NestJS)

  • FoliadoService que encapsule consultarSiguienteFolio() + enviarBatchCrearYFoliar() + lógica de reintento. Reutiliza el cliente HTTP del Service Layer y SapAuthService (sesión B1SESSION / token según la opción de auth vigente).
  • Construcción del $batch: armar el multipart (boundaries, Content-ID: 1, PATCH $1) — encapsular en un helper para no repetir el formato y evitar errores de CRLF/líneas en blanco. Parsear la respuesta multipart para extraer el resultado de cada sub-request.
  • Resolución de serie previa a enviar el batch, desde la config Sucursal → Series.
  • Guard de autorización: solo rol Cajero (Keycloak sapb1) alcanza el endpoint de generación.
  • Serialización por serie: async-mutex con clave por Series, o cola dedicada (minimiza colisiones 10000510).
  • Idempotencia de creación: lock/guard por documento base (docBase) para no duplicar facturas ante reintentos del cliente.
  • Detección del error reintentable: parsear el cuerpo del changeset y matchear 10000510.
  • Configuración: MAX_INTENTOS, backoff y mapa Sucursal → Series externalizados.
  • Observabilidad: loguear cada intento (docBase, Series, NextFolio, resultado) para auditoría tributaria.

9. Detección de anomalías (red de seguridad)

Con creación atómica de factura + folio, una factura activa no debería quedar sin folio. Este proceso (job/endpoint) actúa solo como red de seguridad para casos atípicos (p.ej. facturas creadas por fuera del flujo del OMS):

  1. Buscar facturas con FolioNumber vacío y Cancelled = "tNO".
  2. Alertar (no auto-corregir a ciegas): revisar caso por caso, ya que una factura sin folio en producción indica un flujo no contemplado.
  3. Opcional: foliar manualmente vía PATCH previa validación.

10. Preguntas abiertas / validaciones pendientes

  1. ¿El PATCH de folio avanza NNM1.NextFolio? El PATCH funciona (a diferencia del folio-en-creación, que no avanza el contador y bloquea). Resta confirmar que, en concurrencia, el PATCH sí avanza el contador para que la re-consulta entregue un folio fresco. Si no avanzara, la guardia anti-loop (§5.5) cubre el caso. Validar en concurrencia real.
  2. Valor de MAX_INTENTOS y backoff según volumen de cajas concurrentes por serie.
  3. Anulación: confirmar que "anulada" se representa únicamente con Cancelled = "tYES" y no con otro mecanismo (p.ej. nota de crédito) que también deba excluirse.
  4. Fijar Series explícito en el POST del batch: confirmar si conviene enviarlo o dejar que SAP lo infiera por defaults del documento.
  5. Formato exacto del sub-request en el $batch de SAP B1 (con/sin HTTP/1.1, CRLF): validar contra el ambiente y fijar el helper de construcción del multipart.

Resueltas

  • ~~¿Folio en la creación (POST /Invoices)?~~ → No: SAP no avanza NNM1.NextFolio por esa vía y bloquea la numeración. Se usa POST + PATCH dentro de un $batch atómico.
  • ~~¿Creación y foliado deben ser atómicos?~~ → Sí, resuelto vía changeset del $batch (rollback completo ante colisión).
  • ~~Origen autoritativo de la Series~~ → Config Sucursal → Series en el OMS, resuelta antes del batch.