Jobs Quartz¶
El backend corre tareas de fondo con Quartz (spring-boot-starter-quartz, RAMJobStore por default). No hay crons hardcodeados: cada job se da de alta como una fila en la tabla Tareas y se agenda en el arranque. Esto reemplaza al scheduling Seam-async del legacy (TaskMainController con @Startup/@Observer + StdSchedulerFactory por tarea). El detalle del diseño está en SPEC-011; acá está la referencia operativa. Para entender qué hace cada job en el ciclo fiscal, mirá El ciclo de facturación.
Cómo se cargan los jobs¶
flowchart LR
A["Arranque<br/>ApplicationReadyEvent"] --> B["TareaScheduler<br/>lee tabla Tareas"]
B --> C["por cada Tarea:<br/>resolveJobClass + cron"]
C --> D["scheduler.scheduleJob<br/>JobDetail + CronTrigger"]
D --> E["REGISTRY id→Tarea<br/>(para CommonJob)"]
El componente central es TareaScheduler (action/quartz/TareaScheduler.java):
- En
@EventListener(ApplicationReadyEvent.class)recorreTareaList.instance().getResultList()(todas las filas deTareas). - Por cada
Tarearesuelve la clase del job desdejobProcessor(nombre simple → se le antepone el paquetecom.tipre.tifacturaonlinemanager.action.job.; o FQCN si trae punto), valida que implementeorg.quartz.Joby arma unJobDetail+CronTriggercon el cron deprogramacion. - Guarda la
Tareaen unREGISTRYestático (id → Tarea) para queCommonJobla recupere en cada ejecución (elidviaja comodescriptiondelJobDetail).
Un solo Scheduler, cargado desde DB
El legacy creaba un scheduler por tarea con new StdSchedulerFactory(). Acá hay un único Scheduler de Spring; consolidarlo no cambia el comportamiento observable (documentado en SPEC-011). Si una Tarea falla al agendarse (clase inexistente, cron inválido), se loguea un ERROR y el resto sigue agendándose.
La entidad Tarea¶
model/tarea/Tarea.java, mapeada a la tabla Tareas:
| Columna | Campo | Rol |
|---|---|---|
Id |
id (PK, String) |
Identidad de la tarea; viaja como description del job. |
Descripcion |
descripcion |
Nombre legible (lo muestra el cockpit). |
Programacion |
programacion |
Expresión cron de Quartz (6/7 campos). |
JobProcessor |
jobProcessor |
Nombre de la clase Java del job. |
El JobFactory (QuartzConfig)¶
config/QuartzConfig.java instala un AutowiringSpringBeanJobFactory vía SchedulerFactoryBeanCustomizer. Así, cuando Quartz instancia un job, sus dependencias (@Autowired) se resuelven contra el contexto de Spring. Por eso cada job es una clase fina que delega la lógica a un @Service.
Los jobs¶
Cada job extiende CommonJob e implementa org.quartz.Job. El patrón es siempre el mismo: startExecute (lock + bitácora), try { servicio.hacerAlgo() } catch (Throwable) { registrarError } finally { finallyDo }. La lógica de negocio vive en el @Service, no en el job.
Job (jobProcessor) |
Servicio que invoca | Propósito | Frecuencia típica |
|---|---|---|---|
TokenManagerJob |
EnteFacturadorTokenProvider.get() |
Mantiene el TA de WSAA vigente proactivamente (cache-hit casi siempre; LoginCms solo si venció). |
Frecuente (p. ej. cada 2 min) |
CaeaManagerJob |
CaeaGestionService.gestionarCaeas() |
Consulta/solicita y persiste los CAEA de la quincena. | Según Tareas |
ComprobantesEmitidosJob |
ComprobantesEmitidosService.informarComprobantesEmitidos() |
Informa a AFIP los comprobantes emitidos con CAEA (FECAEARegInformativo). |
Según Tareas |
PvSinMovimientosJob |
PvSinMovimientoService.informarPvSinMovimientos() |
Informa a AFIP los PV sin movimientos de los CAEA vencidos. | Según Tareas |
NotaCreditoReconciliacionJob |
NotaCreditoReconciliacionService.reconciliarYEmitirNC(ventanaDias) |
Reconciliación CAE↔CAEA: detecta tickets doblemente facturados y emite la NC que anula el CAE. | Diario (sugerido 0 30 2 * * ?) |
El job más crítico: reconciliación de doble facturación
NotaCreditoReconciliacionJob es la pieza más sensible: evita que un mismo ticket quede facturado dos veces en AFIP (una fila CAE + una CAEA con la misma identidad de ticket). Si detecta el cruce, emite la Nota de Crédito CAE que anula el CAE. La ventana hacia atrás es configurable: nc.reconciliacion.ventana-dias (default 35 días, para cubrir CAEAs informados con retraso). Ver Configuración y perfiles.
Por qué TokenManagerJob corre tan seguido
Correrlo cada par de minutos es barato: tokenProvider.get() reutiliza el TA vigente (cache memoria/DB) y solo emite un LoginCms cuando expiró. Así renueva dentro del intervalo apenas el TA caduca y el POS nunca pega contra un TA frío. El TA queda persistido en EnteFacturador y sobrevive reinicios.
Alta de un job nuevo¶
Insertás una fila en Tareas. Ejemplo (del propio NotaCreditoReconciliacionJob):
INSERT INTO Tareas (Id, Descripcion, Programacion, JobProcessor)
VALUES ('NC_RECONCILIACION', 'Reconciliacion NC CAE-CAEA',
'0 30 2 * * ?', 'NotaCreditoReconciliacionJob');
Programacion es cron de Quartz: seg min hora díaMes mes díaSemana [año]. El alta se toma en el próximo arranque (el scheduler lee Tareas en ApplicationReadyEvent).
Locking de ejecución (ThreadSafety)¶
Antes de cada corrida, CommonJob.startExecute toma el lock de ThreadSafety.setExecuting(tarea); finallyDo lo libera con setAvailable(tarea). Es un mapa estático sincronizado (Map<Tarea,String>): si la Tarea ya está en el mapa, setExecuting devuelve false y el job no corre (se loguea MULTIPLE EJECUCION). Esto garantiza un solo proceso por Tarea a la vez (INVARIANTE §5.13), aun si un cron dispara mientras la corrida anterior sigue viva.
El lock es por-JVM, no distribuido
ThreadSafety es un mapa en memoria del proceso. Protege contra solapamiento dentro de la misma instancia, no entre instancias. Con RAMJobStore y un único deploy esto alcanza; si algún día corren varias instancias contra la misma DB, el locking habría que repensarlo (es una de las invariantes del sistema, §5.13).
Auditoría de ejecuciones (JobEjecucion)¶
Cada corrida deja una fila en la tabla JobEjecucion (entidad model/job/JobEjecucion.java), que graba CommonJob.finallyDo vía JobEjecucionRegistro:
| Columna | Significado |
|---|---|
tareaId |
Id de la Tarea. |
jobNombre |
Descripción legible. |
inicio / fin |
Timestamps de la corrida. |
resultado |
'OK' o 'ERROR'. |
error |
Detalle si falló (truncado a 2000 chars); null si OK. |
El registro sobrevive al rollback del job
JobEjecucionRegistro.registrar(...) corre con @Transactional(REQUIRES_NEW): en su propia transacción. Así la bitácora se asienta aunque la transacción del job haya hecho rollback. Y como los jobs tragan la excepción (igual que el legacy), registrarError la reporta igual para que quede en la bitácora y la vea el cockpit. La tabla JobEjecucion la administra el cliente (ddl-auto=none); ver db/migration/add-JobEjecucion.sql.
El cockpit muestra la última ejecución por job: JobEjecucionRegistro.ultimasPorTarea() (una fila por tareaId) más el próximo disparo del trigger Quartz (TareaScheduler.nextFireTime). Eso es lo que arma CockpitJobs para GET /api/cockpit/jobs. Ver Cockpit.
Por dónde seguir¶
- Cockpit — la grilla
/jobsque visualiza estas ejecuciones. - El ciclo de facturación — CAE, CAEA y por qué hace falta cada job.
- Configuración y perfiles — propiedades como
nc.reconciliacion.ventana-dias. - Setup del entorno — para ver los jobs agendarse al arrancar en local.