SEGURIDAD (agente IA): - Conversaciones: GET /ai/conversations/:id y el reanudar chat verifican propiedad (antes cualquier usuario leia/continuaba la conversacion de otro). - /ai/documents pasa a solo-admin (antes cualquien podia inyectar o borrar documentos de la base de conocimiento). DATOS / CORRUPCION: - Cotizador: elegir un cliente del maestro en el buscador ya no crea un lead fantasma "Registro previo (CRM anterior)" en el embudo. - Afluencia con oportunidadId inexistente: 404 en vez de crear sombra fantasma. - Cobranza: los 103 planes importados quedaron con vendedor asignado (7 con su asesor real segun el Excel, el resto al admin) y el motor de recordatorios cae al admin cuando un plan no tiene vendedor: antes los 411 recordatorios se registraban 'sin-destinatario' y no los veia nadie. - Backups: script reescrito para el host real (sqlite .backup + uploads + env en /root/backups) y cron instalado (antes: script que asumia docker y sin cron programado = cero respaldos). REPORTES / METRICAS: - Mi tablero: el embudo ya excluye cancelaciones reales (Cancelo/Cancelacion Apartado) y cuenta como vendidos Firma Cliente/Escriturado/Titulado (antes 41 "apartados" donde habia 13 activos y vendidos buscaba una etapa que no existe); tasa de descarte ya compara el mismo periodo. - Scoring: etapa real 'Agendo Cita' (antes 'AGENDO' nunca aplicaba) y los descartados/perdidos ya no reciben score. - ver_cobranza (IA): ignora planes cancelados y no cuenta como vencido el pago que vence hoy. UI / FLUJOS: - mi-dia: typo CANCO -> CANCELO (los cancelados reaparecen en Escrituracion); confirmar cita ya no retrocede la etapa de Cotizador/Aparto (boton y kanban). - alta-clientes: la firma ya no se borra al navegar fuera del paso de firmas; toast de exito solo cuando el API confirma; prefill de apartado trae direccion, estado civil y fecha de nacimiento desde el alta. - Auditoria: el filtro por usuario funciona (el front enviaba 'usuario' y el API esperaba 'usuarioId') y 'hasta' incluye todo el dia. - Contactos: paginacion aplicada despues de fusionar fuentes (antes saltaban o se repetian contactos entre paginas y 'Siguiente' se deshabilitaba antes de tiempo). - Buscador global: leads -> /hoy/, contactos -> /; el cotizador entiende ?cotizacion=<id> para retomar desde la liga. - Export mi-dia: columnas Desarrollo/Origen rellenas y sin duplicados. - Recibo de cobranza: "Recibio" es FRASA (el cliente paga, no recibe). - Alta de plan de cobranza: proyecto libre con sugerencias del catalogo real (antes limitado a 3 proyectos que no cubrian la cartera). Verificado en vivo: autorizacion IA (3x 403), filtro de auditoria, 404 de afluencia. Suites: 717 API + 124 alta-clientes + 115 cotizador + 92 cobranza + 89 landing + 32 mi-dia en verde. Backup de hoy generado en /root/backups.
106 lines
3.7 KiB
TypeScript
106 lines
3.7 KiB
TypeScript
import { jsPDF } from 'jspdf'
|
||
import autoTable from 'jspdf-autotable'
|
||
import type { AplicacionPago, Pago, PlanPago } from '../../types'
|
||
import { round2, saldoPlan } from '../cobranza'
|
||
import { formatoFechaCorta, formatoFechaLarga, formatoMoneda } from '../formatters'
|
||
import type { DocumentoPdf } from './estadoCuenta'
|
||
|
||
const MARGEN = 14
|
||
|
||
/** Folio corto del recibo: primeros 8 caracteres del id de la aplicación, en mayúsculas */
|
||
export function folioRecibo(aplicacion: AplicacionPago): string {
|
||
return aplicacion.id.slice(0, 8).toUpperCase()
|
||
}
|
||
|
||
/**
|
||
* Saldos que imprime el recibo, tomados de los TOTALES del plan y del pago
|
||
* (suma de montos − suma de pagado), no del historial de `AplicacionPago`:
|
||
* el enganche/apartado histórico vive en `Pago.montoPagado` sin fila de
|
||
* aplicación, así que acumular solo aplicaciones daba saldos falsos
|
||
* (incluso negativos). El saldo nunca puede ser negativo porque un abono
|
||
* jamás excede el restante de su pago.
|
||
*/
|
||
export function saldosDelRecibo(plan: PlanPago, pago: Pago): { restantePago: number; saldoPlan: number } {
|
||
return {
|
||
restantePago: round2(pago.monto - pago.montoPagado),
|
||
saldoPlan: saldoPlan(plan),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Construye el recibo de pago de una aplicación (abono) ya registrada.
|
||
* El restante del pago y el saldo del plan son los totales vigentes del plan
|
||
* (ver `saldosDelRecibo`).
|
||
*/
|
||
export function crearDocumento(
|
||
plan: PlanPago,
|
||
pago: Pago,
|
||
aplicacion: AplicacionPago,
|
||
hoy: Date = new Date(),
|
||
): DocumentoPdf {
|
||
const folio = folioRecibo(aplicacion)
|
||
const doc = new jsPDF()
|
||
let y = MARGEN
|
||
|
||
const { restantePago, saldoPlan: saldo } = saldosDelRecibo(plan, pago)
|
||
|
||
// 1. Encabezado
|
||
doc.setFontSize(14)
|
||
doc.setFont('helvetica', 'bold')
|
||
doc.text('FRASA DESARROLLOS', 105, y, { align: 'center' })
|
||
y += 7
|
||
doc.setFontSize(11)
|
||
doc.text('RECIBO DE PAGO', 105, y, { align: 'center' })
|
||
y += 6
|
||
doc.setFontSize(9)
|
||
doc.setFont('helvetica', 'normal')
|
||
doc.text(`Folio: ${folio} · Fecha de emisión: ${formatoFechaLarga(hoy)}`, 105, y, { align: 'center' })
|
||
y += 8
|
||
|
||
// 2. Datos del recibo
|
||
autoTable(doc, {
|
||
startY: y,
|
||
margin: { left: MARGEN, right: MARGEN },
|
||
styles: { fontSize: 9 },
|
||
body: [
|
||
['CLIENTE', plan.clienteNombre],
|
||
['UNIDAD', plan.unidadCodigo],
|
||
['PROYECTO', plan.proyecto],
|
||
['PAGO', `#${pago.numPago} (vencimiento ${formatoFechaCorta(pago.fechaVencimiento)})`],
|
||
['FECHA DE PAGO', formatoFechaCorta(aplicacion.fechaPago)],
|
||
['MONTO DEL ABONO', formatoMoneda(aplicacion.monto)],
|
||
['REFERENCIA', aplicacion.referencia || '—'],
|
||
['RESTANTE DEL PAGO', formatoMoneda(restantePago)],
|
||
['SALDO DEL PLAN (a la fecha de emisión)', formatoMoneda(saldo)],
|
||
],
|
||
theme: 'plain',
|
||
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { halign: 'left' } },
|
||
})
|
||
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 10
|
||
|
||
// 3. Leyenda informativa
|
||
doc.setFontSize(8)
|
||
doc.text(
|
||
'Recibo informativo del abono aplicado al plan de enganche. No sustituye la factura ni el comprobante fiscal correspondiente.',
|
||
MARGEN,
|
||
y,
|
||
{ maxWidth: 180 },
|
||
)
|
||
y += 24
|
||
|
||
// 4. Línea de firma
|
||
doc.setFontSize(9)
|
||
doc.text('_______________________________', 105, y, { align: 'center' })
|
||
y += 5
|
||
// Quien recibe el pago es FRASA (el cliente es quien paga)
|
||
doc.text('Recibió — FRASA DESARROLLOS S.A.P.I. DE C.V.', 105, y, { align: 'center' })
|
||
|
||
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
|
||
}
|
||
|
||
/** Genera y descarga el recibo de pago en PDF */
|
||
export function descargarRecibo(plan: PlanPago, pago: Pago, aplicacion: AplicacionPago): void {
|
||
const { doc, nombreArchivo } = crearDocumento(plan, pago, aplicacion)
|
||
doc.save(nombreArchivo)
|
||
}
|