feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol

This commit is contained in:
2026-09-21 08:34:53 +00:00
parent b5a20aed4e
commit 2fc80e7f9e
66 changed files with 3792 additions and 436 deletions

View File

@@ -69,6 +69,12 @@ interface ParametrosDesarrollo {
precioUnico: boolean
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
nFijo?: number
/**
* N por defecto del desarrollo: se usa cuando el inventario no trae fecha de
* entrega ni meses para firma. Garantiza que la mensualidad y el total
* siempre se calculen (espejo de apps/cotizador/src/data/desarrollos.ts).
*/
nDefault?: number
propuestas: PropuestaConfig[]
}
@@ -81,6 +87,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
id: 'DIAGONAL',
precioUnico: false,
fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega (mediana del
// inventario disponible 2026-09: 13 meses hasta la terminación de obra)
nDefault: 13,
propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'remanenteLista' },
@@ -116,6 +125,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
id: 'SENDA',
precioUnico: true,
fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega (mediana del
// inventario disponible 2026-09: 6 mensualidades)
nDefault: 6,
propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
@@ -127,6 +139,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
id: 'SOLANA',
precioUnico: true,
fuenteDeN: 'porUnidad',
// Último recurso si la unidad no trae "meses para firma" (las casas
// vendibles del inventario Excel están en 1 mes)
nDefault: 1,
propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje', engancheCeroSinApartado: true },
@@ -174,7 +189,9 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
}
/** Resuelve N según la fuente del desarrollo (null si no se puede conocer) */
/** Resuelve N según la fuente del desarrollo; cae al plazo por defecto del
* desarrollo (`nDefault`) cuando el inventario no trae el dato, para que la
* mensualidad y el total SIEMPRE se calculen. */
export function resolverNumMensualidades(
unidad: UnidadCotizable,
params: ParametrosDesarrollo,
@@ -183,9 +200,11 @@ export function resolverNumMensualidades(
case 'fijo':
return params.nFijo ?? 1
case 'porUnidad':
return unidad.mesesParaFirma ?? null
return unidad.mesesParaFirma ?? params.nDefault ?? null
case 'fechaEntrega':
return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null
return unidad.fechaEntrega
? calcularNumMensualidades(unidad.fechaEntrega)
: (params.nDefault ?? null)
}
}
@@ -196,6 +215,42 @@ function baseComun(precioLista: number, descuento: number) {
return { montoDescuento, precioFinal }
}
/** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */
interface BloquesPago {
enganche: number
mensualidades: number | null
pagoFinal: number
}
/**
* Cuadra los bloques para que apartado + enganche + mensualidades + pago final
* sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la
* propuesta), aunque el apartado o un enganche personalizado descuadren las
* fórmulas:
* - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando
* el apartado supera el enganche, ni mensualidades negativas con enganche
* alto);
* - el excedente se recorta de las mensualidades (bloque flexible del
* remanente) y, si no alcanza, del pago final. Nunca se suma por encima del
* precio.
*/
function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago {
const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal
const enganche = Math.max(0, bloques.enganche)
let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades)
let pagoFinal = Math.max(0, bloques.pagoFinal)
let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total
if (excedente > 0 && mensualidades !== null) {
const recorte = Math.min(mensualidades, excedente)
mensualidades -= recorte
excedente -= recorte
}
if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente)
return { enganche, mensualidades, pagoFinal }
}
/** Calcula una propuesta comercial con los parámetros dados */
function calcularPropuestaConfig(
cfg: PropuestaConfig,
@@ -217,17 +272,21 @@ function calcularPropuestaConfig(
cfg.pctEnganche <= 0.05
const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche
// Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de
// SOLANA lo deja en 0 cuando el % es 0
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
const enganche =
const engancheBruto =
cfg.engancheCeroSinApartado && pctEnganche === 0
? 0
: pctEnganche * baseEnganche - apartado
: pctEnganche * baseEnganche
// Fórmula del Excel: enganche neto del apartado (puede salir negativo si el
// apartado supera el enganche; cuadrarBloques lo acota a 0)
const engancheExcel = engancheBruto - apartado
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal
let mensualidadesTotal: number | null = null
let mensualidad: number | null = null
let mensualidadesExcel: number | null = null
let notaMensualidades: string | undefined
if (cfg.modoMensualidades !== 'ninguna') {
if (n === null) {
@@ -235,22 +294,26 @@ function calcularPropuestaConfig(
} else {
switch (cfg.modoMensualidades) {
case 'remanente':
mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel
break
case 'remanenteLista':
// Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel
break
case 'porcentaje':
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
break
}
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
mensualidadesTotal = Math.max(0, mensualidadesTotal)
mensualidad = mensualidadesTotal / n
}
}
const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, {
enganche: engancheExcel,
mensualidades: mensualidadesExcel,
pagoFinal: pagoFinalExcel,
})
const mensualidad = mensualidades === null || n === null ? null : mensualidades / n
return {
tipo: cfg.tipo,
titulo: cfg.titulo,
@@ -260,7 +323,7 @@ function calcularPropuestaConfig(
enganche,
numMensualidades: n,
mensualidad,
mensualidadesTotal,
mensualidadesTotal: mensualidades,
pagoFinal,
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
}
@@ -321,6 +384,8 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
* `descuento` opcional (fracción); si no viene, usa el default de la propuesta.
* `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con
* crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo.
* `numMeses` opcional: número de mensualidades elegido por el vendedor en el
* cotizador; si no viene se resuelve con la fuente del desarrollo.
*/
export function calcularPropuesta(
unidad: UnidadCotizable,
@@ -329,6 +394,7 @@ export function calcularPropuesta(
apartado: number = APARTADO_DEFAULT,
descuento?: number,
enganchePct?: number,
numMeses?: number,
): PropuestaResultado {
const params = obtenerDesarrollo(unidad.desarrollo)
const tipoInterno = TIPO_PROPUESTA_API[tipo]
@@ -337,7 +403,7 @@ export function calcularPropuesta(
throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`)
}
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = resolverNumMensualidades(unidad, params)
const n = numMeses ?? resolverNumMensualidades(unidad, params)
const d = descuento ?? cfg.descuentoDefault
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null)
}