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 /Invoicesde creación, pero SAP no incrementaNNM1.NextFolioen 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 luegoPATCHpara asignar el folio. Por eso se adopta el endpoint$batch, empaquetandoPOST(crear) +PATCH(foliar) en un único changeset atómico: una sola petición, sin ida y vuelta para leer elDocEntry, 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 elPATCHdel 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 → Seriesen 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 fijarSeriesexplícitamente en elPOSTde 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).$1referencia la factura creada enContent-ID: 1.FolioPrefyNextFolioprovienen 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
POSTde creación no avanzaNNM1.NextFolioy bloquea la numeración. ElPATCHposterior 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 (trasContent-IDy tras elContent-Typeinterno); si falla con400, 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
Seriesdentro 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)¶
FoliadoServiceque encapsuleconsultarSiguienteFolio()+enviarBatchCrearYFoliar()+ lógica de reintento. Reutiliza el cliente HTTP del Service Layer ySapAuthService(sesiónB1SESSION/ 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-mutexcon clave porSeries, o cola dedicada (minimiza colisiones10000510). - 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 mapaSucursal → Seriesexternalizados. - 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):
- Buscar facturas con
FolioNumbervacío yCancelled = "tNO". - Alertar (no auto-corregir a ciegas): revisar caso por caso, ya que una factura sin folio en producción indica un flujo no contemplado.
- Opcional: foliar manualmente vía
PATCHprevia validación.
10. Preguntas abiertas / validaciones pendientes¶
- ¿El
PATCHde folio avanzaNNM1.NextFolio? ElPATCHfunciona (a diferencia del folio-en-creación, que no avanza el contador y bloquea). Resta confirmar que, en concurrencia, elPATCHsí 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. - Valor de
MAX_INTENTOSy backoff según volumen de cajas concurrentes por serie. - 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. - Fijar
Seriesexplícito en elPOSTdel batch: confirmar si conviene enviarlo o dejar que SAP lo infiera por defaults del documento. - Formato exacto del sub-request en el
$batchde SAP B1 (con/sinHTTP/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 avanzaNNM1.NextFoliopor esa vía y bloquea la numeración. Se usaPOST+PATCHdentro de un$batcható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~~ → ConfigSucursal → Seriesen el OMS, resuelta antes del batch.