Files
HoruxDespachosNuevo/docs/plans/2026-04-25-despacho-tareas-papeleria.md

514 lines
20 KiB
Markdown

# Sesión 2026-04-25 — Módulo Despacho, Tareas y Papelería
Sesión enfocada en **features nuevos** del fork `Horux_despacho`. Los fixes
fiscales del día anterior están en
`docs/plans/2026-04-24-session-fixes-and-features.md`.
---
## Índice
1. [Banner "CSD recién tramitado"](#1-banner-csd-recién-tramitado)
2. [Preferencias de notificación por contribuyente](#2-preferencias-de-notificación-por-contribuyente)
3. [Tareas operativas (recurrentes)](#3-tareas-operativas-recurrentes)
4. [Papelería de Trabajo](#4-papelería-de-trabajo)
5. [Módulo "Despacho" — 3 páginas](#5-módulo-despacho-3-páginas)
6. [Selector de periodo global](#6-selector-de-periodo-global)
7. [Asignación de supervisor desde /usuarios](#7-asignación-de-supervisor-desde-usuarios)
8. [Ajustes de UI](#8-ajustes-de-ui)
9. [Migraciones aplicadas](#9-migraciones-aplicadas)
10. [Pendientes](#10-pendientes)
---
## 1. Banner "CSD recién tramitado"
### Problema
El SAT tarda 24-72h en propagar un CSD nuevo a la Lista de Contribuyentes
Obligados (LCO). Durante esa ventana, intentos de emisión vía Facturapi
fallan con mensajes tipo "RFC no encontrado en LCO". El user pierde
tiempo intentando emitir y abre tickets innecesarios.
### Solución
Banner contextual en `/facturacion` que aparece SOLO si hubo un rechazo
del SAT con patrón LCO en las últimas 24h.
### Archivos
- **Migración 033**: `facturapi_orgs.last_lco_rejection_at timestamptz`.
- `apps/api/src/controllers/facturacion.controller.ts`:
- Helper `isLcoRejection(message)` con regex contra patrones SAT.
- Helper `markLcoRejection(pool, contribuyenteId)` upsertea timestamp.
- En el catch de `emitir()` se dispara la marca cuando aplica.
- Endpoint `getLcoStatus` retorna `{ hasRecentLcoRejection, rejectedAt }`.
- `apps/api/src/routes/facturacion.routes.ts`: `GET /facturacion/lco-status`.
- `apps/web/app/(dashboard)/facturacion/page.tsx`: banner amber al inicio
del form, polling cada 15min.
### Mensaje
> CSD aún en proceso de validación — espera 24 horas antes de emitir tu factura.
### Auto-expiración
La condición es `rejectedAt > NOW() - 24h`. Pasadas las 24h el banner
desaparece sin necesidad de cron.
---
## 2. Preferencias de notificación por contribuyente
### Problema
Los emails informativos se enviaban siempre. El user quería poder
desactivar tipos específicos por contribuyente sin afectar a otros.
### Solución
JSONB `email_preferences` en `contribuyentes`; los handlers de envío
respetan la preferencia antes de mandar.
### Archivos
- **Migración 034**: `contribuyentes.email_preferences jsonb DEFAULT '{}'`.
- `apps/api/src/services/notification-preferences.service.ts`:
- `EMAIL_TYPES = ['documento_subido', 'weekly_update', 'subscription_expiring', 'recordatorio_fiscal']`.
- `getContribuyenteEmailPreferences()` con default true.
- `setContribuyenteEmailPreferences()` merge sobre JSONB.
- `apps/api/src/services/notify-upload.service.ts`: check
`prefs.documento_subido` antes de enviar.
- `apps/api/src/controllers/notification-preferences.controller.ts`:
`listPreferences` + `updatePreferences`.
- `apps/api/src/routes/notification-preferences.routes.ts`:
`GET / PUT /api/notificaciones`.
- `apps/web/app/(dashboard)/configuracion/notificaciones/page.tsx`:
toggles por tipo de email por contribuyente, optimistic update.
### Tipos de email
| Tipo | Estado | Notas |
|---|---|---|
| `documento_subido` | ✅ activo | check en `notify-upload.service.ts` |
| `weekly_update` | ⏳ próximamente | el job es tenant-wide, requiere refactor |
| `subscription_expiring` | ⏳ próximamente | no es per-contribuyente hoy |
| `recordatorio_fiscal` | ⏳ placeholder | para futuras alertas fiscales |
Los toggles "Próximamente" se persisten pero no bloquean el envío
todavía. Cuando se implemente cada email per-contribuyente, basta con
agregar el check.
### Default
Todo activado. Si la columna está vacía o le falta una key, asumimos
`true` para preservar comportamiento previo.
---
## 3. Tareas operativas (recurrentes)
Sistema completo de tareas operativas del despacho por contribuyente,
distinto del flujo de obligaciones fiscales (que ya existía).
### Características
- **Recurrencias**: semanal, quincenal, mensual, bimestral, trimestral,
semestral, anual.
- **Día de vencimiento**: día de la semana (1-7) o día del mes (1-31)
según recurrencia.
- **Estados**: pendiente / completada (en BD); "atrasada" se calcula en
frontend.
- **Permisos**:
- Rol `cliente`: bloqueado en TODOS los endpoints (403).
- Flag `solo_supervisor_completa`: si está en true, solo
owner/cfo/supervisor pueden marcarla completa.
### Archivos
- **Migración 035**:
```sql
tareas_catalogo (id, contribuyente_id, nombre, descripcion, recurrencia,
dia_semana, dia_mes, solo_supervisor_completa, es_default,
active, orden, created_at)
tarea_periodos (id, tarea_id, periodo, fecha_limite, completada,
completada_at, completada_por, notas, created_at)
```
- `apps/api/src/services/tareas.service.ts`:
- CRUD del catálogo.
- **Materialización lazy**: cuando el frontend pide tareas con periodo
actual, el backend genera periodos faltantes solo del presente en
adelante (códigos `2025-W12`, `2025-01`, `2025-B1`, `2025-Q1`,
`2025-S1`, `2025`).
- `seedTareasDefault()` con 4 tareas: estados de cuenta (día 5),
conciliación (día 10), contabilización (día 14), revisión fiscal
preliminar (día 15, `solo_supervisor_completa=true`).
- `getEventosTareasParaCalendario()` retorna shape `EventoFiscal` con
`tipo: 'tarea'`.
- `contarTareasProximasVencer()` para alertas auto.
- `getAuxiliarUserIdDeContribuyente()` resuelve cartera → auxiliar.
- `apps/api/src/controllers/tareas.controller.ts`:
- Bloqueo rol cliente.
- `notifyAuxiliarTareaCompletada()`: dispara email cuando una tarea
con `solo_supervisor_completa=true` se marca completa por
supervisor/owner — el auxiliar recibe aviso.
- `apps/api/src/routes/tareas.routes.ts`:
```
GET /api/tareas — list con periodos materializados
POST /api/tareas — crear
PATCH /api/tareas/:id — update
DELETE /api/tareas/:id — soft delete (active=false)
POST /api/tareas/seed — seed defaults
POST /api/tareas/periodo/:id/completar
DELETE /api/tareas/periodo/:id/completar
```
- `apps/api/src/services/email/templates/tarea-completada.ts`: template
para notificar al auxiliar.
- `apps/api/src/services/alertas-auto.service.ts`:
`alertaTareasProximasVencer()` (≤3 días, prioridad media), enchufada
en `generarAlertasAutomaticas`.
- `apps/api/src/controllers/calendario.controller.ts`: incluye tareas en
el GET de eventos cuando hay contribuyente seleccionado y rol no es
cliente.
- `packages/shared/src/types/calendario.ts`: agregado `'tarea'` a
`TipoEvento`.
### Frontend
- `apps/web/components/obligaciones/tareas-tab.tsx`: pestaña "Tareas" en
`/configuracion/obligaciones` con tabla, modal crear/editar, check
pendiente/completada.
- `apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx`: tabs
"Obligaciones" / "Tareas".
### Flujo email "tarea revisada"
1. Auxiliar trabaja en algo del contribuyente.
2. Supervisor/owner marca como completada la tarea
`solo_supervisor_completa=true` (ej. "Revisión fiscal preliminar").
3. Backend resuelve `auxiliar_user_id` desde la cartera del
contribuyente.
4. Email al auxiliar con el detalle (quién marcó, periodo, notas).
---
## 4. Papelería de Trabajo
Sección nueva en `/documentos` para subir archivos de trabajo del
despacho con flujo opcional de aprobación.
### Características
- **No accesible para usuarios rol cliente** (oculta + 403 en API).
- **Formatos permitidos**: PDF, Word (doc/docx), Excel (xls/xlsx).
- **Tamaño máximo**: 5 MB por archivo (validado backend + frontend).
- **Periodo**: mes + año (selector en el modal de subida).
- **Aprobación opcional**: checkbox al subir; si está activo, queda en
estado `pendiente` y solo owner/supervisor pueden aprobar/rechazar.
- **Comentario opcional al rechazar**.
### Archivos
- **Migración 036**:
```sql
papeleria_trabajo (id, contribuyente_id, nombre, descripcion,
archivo bytea, archivo_filename, archivo_mime,
archivo_size, anio, mes,
requiere_aprobacion, estado, aprobado_por,
aprobado_at, comentario_rechazo, subido_por,
created_at)
```
Estados válidos: `pendiente | aprobado | rechazado`. NULL si no
requiere aprobación.
- `apps/api/src/services/papeleria.service.ts`:
- Validación MIME + tamaño.
- CRUD: `uploadPapeleria`, `listPapeleria` (filtros año/mes/estado),
`downloadArchivo`, `aprobar`, `rechazar`, `eliminar`.
- `aprobar`/`rechazar` validan rol owner/cfo/supervisor.
- `apps/api/src/controllers/papeleria.controller.ts`:
- Bloqueo rol cliente con `rejectClienteRole()`.
- Schema Zod estricto.
- **Notificaciones**:
- Al subir con `requiere_aprobacion=true`: email a owners + supervisores.
- Al aprobar/rechazar: email al uploader (excepto si es el mismo).
- Excepción al uploader: si el aprobador es el mismo user que subió
(ej. owner que sube su propia papelería), no se auto-notifica.
- `apps/api/src/services/email/templates/papeleria.ts`: 2 templates
(`papeleriaAprobacionRequeridaEmail` + `papeleriaDecisionEmail`).
- `apps/api/src/routes/papeleria.routes.ts`:
```
GET /api/papeleria — list con filtros
POST /api/papeleria — upload (base64 en JSON body)
GET /api/papeleria/:id/download — descarga binaria
POST /api/papeleria/:id/aprobar
POST /api/papeleria/:id/rechazar — body: { comentario? }
DELETE /api/papeleria/:id
```
### Frontend
- `apps/web/components/documentos/papeleria-tab.tsx`: pestaña con
filtros (año, mes, estado), modal de upload con base64 conversion,
badges de estado, modal de rechazo con comentario.
- `apps/web/app/(dashboard)/documentos/page.tsx`: pestaña condicional
`if (user.role !== 'cliente')`.
---
## 5. Módulo "Despacho" — 3 páginas
Reemplazo del módulo `/pendientes` con sub-secciones específicas por
rol. **Sidebar** renombrado: "Pendientes" → "Despacho".
### Estructura
```
/despachos → redirect según rol
/despachos/contribuyentes → owner/cfo (métricas globales)
/despachos/mis-asignados → owner/cfo/supervisor/auxiliar
/despachos/equipo → owner/cfo/supervisor (jerárquico)
```
### Sub-nav común
- `apps/web/components/despachos/despacho-subnav.tsx`: tabs visibles
según rol del user (filtrado en frontend).
- `defaultDespachoPathForRole(role)`: helper para el redirect en
`/despachos`.
### 5a. `/despachos/contribuyentes` (owner-only)
7 cards de métricas globales del despacho, todas filtrables por periodo:
| Card | Fuente | Notas |
|---|---|---|
| Total contribuyentes | `entidades_gestionadas` activos | independiente del periodo |
| Última extracción SAT | `MAX(sat_sync_jobs.completed_at)` BD central | independiente del periodo |
| Progreso del mes (%) | `obligaciones+tareas completadas / total` | barra de color |
| Declaraciones presentadas | `declaraciones_provisionales` con `created_at` en periodo | |
| Declaraciones pagadas | subset con `pdf_pago IS NOT NULL` | |
| Declaraciones atrasadas | obligaciones de declaración pendientes con `periodo < seleccionado` | heurística por categoría |
| Tareas atrasadas | tareas pendientes con `fecha_limite < primer día` | |
**Heurística "es declaración"** (sin flag explícito en el catálogo):
```sql
LOWER(categoria) ~ 'mensual|anual|declarac' OR LOWER(nombre) LIKE '%declarac%'
```
Si crece la base se puede agregar columna `es_declaracion boolean` y
ajustar.
### 5b. `/despachos/mis-asignados`
Tabla con contribuyentes filtrada por cartera del user:
| Rol | Filtro |
|---|---|
| owner / cfo | TODOS los contribuyentes |
| supervisor | contribuyentes en sus carteras top-level + subcarteras |
| auxiliar | contribuyentes en carteras donde es `auxiliar_user_id` |
**Columnas**:
- Contribuyente (link → `/configuracion/obligaciones` con contribuyente
preseleccionado).
- Cartera.
- **Avance** (barra de color verde≥80% / ámbar 50-79% / rojo <50% +
porcentaje numérico).
- **Atrasos** — badge agregado de obligaciones+tareas atrasadas; "Al
día" si no tiene.
- Obl. periodo (completadas / pendientes).
- Tareas periodo (completadas / pendientes).
**Resaltado de filas** con atrasos: `bg-red-50/50` para llamar la
atención.
**Orden**: por suma de atrasos descendente (rezagados arriba).
### 5c. `/despachos/equipo`
Vista jerárquica supervisor → auxiliares. Click en un supervisor
**expande** debajo a sus auxiliares.
**Resolución de la relación supervisor → auxiliar** (UNION de 3
fuentes):
```sql
1. carteras top-level con auxiliar_user_id + supervisor_user_id directos
2. subcarteras con auxiliar_user_id; supervisor del cartera padre
3. tabla legacy auxiliar_supervisores (fallback / override desde /usuarios)
```
**Sección "Auxiliares sin supervisor asignado"**: si después del UNION
queda un auxiliar activo (rol `auxiliar` en `tenant_memberships`) sin
supervisor, aparece en una sección al final con icono de advertencia
ámbar — solo el owner la ve.
**Permisos**:
- owner/cfo: ve TODOS los supervisores con sus auxiliares + huérfanos.
- supervisor: ve solo a sí mismo con sus auxiliares (sin huérfanos).
**Métricas por miembro**:
- Contribuyentes asignados.
- Avance del periodo (barra de color, igual que mis-asignados).
- Atrasos (obligaciones + tareas).
### Backend
- `apps/api/src/services/despacho-stats.service.ts`:
- `getContribuyentesStats(pool, tenantId, año?, mes?)`.
- `getMisAsignados(pool, userId, userRole, año?, mes?)`.
- `getEquipoStats(pool, userId, userRole, tenantId, año?, mes?)`.
- `apps/api/src/controllers/despacho-stats.controller.ts`: 3 endpoints
con guards de rol.
- `apps/api/src/routes/despacho-stats.routes.ts`: mountea en
`/api/despachos`.
---
## 6. Selector de periodo global
### Problema
Antes solo el mes en curso era visible. El user quiere navegar a otros
periodos para revisar avance histórico.
### Solución
Wrapper `<PeriodoSelector />` alrededor del `<PeriodSelector />` de
`@horux/shared-ui` (mismo patrón visual que `/dashboard`, `/impuestos`,
`/reportes`). Persiste selección en `localStorage` via Zustand.
### Archivos
- `apps/web/stores/periodo-store.ts`:
- `fechaInicio` / `fechaFin` (strings ISO YYYY-MM-DD).
- Default: primer y último día del mes en curso.
- Helper `añoMesFromFechaInicio()` para queries que usan año/mes.
- `apps/web/components/periodo-selector.tsx`: wrapper que se pasa como
children al `<Header>` en cada página `/despachos/*`.
### Endpoints que aceptan el filtro
- `GET /api/despachos/contribuyentes-stats?año=YYYY&mes=MM`
- `GET /api/despachos/mis-asignados?año=YYYY&mes=MM`
- `GET /api/despachos/equipo-stats?año=YYYY&mes=MM`
Default: mes en curso si no vienen los params.
---
## 7. Asignación de supervisor desde /usuarios
### Problema
Al invitar un auxiliar se pedía supervisor, pero **no había forma de
cambiarlo después**. Al abrir el modal aparecía "none" porque el
endpoint solo leía de `auxiliar_supervisores` ignorando carteras.
### Solución
Endpoint que consulta TODAS las fuentes de la relación supervisor↔auxiliar
y un modal "Supervisor" en `/usuarios`.
### Archivos backend
- `apps/api/src/controllers/usuarios.controller.ts`:
- `getSupervisor`: UNION priorizada — `auxiliar_supervisores` (1) →
cartera directa (2) → cartera padre de subcartera (3). El primer
resultado gana.
- `updateSupervisor`: upsert en `auxiliar_supervisores` (override
explícito sobre la cartera). Pasar `supervisorUserId: null` borra
la asignación.
- `apps/api/src/routes/usuarios.routes.ts`:
```
GET /api/usuarios/:id/supervisor
PUT /api/usuarios/:id/supervisor
```
### Frontend
- Botón "Supervisor" (icono `UserCheck`) en cada fila de auxiliar,
visible solo en tenant tipo despacho y para admins.
- Modal con `<Select>` de supervisores + opción "Sin supervisor
asignado".
### Trade-off
La tabla `auxiliar_supervisores` actúa como **override** sobre la
cartera. Si después de asignar via cartera, el owner cambia el
supervisor desde `/usuarios`, queda guardado en `auxiliar_supervisores`
y eso prevalece. Si quieres revertir, borrar la fila desde
`/usuarios` (opción "Sin supervisor asignado").
---
## 8. Ajustes de UI
### 8a. Sidebar sin selector de contribuyente
El `<ContribuyenteSelector />` ya estaba en el header — duplicado en
sidebar agregaba ruido. Removido del sidebar (`apps/web/components/layouts/sidebar.tsx`).
### 8b. Selector contribuyente oculto en `/despachos/*`
Las 3 vistas de Despacho son cross-contribuyente (métricas agregadas,
mis asignados es la lista completa, equipo es jerarquía). Filtrar por
contribuyente individual rompe el propósito.
`apps/web/components/contribuyente-selector.tsx`: array `HIDDEN_PATHS`
con `/despachos`. Si el pathname matchea, retorna `null` antes de
renderizar.
### 8c. Header
Selector de periodo se pasa como **children del `<Header>`** (al lado
del título), igual que en `/dashboard` — no en el lado derecho.
### 8d. PageNotificaciones centrada
`<main className="... mx-auto">` agregado.
### 8e. Configuración Obligaciones Fiscales — Header
Faltaba el `<Header />` en la página `/configuracion/obligaciones` así
que perdía el selector global. Agregado.
---
## 9. Migraciones aplicadas
| # | Archivo | Tabla / cambio |
|---|---|---|
| 033 | `033_facturapi_orgs_lco_rejection.sql` | `facturapi_orgs.last_lco_rejection_at` |
| 034 | `034_contribuyentes_email_preferences.sql` | `contribuyentes.email_preferences jsonb` |
| 035 | `035_tareas.sql` | `tareas_catalogo` + `tarea_periodos` |
| 036 | `036_papeleria_trabajo.sql` | `papeleria_trabajo` |
Aplicadas vía `pnpm db:migrate-tenants` en ambos tenants
(`DESPACHO_MO7JE8BZ_VDOPR` y `DESPACHO_MO3NI6U8_B9VGG`).
---
## 10. Pendientes
### Tareas
- Soporte de tipo "one-time" (no recurrente). Hoy todas son recurrentes.
- Asignación explícita de tarea a un user específico (hoy se asume
cartera).
### Notificaciones
- Implementar el envío per-contribuyente para `weekly_update`,
`subscription_expiring`, `recordatorio_fiscal` (los 3 toggles
"Próximamente"). Requiere refactor del cron de weekly-update para que
itere por contribuyente en lugar de por tenant.
### Métricas Despacho
- "Es declaración" en obligaciones: agregar flag explícito al catálogo
(`obligaciones_contribuyente.es_declaracion boolean`) y migrar la
heurística actual de regex.
- Drill-down: click en cada card del módulo Contribuyentes lleva a una
vista detallada (ej. listado de declaraciones atrasadas con
contribuyente y periodo).
### Equipo
- Mostrar % avance histórico (gráfica) de cada miembro a lo largo de
varios periodos.
- Asignar manualmente contribuyentes a un user desde la vista de equipo
(hoy solo via carteras).
### Papelería
- Versionado: si el user sube un nuevo archivo del mismo "concepto",
guardar histórico de versiones (hoy crea fila independiente cada vez).
- Filtros por uploader.
### Bugs reportados (por revisar)
- **Visibilidad de auxiliares en carteras de supervisores**: en la
pantalla de carteras, el supervisor está viendo a auxiliares que NO
están asignados a él. Debe filtrar para que cada supervisor solo vea
los auxiliares en sus propias carteras (top-level + subcarteras
donde es supervisor).
- **Tareas completadas > pendientes en módulo Despacho**: las métricas
muestran más tareas completadas de las que existen pendientes —
aritméticamente raro. Posible causa: el conteo de "completadas"
incluye periodos históricos mientras que "pendientes" se limita al
periodo seleccionado. Validar que ambos lados del ratio usen el
mismo universo (mes filtrado) o documentar explícitamente la
asimetría si es intencional.
- **Drill-down de alerta TipoRelacion sospechoso incompleto**: no
están saliendo todos los CFDIs tipo E con posible TipoRelacion
errónea en el listado. Revisar la heurística en
`apps/api/src/services/alertas-auto.service.ts` (constante
`SOSPECHOSA_TIPO_RELACION_WHERE` y reuso en
`getCfdisTipoRelacionSospechosa` del controller). Posibles causas:
(a) operador `&&` de PostgreSQL sobre `string_to_array` no detecta
algunos casos por case-sensitivity o caracteres extraños en
`cfdis_relacionados`; (b) el filtro excluye E con
`cfdi_tipo_relacion=NULL` cuando algunas inconsistencias podrían
tener ese estado; (c) RFC del contribuyente no se aplica
uniformemente en el JOIN.