Files
HoruxDespachosNuevo/apps/api/src/services/papeleria.service.ts
Horux Dev 9b535354fb feat(papeleria): aprobación independiente por cliente
- Agrega migración 050 con columnas de aprobación de cliente
  (requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, etc.)
- Backend: endpoints /aprobar-cliente y /rechazar-cliente con validación de permisos
- Backend: list/download permiten acceso a clientes filtrando por entidades visibles
- Backend: notificación por email a clientes cuando se les solicita aprobación
- Frontend: checkbox independiente para solicitar aprobación del cliente
- Frontend: badge de estado combinado (owner + cliente)
- Frontend: botones de aprobar/rechazar para clientes en su propio flujo
2026-05-29 00:36:33 +00:00

296 lines
8.8 KiB
TypeScript

import type { Pool } from 'pg';
export const ALLOWED_MIMES = new Set([
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx
]);
export const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
export type EstadoPapeleria = 'pendiente' | 'aprobado' | 'rechazado';
export interface PapeleriaItem {
id: number;
contribuyenteId: string;
nombre: string;
descripcion: string | null;
archivoFilename: string;
archivoMime: string;
archivoSize: number;
anio: number;
mes: number;
requiereAprobacion: boolean;
estado: EstadoPapeleria | null;
aprobadoPor: string | null;
aprobadoAt: Date | null;
comentarioRechazo: string | null;
requiereAprobacionCliente: boolean;
estadoCliente: EstadoPapeleria | null;
aprobadoPorCliente: string | null;
aprobadoAtCliente: Date | null;
comentarioRechazoCliente: string | null;
subidoPor: string;
createdAt: Date;
}
const SELECT = `
id, contribuyente_id, nombre, descripcion,
archivo_filename, archivo_mime, archivo_size,
anio, mes,
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, aprobado_at_cliente, comentario_rechazo_cliente,
subido_por, created_at
`;
const ROW = (r: any): PapeleriaItem => ({
id: r.id,
contribuyenteId: r.contribuyente_id,
nombre: r.nombre,
descripcion: r.descripcion,
archivoFilename: r.archivo_filename,
archivoMime: r.archivo_mime,
archivoSize: r.archivo_size,
anio: r.anio,
mes: r.mes,
requiereAprobacion: r.requiere_aprobacion,
estado: r.estado,
aprobadoPor: r.aprobado_por,
aprobadoAt: r.aprobado_at,
comentarioRechazo: r.comentario_rechazo,
requiereAprobacionCliente: r.requiere_aprobacion_cliente,
estadoCliente: r.estado_cliente,
aprobadoPorCliente: r.aprobado_por_cliente,
aprobadoAtCliente: r.aprobado_at_cliente,
comentarioRechazoCliente: r.comentario_rechazo_cliente,
subidoPor: r.subido_por,
createdAt: r.created_at,
});
function sanitizeUuid(id: string): string {
return id.replace(/[^a-f0-9-]/gi, '');
}
export interface UploadInput {
contribuyenteId: string;
nombre: string;
descripcion: string | null;
anio: number;
mes: number;
requiereAprobacion: boolean;
requiereAprobacionCliente: boolean;
archivo: Buffer;
archivoFilename: string;
archivoMime: string;
subidoPor: string;
}
export async function uploadPapeleria(
pool: Pool,
input: UploadInput,
): Promise<PapeleriaItem> {
if (!ALLOWED_MIMES.has(input.archivoMime)) {
throw new Error(`Formato no permitido: ${input.archivoMime}. Solo PDF, Word y Excel.`);
}
if (input.archivo.length > MAX_SIZE_BYTES) {
throw new Error(`Archivo excede el máximo de 5 MB (recibido ${(input.archivo.length / 1024 / 1024).toFixed(1)} MB).`);
}
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
const { rows: [r] } = await pool.query(
`INSERT INTO papeleria_trabajo
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
anio, mes, requiere_aprobacion, estado, requiere_aprobacion_cliente, estado_cliente, subido_por)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING ${SELECT}`,
[
sanitizeUuid(input.contribuyenteId),
input.nombre,
input.descripcion,
input.archivo,
input.archivoFilename,
input.archivoMime,
input.archivo.length,
input.anio,
input.mes,
input.requiereAprobacion,
estadoInicial,
input.requiereAprobacionCliente,
estadoClienteInicial,
input.subidoPor,
],
);
return ROW(r);
}
export interface ListFilters {
contribuyenteId: string;
anio?: number;
mes?: number;
estado?: EstadoPapeleria | 'sin_aprobacion';
entidadIds?: string[];
userRole?: string;
}
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
const conds: string[] = ['contribuyente_id = $1'];
const vals: unknown[] = [sanitizeUuid(f.contribuyenteId)];
let i = 2;
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
if (f.estado === 'sin_aprobacion') {
conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
} else if (f.estado) {
conds.push(`estado = $${i++}`); vals.push(f.estado);
}
if (f.entidadIds && f.entidadIds.length > 0) {
conds.push(`contribuyente_id = ANY($${i++})`);
vals.push(f.entidadIds);
}
if (f.userRole === 'cliente') {
conds.push('requiere_aprobacion_cliente = true');
}
const { rows } = await pool.query(
`SELECT ${SELECT} FROM papeleria_trabajo
WHERE ${conds.join(' AND ')}
ORDER BY anio DESC, mes DESC, created_at DESC`,
vals,
);
return rows.map(ROW);
}
export async function getById(pool: Pool, id: number): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`SELECT ${SELECT} FROM papeleria_trabajo WHERE id = $1`,
[id],
);
return r ? ROW(r) : null;
}
export async function downloadArchivo(
pool: Pool,
id: number,
): Promise<{ archivo: Buffer; filename: string; mime: string } | null> {
const { rows: [r] } = await pool.query(
`SELECT archivo, archivo_filename, archivo_mime FROM papeleria_trabajo WHERE id = $1`,
[id],
);
if (!r) return null;
return { archivo: r.archivo, filename: r.archivo_filename, mime: r.archivo_mime };
}
const ROLES_APROBADOR = new Set(['owner', 'cfo', 'supervisor']);
export async function aprobar(
pool: Pool,
id: number,
userId: string,
userRole: string,
): Promise<PapeleriaItem | null> {
if (!ROLES_APROBADOR.has(userRole)) {
throw new Error('Solo owner o supervisor pueden aprobar papelería');
}
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado = 'aprobado', aprobado_por = $2, aprobado_at = NOW(),
comentario_rechazo = NULL
WHERE id = $1 AND requiere_aprobacion = true
RETURNING ${SELECT}`,
[id, userId],
);
return r ? ROW(r) : null;
}
export async function rechazar(
pool: Pool,
id: number,
userId: string,
userRole: string,
comentario: string | null,
): Promise<PapeleriaItem | null> {
if (!ROLES_APROBADOR.has(userRole)) {
throw new Error('Solo owner o supervisor pueden rechazar papelería');
}
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado = 'rechazado', aprobado_por = $2, aprobado_at = NOW(),
comentario_rechazo = $3
WHERE id = $1 AND requiere_aprobacion = true
RETURNING ${SELECT}`,
[id, userId, comentario],
);
return r ? ROW(r) : null;
}
export async function aprobarCliente(
pool: Pool,
id: number,
userId: string,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'aprobado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = NULL
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId],
);
return r ? ROW(r) : null;
}
export async function rechazarCliente(
pool: Pool,
id: number,
userId: string,
comentario: string | null,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'rechazado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = $3
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId, comentario],
);
return r ? ROW(r) : null;
}
export async function eliminar(pool: Pool, id: number): Promise<boolean> {
const { rowCount } = await pool.query(
`DELETE FROM papeleria_trabajo WHERE id = $1`,
[id],
);
return (rowCount ?? 0) > 0;
}
/**
* Calcula el estado visual combinado considerando ambas aprobaciones.
*/
export function estadoGlobal(item: PapeleriaItem): 'pendiente' | 'aprobado' | 'rechazado' | null {
const reqOwner = item.requiereAprobacion;
const reqCliente = item.requiereAprobacionCliente;
const estOwner = item.estado;
const estCliente = item.estadoCliente;
if (!reqOwner && !reqCliente) return null;
// Si cualquiera está rechazado, el documento está rechazado
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
// Si ambos requieren aprobación
if (reqOwner && reqCliente) {
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
return 'pendiente';
}
// Solo owner
if (reqOwner) return estOwner;
// Solo cliente
return estCliente;
}