Initial commit - Horux Despachos NL
This commit is contained in:
467
apps/api/src/services/tareas.service.ts
Normal file
467
apps/api/src/services/tareas.service.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 * FROM tareas_catalogo
|
||||
WHERE contribuyente_id = $1 AND active = true
|
||||
ORDER BY orden, 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 }));
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
Reference in New Issue
Block a user