Files
HoruxDespachosNuevo/apps/api/src/services/tareas.service.ts
Horux Dev f43cb165c6 feat: asignaciones obligaciones/tareas + fixes backend
- Migracion 046: tablas obligacion_asignaciones y tarea_asignaciones
- Servicio y controller de asignaciones (CRUD + listados)
- Fix: enviar correo welcome al invitar usuario nuevo
- Fix: quitar JOIN users de queries tenant (usar Prisma en BD central)
- Fix: req.params.obligacionId correcto en asignaciones controller
- Fix: orden rutas estaticas antes de dinamicas en cartera.routes
- Fix: owner/cfo ven todas las asignaciones en getAsignacionesPorSupervisor
- Fix: validar que entidad pertenezca a cartera padre en subcartera
- Nuevo endpoint GET /carteras/asignaciones/sin-asignar
- Nuevo endpoint GET /tareas/mis-tareas
2026-05-23 23:40:12 +00:00

529 lines
18 KiB
TypeScript

import type { Pool } from 'pg';
export type Recurrencia =
| 'semanal' | 'quincenal' | 'mensual'
| 'bimestral' | 'trimestral' | 'semestral' | 'anual';
export interface TareaCatalogo {
id: string;
contribuyenteId: string;
nombre: string;
descripcion: string | null;
recurrencia: Recurrencia;
diaSemana: number | null;
diaMes: number | null;
soloSupervisorCompleta: boolean;
esDefault: boolean;
active: boolean;
orden: number;
createdAt: Date;
auxiliarAsignadoId?: string | null;
auxiliarAsignadoNombre?: string | null;
}
export interface TareaPeriodo {
id: string;
tareaId: string;
periodo: string;
fechaLimite: Date;
completada: boolean;
completadaAt: Date | null;
completadaPor: string | null;
notas: string | null;
}
export interface TareaConPeriodo extends TareaCatalogo {
periodoActual: TareaPeriodo | null;
}
const ROW_TO_TAREA = (r: any): TareaCatalogo => ({
id: r.id,
contribuyenteId: r.contribuyente_id,
nombre: r.nombre,
descripcion: r.descripcion,
recurrencia: r.recurrencia,
diaSemana: r.dia_semana,
diaMes: r.dia_mes,
soloSupervisorCompleta: r.solo_supervisor_completa,
esDefault: r.es_default,
active: r.active,
orden: r.orden,
createdAt: r.created_at,
auxiliarAsignadoId: r.auxiliarAsignadoId ?? null,
auxiliarAsignadoNombre: r.auxiliarAsignadoNombre ?? null,
});
const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({
id: r.id,
tareaId: r.tarea_id,
periodo: r.periodo,
fechaLimite: r.fecha_limite,
completada: r.completada,
completadaAt: r.completada_at,
completadaPor: r.completada_por,
notas: r.notas,
});
function sanitizeUuid(id: string): string {
return id.replace(/[^a-f0-9-]/gi, '');
}
// ─── Catálogo CRUD ───
export async function listTareas(pool: Pool, contribuyenteId: string): Promise<TareaCatalogo[]> {
const { rows } = await pool.query(
`SELECT
tc.*,
ta.auxiliar_user_id AS "auxiliarAsignadoId"
FROM tareas_catalogo tc
LEFT JOIN tarea_asignaciones ta ON ta.tarea_id = tc.id
WHERE tc.contribuyente_id = $1 AND tc.active = true
ORDER BY tc.orden, tc.nombre`,
[sanitizeUuid(contribuyenteId)],
);
return rows.map(ROW_TO_TAREA);
}
export interface TareaInput {
nombre: string;
descripcion?: string | null;
recurrencia: Recurrencia;
diaSemana?: number | null;
diaMes?: number | null;
soloSupervisorCompleta?: boolean;
orden?: number;
}
export async function createTarea(
pool: Pool,
contribuyenteId: string,
data: TareaInput,
): Promise<TareaCatalogo> {
const { rows: [r] } = await pool.query(
`INSERT INTO tareas_catalogo
(contribuyente_id, nombre, descripcion, recurrencia,
dia_semana, dia_mes, solo_supervisor_completa, orden)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
sanitizeUuid(contribuyenteId),
data.nombre,
data.descripcion ?? null,
data.recurrencia,
data.diaSemana ?? null,
data.diaMes ?? null,
data.soloSupervisorCompleta ?? false,
data.orden ?? 0,
],
);
return ROW_TO_TAREA(r);
}
export async function updateTarea(
pool: Pool,
tareaId: string,
data: Partial<TareaInput>,
): Promise<TareaCatalogo | null> {
const fields: string[] = [];
const values: unknown[] = [];
let i = 1;
if (data.nombre !== undefined) { fields.push(`nombre = $${i++}`); values.push(data.nombre); }
if (data.descripcion !== undefined) { fields.push(`descripcion = $${i++}`); values.push(data.descripcion); }
if (data.recurrencia !== undefined) { fields.push(`recurrencia = $${i++}`); values.push(data.recurrencia); }
if (data.diaSemana !== undefined) { fields.push(`dia_semana = $${i++}`); values.push(data.diaSemana); }
if (data.diaMes !== undefined) { fields.push(`dia_mes = $${i++}`); values.push(data.diaMes); }
if (data.soloSupervisorCompleta !== undefined) { fields.push(`solo_supervisor_completa = $${i++}`); values.push(data.soloSupervisorCompleta); }
if (data.orden !== undefined) { fields.push(`orden = $${i++}`); values.push(data.orden); }
if (fields.length === 0) return null;
values.push(sanitizeUuid(tareaId));
const { rows: [r] } = await pool.query(
`UPDATE tareas_catalogo SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
values,
);
return r ? ROW_TO_TAREA(r) : null;
}
export async function deleteTarea(pool: Pool, tareaId: string): Promise<boolean> {
const { rowCount } = await pool.query(
`UPDATE tareas_catalogo SET active = false WHERE id = $1`,
[sanitizeUuid(tareaId)],
);
return (rowCount ?? 0) > 0;
}
// ─── Materialización de periodos ───
/** Convierte una fecha a su `periodo` según recurrencia. */
function periodoForDate(date: Date, recurrencia: Recurrencia): string {
const año = date.getFullYear();
const mes = date.getMonth() + 1;
if (recurrencia === 'semanal' || recurrencia === 'quincenal') {
return `${año}-W${String(isoWeek(date)).padStart(2, '0')}`;
}
if (recurrencia === 'mensual') return `${año}-${String(mes).padStart(2, '0')}`;
if (recurrencia === 'bimestral') return `${año}-B${Math.ceil(mes / 2)}`;
if (recurrencia === 'trimestral') return `${año}-Q${Math.ceil(mes / 3)}`;
if (recurrencia === 'semestral') return `${año}-S${Math.ceil(mes / 6)}`;
return `${año}`;
}
function isoWeek(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
}
/**
* Calcula la fecha límite del próximo periodo de una tarea, basado en
* `recurrencia`, `dia_semana`/`dia_mes` y un punto de referencia.
*
* Para semanal/quincenal: primera ocurrencia del dia_semana en/después de
* `fromDate`. (Quincenal alterna con el mismo dia_semana cada 14 días.)
* Para mensual+: dia_mes del periodo en curso de `fromDate`. Si el dia_mes
* excede el último día del mes, se clampa al último día.
*/
function computeFechaLimite(
recurrencia: Recurrencia,
diaSemana: number | null,
diaMes: number | null,
fromDate: Date,
): Date {
if (recurrencia === 'semanal' || recurrencia === 'quincenal') {
const target = (diaSemana ?? 5);
const d = new Date(fromDate);
const current = d.getDay() === 0 ? 7 : d.getDay();
const diff = (target - current + 7) % 7;
d.setDate(d.getDate() + diff);
return d;
}
// mensual a anual: usar el mes "ancla" del periodo
const año = fromDate.getFullYear();
let mesAncla = fromDate.getMonth() + 1;
if (recurrencia === 'bimestral') mesAncla = Math.ceil(mesAncla / 2) * 2;
else if (recurrencia === 'trimestral') mesAncla = Math.ceil(mesAncla / 3) * 3;
else if (recurrencia === 'semestral') mesAncla = Math.ceil(mesAncla / 6) * 6;
else if (recurrencia === 'anual') mesAncla = 12;
const lastDay = new Date(año, mesAncla, 0).getDate();
const dia = Math.min(diaMes ?? lastDay, lastDay);
return new Date(año, mesAncla - 1, dia);
}
/**
* Asegura que existan los periodos vigentes (presente + futuros próximos)
* para todas las tareas activas del contribuyente. Solo crea periodos
* cuya `fecha_limite >= today` (no retroactivos, igual que obligaciones).
*
* Para cada tarea genera el periodo CURRENT (donde cae hoy) si su fecha
* límite aún no ha pasado, o el NEXT si ya pasó.
*/
export async function materializarPeriodos(
pool: Pool,
contribuyenteId: string,
): Promise<void> {
const tareas = await listTareas(pool, contribuyenteId);
const today = new Date();
today.setHours(0, 0, 0, 0);
for (const t of tareas) {
let fechaLimite = computeFechaLimite(t.recurrencia, t.diaSemana, t.diaMes, today);
// Si la fecha límite calculada para "hoy" ya pasó, salta al siguiente periodo
while (fechaLimite < today) {
const next = new Date(fechaLimite);
const recurrenciaIncrement: Record<Recurrencia, () => void> = {
semanal: () => next.setDate(next.getDate() + 7),
quincenal: () => next.setDate(next.getDate() + 14),
mensual: () => next.setMonth(next.getMonth() + 1),
bimestral: () => next.setMonth(next.getMonth() + 2),
trimestral: () => next.setMonth(next.getMonth() + 3),
semestral: () => next.setMonth(next.getMonth() + 6),
anual: () => next.setFullYear(next.getFullYear() + 1),
};
recurrenciaIncrement[t.recurrencia]();
fechaLimite = computeFechaLimite(t.recurrencia, t.diaSemana, t.diaMes, next);
}
const periodo = periodoForDate(fechaLimite, t.recurrencia);
await pool.query(
`INSERT INTO tarea_periodos (tarea_id, periodo, fecha_limite)
VALUES ($1, $2, $3)
ON CONFLICT (tarea_id, periodo) DO NOTHING`,
[t.id, periodo, fechaLimite.toISOString().split('T')[0]],
);
}
}
/**
* Lee tareas activas del contribuyente con su periodo más cercano (vigente
* o pendiente). Materializa los faltantes antes de leer.
*/
export async function listTareasConPeriodoActual(
pool: Pool,
contribuyenteId: string,
): Promise<TareaConPeriodo[]> {
await materializarPeriodos(pool, contribuyenteId);
const tareas = await listTareas(pool, contribuyenteId);
if (tareas.length === 0) return [];
const ids = tareas.map(t => t.id);
const today = new Date().toISOString().split('T')[0];
const { rows } = await pool.query(
`SELECT DISTINCT ON (tarea_id) *
FROM tarea_periodos
WHERE tarea_id = ANY($1::uuid[])
AND (completada = false OR fecha_limite >= $2::date)
ORDER BY tarea_id, fecha_limite ASC`,
[ids, today],
);
const periodos = new Map(rows.map(r => [r.tarea_id, ROW_TO_PERIODO(r)]));
return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null }));
}
export interface TareaConContribuyente extends TareaConPeriodo {
contribuyenteRfc: string;
contribuyenteRazonSocial: string;
}
/**
* Lee tareas activas con periodo actual para una lista de contribuyentes.
* Útil para la vista "Mis Tareas".
*/
export async function listTareasConPeriodoPorContribuyentes(
pool: Pool,
contribuyenteIds: string[],
): Promise<TareaConContribuyente[]> {
if (contribuyenteIds.length === 0) return [];
// Materializar periodos para cada contribuyente en paralelo
await Promise.all(contribuyenteIds.map(id => materializarPeriodos(pool, id)));
const { rows: tareasRows } = await pool.query(
`SELECT tc.*, c.entidad_id AS "contribuyenteId",
c.rfc AS "contribuyenteRfc",
COALESCE(r.razon_social, c.rfc) AS "contribuyenteRazonSocial"
FROM tareas_catalogo tc
JOIN contribuyentes c ON c.entidad_id = tc.contribuyente_id
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
WHERE tc.contribuyente_id = ANY($1::uuid[]) AND tc.active = true
ORDER BY c.rfc, tc.orden, tc.nombre`,
[contribuyenteIds],
);
if (tareasRows.length === 0) return [];
const tareaIds = tareasRows.map((r: any) => r.id);
const today = new Date().toISOString().split('T')[0];
const { rows: periodoRows } = await pool.query(
`SELECT DISTINCT ON (tarea_id) *
FROM tarea_periodos
WHERE tarea_id = ANY($1::uuid[])
AND (completada = false OR fecha_limite >= $2::date)
ORDER BY tarea_id, fecha_limite ASC`,
[tareaIds, today],
);
const periodos = new Map(periodoRows.map((r: any) => [r.tarea_id, ROW_TO_PERIODO(r)]));
return tareasRows.map((r: any) => ({
...ROW_TO_TAREA(r),
contribuyenteId: r.contribuyenteId,
contribuyenteRfc: r.contribuyenteRfc,
contribuyenteRazonSocial: r.contribuyenteRazonSocial,
periodoActual: periodos.get(r.id) ?? null,
}));
}
// ─── Completar / descompletar periodo ───
const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']);
/**
* Marca un periodo como completado. Si la tarea tiene
* `solo_supervisor_completa=true`, valida que el rol del usuario sea
* owner/cfo/supervisor. Devuelve el periodo actualizado y la tarea
* (para que el caller pueda disparar notificaciones).
*/
export async function completarPeriodo(
pool: Pool,
periodoId: string,
userId: string,
userRole: string,
notas: string | null = null,
): Promise<{ periodo: TareaPeriodo; tarea: TareaCatalogo } | null> {
const { rows: [pRow] } = await pool.query(
`SELECT tp.*, tc.solo_supervisor_completa
FROM tarea_periodos tp
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
WHERE tp.id = $1`,
[sanitizeUuid(periodoId)],
);
if (!pRow) return null;
if (pRow.solo_supervisor_completa && !ROLES_SUPERVISOR.has(userRole)) {
throw new Error('Solo supervisor o owner pueden marcar esta tarea como completada');
}
const { rows: [updated] } = await pool.query(
`UPDATE tarea_periodos
SET completada = true, completada_at = NOW(), completada_por = $2, notas = $3
WHERE id = $1
RETURNING *`,
[sanitizeUuid(periodoId), userId, notas],
);
const { rows: [tareaRow] } = await pool.query(
`SELECT * FROM tareas_catalogo WHERE id = $1`,
[pRow.tarea_id],
);
return { periodo: ROW_TO_PERIODO(updated), tarea: ROW_TO_TAREA(tareaRow) };
}
export async function descompletarPeriodo(
pool: Pool,
periodoId: string,
): Promise<boolean> {
const { rowCount } = await pool.query(
`UPDATE tarea_periodos
SET completada = false, completada_at = NULL, completada_por = NULL
WHERE id = $1`,
[sanitizeUuid(periodoId)],
);
return (rowCount ?? 0) > 0;
}
// ─── Calendario / alertas ───
export interface TareaEventoCalendario {
titulo: string;
descripcion: string;
tipo: 'tarea';
fechaLimite: string;
recurrencia: string;
completado: boolean;
notas: string | null;
contribuyenteId: string;
tareaId: string;
periodoId: string;
}
/**
* Lee tareas + sus periodos del año para mostrar en /calendario. Materializa
* los faltantes antes de leer (mismo patrón que listTareasConPeriodoActual).
* Si no hay contribuyenteId, no retorna nada (las tareas son siempre por
* contribuyente).
*/
export async function getEventosTareasParaCalendario(
pool: Pool,
contribuyenteId: string,
año: number,
): Promise<TareaEventoCalendario[]> {
await materializarPeriodos(pool, contribuyenteId);
const { rows } = await pool.query(
`SELECT tp.id AS periodo_id, tp.tarea_id, tp.fecha_limite, tp.completada, tp.notas,
tc.nombre, tc.descripcion, tc.recurrencia, tc.contribuyente_id
FROM tarea_periodos tp
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
WHERE tc.contribuyente_id = $1
AND tc.active = true
AND EXTRACT(YEAR FROM tp.fecha_limite) = $2`,
[sanitizeUuid(contribuyenteId), año],
);
return rows.map(r => ({
titulo: r.nombre,
descripcion: r.descripcion ?? '',
tipo: 'tarea' as const,
fechaLimite: r.fecha_limite instanceof Date ? r.fecha_limite.toISOString().split('T')[0] : String(r.fecha_limite),
recurrencia: r.recurrencia,
completado: r.completada,
notas: r.notas,
contribuyenteId: r.contribuyente_id,
tareaId: r.tarea_id,
periodoId: r.periodo_id,
}));
}
/**
* Cuenta tareas próximas a vencer (≤3 días) para alertas auto. Solo cuenta
* los periodos pendientes del contribuyente filtrado o de todo el tenant.
*/
export async function contarTareasProximasVencer(
pool: Pool,
contribuyenteId: string | null | undefined,
): Promise<{ total: number; monto?: number }> {
const safeId = contribuyenteId ? sanitizeUuid(contribuyenteId) : null;
if (safeId) await materializarPeriodos(pool, safeId);
const cf = safeId ? `AND tc.contribuyente_id = '${safeId}'` : '';
const { rows: [r] } = await pool.query(
`SELECT COUNT(*)::int AS total
FROM tarea_periodos tp
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
WHERE tc.active = true
AND tp.completada = false
AND tp.fecha_limite >= CURRENT_DATE
AND tp.fecha_limite <= CURRENT_DATE + INTERVAL '3 days'
${cf}`,
);
return { total: r?.total || 0 };
}
/**
* Resuelve el auxiliar asignado a la cartera del contribuyente (si existe).
* Busca primero en subcarteras (más específico) y luego en la top-level.
* Retorna null si el contribuyente no está en ninguna cartera o ninguna
* tiene auxiliar asignado.
*/
export async function getAuxiliarUserIdDeContribuyente(
pool: Pool,
contribuyenteId: string,
): Promise<string | null> {
const { rows } = await pool.query<{ auxiliar_user_id: string | null; parent_id: string | null }>(
`SELECT c.auxiliar_user_id, c.parent_id
FROM cartera_entidades ce
JOIN carteras c ON c.id = ce.cartera_id
WHERE ce.entidad_id = $1
ORDER BY (c.parent_id IS NOT NULL) DESC, c.created_at DESC`,
[sanitizeUuid(contribuyenteId)],
);
for (const row of rows) {
if (row.auxiliar_user_id) return row.auxiliar_user_id;
}
return null;
}
// ─── Seed de tareas default ───
const TAREAS_DEFAULT: TareaInput[] = [
{ nombre: 'Solicitar estados de cuenta', descripcion: 'Pedir al cliente los estados de cuenta bancarios del mes', recurrencia: 'mensual', diaMes: 5, orden: 1 },
{ nombre: 'Realizar conciliación bancaria', descripcion: 'Conciliar los movimientos bancarios contra los CFDIs', recurrencia: 'mensual', diaMes: 10, orden: 2 },
{ nombre: 'Realizar contabilización', descripcion: 'Registrar los CFDIs y movimientos en la contabilidad', recurrencia: 'mensual', diaMes: 14, orden: 3 },
{ nombre: 'Revisión fiscal preliminar', descripcion: 'Revisar declaración antes de presentación (solo supervisor/owner)', recurrencia: 'mensual', diaMes: 15, soloSupervisorCompleta: true, orden: 4 },
];
export async function seedTareasDefault(
pool: Pool,
contribuyenteId: string,
): Promise<number> {
const existentes = await pool.query(
`SELECT 1 FROM tareas_catalogo WHERE contribuyente_id = $1 AND es_default = true LIMIT 1`,
[sanitizeUuid(contribuyenteId)],
);
if (existentes.rowCount && existentes.rowCount > 0) return 0;
let count = 0;
for (const t of TAREAS_DEFAULT) {
await pool.query(
`INSERT INTO tareas_catalogo
(contribuyente_id, nombre, descripcion, recurrencia, dia_mes, solo_supervisor_completa, es_default, orden)
VALUES ($1, $2, $3, $4, $5, $6, true, $7)`,
[
sanitizeUuid(contribuyenteId),
t.nombre,
t.descripcion ?? null,
t.recurrencia,
t.diaMes ?? null,
t.soloSupervisorCompleta ?? false,
t.orden ?? 0,
],
);
count++;
}
return count;
}