Cómo un módulo de Nebula ofrece una exportación en streaming del listado de una grilla (CSV/XLSX/TEXT/JSON/XML) sobre la capa genérica de
SimappeCommons, reutilizando la MISMA consultapage-response(nativa + tenant + filtros), con memoria acotada e independiente del total de filas. Es el inverso del Patrón Canónico de Importación de Archivos. Referencia de implementación: Moneda y Tasa de Cambio (nebula-accounting-core, HT_CON_017), con Dependencias y Tipos de Asiento como implementaciones previas. Validado en dev.
Versión: 2.0 · Fecha: 11 de Agosto, 2026 · Arquitecto: Carlos Alberto Torres Camargo
Librerías: SimappeCommons ≥ 4.37.0 · SimappeModel ≥ 3.10.0
Cambio de la v2.0 (SimappeCommons 4.37.0):
@ExportColumnpasa a ser OPCIONAL. Antes, un record sin anotaciones abortaba la exportación con 500; ahora se exporta completo. La anotación ya no habilita el export: sirve para elegir qué columnas salen, con qué título y en qué orden.
Todo listado/grilla que deba descargarse a archivo usa esta capa. Sustituye las exportaciones ad-hoc (cada módulo materializaba el total en byte[] en memoria y armaba el archivo a mano). El archivo exportado contiene exactamente lo que la grilla puede ver — misma vista/consulta, mismos filtros del tenant — porque reutiliza el page-response.
SimappeCommons es una librería de arquitectura: la exportación es un proceso atómico que se ejecuta siempre de una sola forma, sin divergencias — como el paginador nativo.
page-response (vista/consulta nativa + tenant + filtros forzados). No hay una segunda consulta divergente. Alinea con la regla dura "listas se consumen solo por page-response/get-record".OutputStream de la respuesta HTTP en el hilo del request, donde el TenantContext está presente y el TenantAwareDataSource rutea al datasource correcto. NUNCA StreamingResponseBody (correría en un hilo async donde el tenant, ThreadLocal, se pierde).ExportSupport.export reutilizando su page-response. La escritura por formato la resuelve el framework.@ExportColumn el record sale completo. Se anota cuando el archivo lo va a leer una persona: encabezados en español, orden fijo y fuera lo que no significa nada para quien lo recibe.com.catcsoft.simappe.commons.api.v1.core.export)| Pieza | Rol |
|---|---|
SimappeExporter |
Motor: itera lotes del proveedor y escribe al flujo por el writer del formato |
ExportBatchSupplier<R> |
Provee los datos por lotes (página 0,1,2…); reutiliza el page-response |
ExportStreamWriter + Csv/Xlsx/Pdf/Text/Json/Xml ExportWriter |
Escritura incremental: begin() → N×writeRow() → end() |
ExportOptions |
simappe.export.max-rows / batch-size (config-server) |
@ExportColumn / ExportFormat |
Declaración de columnas + formato, en SimappeModel |
ExportSupport<R> |
Contrato para componentes con PageResponseSupport: export(query, format, out, request) |
Firma del motor y contrato del proveedor:
public class SimappeExporter {
public <R> void export(ExportBatchSupplier<R> batches, Class<R> type,
ExportFormat format, OutputStream out, ExportOptions options) throws SimappeException;
}
@FunctionalInterface
public interface ExportBatchSupplier<R> {
List<R> nextBatch(int pageIndex) throws SimappeException; // lista vacía o < batchSize = fin
}
public interface ExportSupport<R> {
void export(SimappeRequestQuery query, ExportFormat format, OutputStream out,
HttpServletRequest request) throws SimappeException;
}
ExportFormatDefinición (SimappeModel · com.catcsoft.simappe.model.core.export):
public enum ExportFormat {
CSV ("text/csv", "csv"),
XLSX("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"),
PDF ("application/pdf", "pdf"),
TEXT("text/plain", "txt"), // TSV, separado por tabulador
JSON("application/json", "json"),
XML ("application/xml", "xml");
// getContentType(), getExtension(), fromValue(String) (default CSV)
}
| Formato | Salida | Encabezado / clave |
|---|---|---|
CSV |
Valores separados por coma (RFC 4180) | fila de encabezados = @ExportColumn.title |
TEXT |
TSV (tabulador) | igual que CSV |
XLSX |
Libro Excel OOXML (streaming, ventana de filas) | encabezados = title |
JSON |
Arreglo de objetos, uno por fila | clave = nombre del componente del record |
XML |
<rows><row>…</row></rows> |
elemento = nombre del componente |
PDF |
Documento con tabla incremental | encabezados = title |
PDF en el cliente Nebula: NO aplica. Exportar una tabla plana a PDF (sin formato ni encabezado) no tiene valor de negocio. El
PdfExportWriterexiste en Commons (compartido con otros productos), pero el endpoint Nebula lo rechaza con400. Los formatos ofrecidos en Nebula son los mismos 5 de datos que la importación: CSV, XLSX, TEXT, JSON, XML.
@ExportColumn (opcional)Se anota el mismo record del page-response. Desde 4.37.0 el comportamiento es:
| Caso | Resultado |
|---|---|
| El record no anota ninguna columna | Se exporta completo, en el orden en que declara sus componentes, con el nombre del componente como encabezado |
| El record anota algunas | Salen solo las anotadas, con su title y su order |
El tipo no es un record |
500: no hay componentes que proyectar |
@ExportColumn = title() (encabezado humano) + order() (orden 1..N).
Ejemplo canónico — Moneda (MonedaResponse, HT_CON_017):
public record MonedaResponse(
Long id, // fuera: identidad tecnica
@ExportColumn(title = "Código", order = 1) String codigo,
@ExportColumn(title = "Descripción", order = 2) String descripcion,
@ExportColumn(title = "Símbolo", order = 3) String simbolo,
@ExportColumn(title = "Decimales", order = 4) Integer decimales,
String status, // fuera: ciclo tecnico de plataforma
@ExportColumn(title = "Tasas", order = 6) Integer cantidadTasas,
List<TasaCambioResponse> tasasCambio, // fuera: deprecado, viaja vacio
String estadoMoneda, // fuera: crudo; su etiqueta va abajo
String esMonedaOperacion, // fuera: crudo; su etiqueta va abajo
@ExportColumn(title = "Estado", order = 7) String estadoNombre,
@ExportColumn(title = "De operación", order = 5) String esMonedaOperacionNombre) {}
Ejemplo canónico — Tasa de Cambio (TasaCambioResponse):
public record TasaCambioResponse(
Long id, // fuera
Long monedaId, // fuera: la FK; se exporta el codigo
@ExportColumn(title = "Moneda", order = 1) String monedaCodigo,
@ExportColumn(title = "Fecha", order = 2) LocalDate fecha,
@ExportColumn(title = "Valor", order = 3) BigDecimal valor,
String status, // fuera: ciclo tecnico
Long version, // fuera
String estadoTasa, // fuera: crudo; su etiqueta va abajo
@ExportColumn(title = "Estado", order = 4) String estadoNombre) {}
Qué se deja fuera, y por qué. El criterio no es técnico, es quién lee el archivo:
| Se excluye | Motivo |
|---|---|
id, monedaId, version |
Identificadores internos; no significan nada para quien recibe el archivo |
status |
Ciclo técnico de plataforma. JAMÁS es estado de negocio, ni en pantalla ni en el archivo |
estadoMoneda, estadoTasa, esMonedaOperacion |
Valores crudos; al archivo va su etiqueta i18n (*Nombre), que es lo legible |
tasasCambio |
Deprecado por la separación (RN-SEP-06): viaja siempre vacío |
La pareja crudo + etiqueta. El aplanado del
page-responselleva el valor crudo (filtrable y ordenable en SQL) y su etiqueta i18n. Al archivo va la etiqueta; el crudo se queda para la grilla.
ExportSupport reusando el page-response@Override
public void export(SimappeRequestQuery query, ExportFormat format, OutputStream out,
HttpServletRequest request) throws SimappeException {
UserSession us = sesion(request);
var options = ExportOptions.builder().maxRows(exportMaxRows).batchSize(exportBatchSize)
.title("monedas").build();
aplicarTenant(query, us); // fuerza clientId/companyId/subsidiaryId (filtros que el front no controla)
query.setSize(options.getBatchSize());
simappeExporter.export(pageIndex -> {
query.setPage(pageIndex);
return nativeTenantPaginator
.getPageResponseNative(query, us, MonedaView.class, this::toViewResponse).content();
}, MonedaResponse.class, format, out, options);
}
El proveedor de lotes es la misma consulta nativa/tenant del
page-response(aquí sobre la vista aplanadav_monedas), paginada por índice. Memoria acotada al lote.
/export (contrato del controlador)Estándar: POST /api/v1/<recurso>/export. Síncrono, escribe directo al OutputStream de la respuesta en el hilo del request.
@PostMapping(value = "/export", produces = MediaType.ALL_VALUE)
public void export(@RequestBody SimappeRequestQuery requestQuery,
@RequestParam(name = "formato", defaultValue = "csv") String formato,
HttpServletRequest request, HttpServletResponse response) throws SimappeException, IOException {
ExportFormat exportFormat = ExportFormat.fromValue(formato);
if (exportFormat == ExportFormat.PDF) { // PDF fuera del cliente Nebula
throw new SimappeException("Formato de exportacion no soportado para dependencias",
List.of("El formato pdf no aplica para este listado en el cliente Nebula"),
List.of("Use csv, xlsx, text, json o xml"), HttpStatus.BAD_REQUEST);
}
response.setContentType(exportFormat.getContentType());
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=dependencias." + exportFormat.getExtension());
dependenciasService.export(requestQuery, exportFormat, response.getOutputStream(), request); // hilo del request
response.flushBuffer();
}
Dos reglas de contrato aprendidas (obligatorias):
formato, NO format. format colisiona con la content-negotiation por parámetro de Spring (favorParameter): ?format=csv intenta negociar un media type "csv" inexistente → 406. Usar formato (u otro nombre no reservado).response.getOutputStream()), NUNCA StreamingResponseBody. El streaming async corre en otro hilo donde el TenantContext (ThreadLocal) se pierde → el TenantAwareDataSource rutea al datasource equivocado → la vista/consulta "no existe" (500). El SimappeExporter ya es streaming por lotes; no se necesita el hilo async.No hay envelope: la respuesta es el archivo. Content-Type = el del formato; Content-Disposition: attachment; filename=<recurso>.<ext>. HTTP 200 con el binario/texto en streaming.
SimappeExceptionIgual que en importación, la excepción canónica es SimappeException. Casos:
| Caso | HTTP | Origen |
|---|---|---|
Formato no soportado en el cliente (p. ej. pdf) |
400 | el controlador (validación explícita) |
Se supera simappe.export.max-rows |
500 | SimappeExporter (tope de filas) |
El tipo exportado no es un record |
500 | SimappeExporter ("no columns") — un record sin @ExportColumn ya no falla: sale completo |
| Falla la escritura / la consulta | 500 | SimappeExporter (envuelve la causa) |
El HttpStatus viaja en la propia SimappeException; para el formato no soportado se lanza con HttpStatus.BAD_REQUEST.
Login idéntico al de importación (2 pasos; header x-simappe-environment: dev). Con el token de negocio:
# el cuerpo son los MISMOS filtros/orden de la grilla (page-response); la paginacion la gobierna la capa
curl -s -X POST "$BASE/nebula-accounting-core/api/v1/monedas/export?formato=csv" \
-H "Authorization: Bearer <token>" -H "x-simappe-environment: dev" \
-H "content-type: application/json" \
-d '{"page":0,"size":50,"searchsBy":[],"ordersBy":[]}' -o monedas.csv
# con filtro: el archivo trae lo que el usuario esta viendo, no el maestro completo
curl -s -X POST "$BASE/nebula-accounting-core/api/v1/monedas/export?formato=xlsx" \
-H "Authorization: Bearer <token>" -H "x-simappe-environment: dev" \
-H "content-type: application/json" \
-d '{"page":0,"size":50,"searchsBy":[{"key":"codigo","operation":"MATCH","value":"USD"}]}' -o monedas.xlsx
# el hijo, con su moneda ya resuelta por la vista aplanada
curl -s -X POST "$BASE/nebula-accounting-core/api/v1/monedas/tasas/export?formato=csv" \
-H "Authorization: Bearer <token>" -H "x-simappe-environment: dev" \
-H "content-type: application/json" \
-d '{"page":0,"size":50,"searchsBy":[],"ordersBy":[]}' -o tasas-de-cambio.csv
Salida esperada de monedas.csv (encabezados y orden los fija @ExportColumn):
Código,Descripción,Símbolo,Decimales,De operación,Tasas,Estado
COP,Peso Colombiano,$,2,No,3,Activo
USD,Dolar Americano,US$,2,No,1,Activo
?formato= |
HTTP | Content-Type |
Archivo |
|---|---|---|---|
csv |
200 | text/csv |
monedas.csv |
text |
200 | text/plain |
monedas.txt (TSV) |
json |
200 | application/json |
monedas.json |
xml |
200 | application/xml |
monedas.xml |
xlsx |
200 | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
dependencias.xlsx (Excel 2007+) |
pdf |
400 | — | rechazado en el cliente Nebula |
Contenido real por formato:
CSV (text/csv):
Código,Nombre,Estado
12345,Dependencia JPrueba 01,ACTIVO
98765,Dependencia JPrueba 02 Created,ACTIVO
TEXT (text/plain, tabulador ⇥):
Código⇥Nombre⇥Estado
12345⇥Dependencia JPrueba 01⇥ACTIVO
JSON (application/json, clave = nombre del componente):
[{"codigoDependencia":"12345","nombreDependencia":"Dependencia JPrueba 01","estadoDependencia":"ACTIVO"}, ...]
XML (application/xml):
<?xml version="1.0" encoding="UTF-8"?><rows><row><codigoDependencia>12345</codigoDependencia><nombreDependencia>Dependencia JPrueba 01</nombreDependencia><estadoDependencia>ACTIVO</estadoDependencia></row>...</rows>
PDF (formato=pdf → 400):
{"status":"BAD_REQUEST","message":"Formato de exportacion no soportado para dependencias","errors":["El formato pdf no aplica para este listado en el cliente Nebula"],"solutions":["Use csv, xlsx, text, json o xml"]}
page-response lleva @ExportColumn (title/order) en las columnas que debe leer una persona. Sin anotaciones el record sale completo: anotar es la decisión de cuidar cómo se ve el archivo.id/FKs/version, el status técnico, los valores crudos cuya etiqueta i18n (*Nombre) ya viaja, y los campos deprecados.PageResponseSupport (page-response/get-record, SQL nativo + tenant). El export reutiliza esa consulta como ExportBatchSupplier.ExportSupport<R>; inyecta SimappeExporter; max-rows/batch-size de config-server.POST /api/v1/<recurso>/export, param formato (no format), escritura síncrona a response.getOutputStream() (no StreamingResponseBody).csv/xlsx/text/json/xml; pdf → 400.byte[]/generarReporte) con @Deprecated(forRemoval=true) — no romper.SimappeModel/SimappeCommons viajan de Catcsoft support/3.6.x a Centrica por rama release → Nexus. SemVer obligatorio.page-response se apoya en una vista aplanada para resolver computados (como v_dependencias), su migración Oracle (tabla/vista) se aplica antes del deploy del servicio (sqlplus, SET SQLBLANKLINES ON; secuencias, nunca IDENTITY; tablespaces *_DATA/*_INDEX).Los dos exports del ejemplo canónico salieron de un defecto real, y vale la pena que el equipo lo conozca porque explica el cambio de la v2.0:
/monedas/export, pero el endpoint nunca se implementó en el backend y el botón respondía 404./export respondieron 500: No @ExportColumn annotation found. El exportador exigía la anotación para funcionar.SimappeCommons 4.37.0. Los records se anotaron después, para controlar títulos y orden.Regla que queda: una separación o refactor no puede reducir lo que el módulo ya ofrecía. Si el listado exportaba, sigue exportando; si el hijo pasa a tener listado propio, hereda la capacidad.
Referencia inversa: Patrón Canónico de Importación de Archivos.