Saltar a contenido

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):

  1. En @EventListener(ApplicationReadyEvent.class) recorre TareaList.instance().getResultList() (todas las filas de Tareas).
  2. Por cada Tarea resuelve la clase del job desde jobProcessor (nombre simple → se le antepone el paquete com.tipre.tifacturaonlinemanager.action.job.; o FQCN si trae punto), valida que implemente org.quartz.Job y arma un JobDetail + CronTrigger con el cron de programacion.
  3. Guarda la Tarea en un REGISTRY estático (id → Tarea) para que CommonJob la recupere en cada ejecución (el id viaja como description del JobDetail).

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