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:
@@ -23,18 +23,24 @@ interface Fila {
|
||||
/**
|
||||
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
|
||||
* con la propuesta activa calculada por el motor con los parámetros de cada
|
||||
* desarrollo.
|
||||
* desarrollo. Solo ofrece unidades DISPONIBLES: no se compara (ni se cotiza)
|
||||
* inventario apartado, vendido o bloqueado.
|
||||
*/
|
||||
export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props) {
|
||||
const [abierto, setAbierto] = useState(false)
|
||||
const [codigoA, setCodigoA] = useState('')
|
||||
const [codigoB, setCodigoB] = useState('')
|
||||
|
||||
const disponibles = useMemo(
|
||||
() => unidades.filter((u) => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'),
|
||||
[unidades],
|
||||
)
|
||||
|
||||
const porCodigo = useMemo(() => {
|
||||
const m = new Map<string, Unidad>()
|
||||
for (const u of unidades) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
|
||||
for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
|
||||
return m
|
||||
}, [unidades])
|
||||
}, [disponibles])
|
||||
|
||||
const unidadA = porCodigo.get(codigoA) ?? null
|
||||
const unidadB = porCodigo.get(codigoB) ?? null
|
||||
@@ -82,7 +88,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
>
|
||||
<option value="">Selecciona una unidad…</option>
|
||||
{LISTA_DESARROLLOS.map((dev) => {
|
||||
const delDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
|
||||
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
|
||||
if (delDesarrollo.length === 0) return null
|
||||
return (
|
||||
<optgroup key={dev} label={obtenerDesarrollo(dev).nombre}>
|
||||
@@ -172,8 +178,8 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Cada unidad se calcula con los parámetros de su desarrollo y el mismo apartado.
|
||||
El enganche ya incluye la deducción del apartado.
|
||||
Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
|
||||
su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,6 +27,15 @@ const formatoFechaHora = (iso: string): string =>
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
/**
|
||||
* Mensualidades guardadas: "N × $m". Las cotizaciones viejas (plazo 0/null, sin
|
||||
* mensualidad) muestran "—".
|
||||
*/
|
||||
const formatoMensualidades = (c: CotizacionGuardada): string => {
|
||||
if (c.mensualidad === null || c.numMensualidades === 0) return '—'
|
||||
return `${c.numMensualidades} × ${formatoMoneda(c.mensualidad)}`
|
||||
}
|
||||
|
||||
/** Sección colapsable "Mis cotizaciones": historial del vendedor logueado */
|
||||
export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Props) {
|
||||
const [abierto, setAbierto] = useState(false)
|
||||
@@ -65,7 +74,7 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
|
||||
<table className="w-full min-w-[640px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
|
||||
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Precio final', ''].map(
|
||||
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Mensualidades', 'Precio final', ''].map(
|
||||
(h) => (
|
||||
<th
|
||||
key={h}
|
||||
@@ -95,6 +104,9 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
|
||||
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
|
||||
{TITULO_PROPUESTA[c.tipoPropuesta] ?? c.tipoPropuesta}
|
||||
</td>
|
||||
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
|
||||
{formatoMensualidades(c)}
|
||||
</td>
|
||||
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap">
|
||||
{formatoMoneda(c.precioFinal)}
|
||||
</td>
|
||||
|
||||
@@ -12,6 +12,9 @@ const DIAGONAL: ParametrosDesarrollo = {
|
||||
nombre: 'Diagonal',
|
||||
precioUnico: false,
|
||||
fuenteDeN: 'fechaEntrega',
|
||||
// Último recurso si la unidad no trae fecha de entrega: mediana de la
|
||||
// terminación de obra del inventario disponible (2026-09) = 13 mensualidades
|
||||
nDefault: 13,
|
||||
cuotaMantenimiento: 1500,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
propuestas: [
|
||||
@@ -180,7 +183,10 @@ const SENDA: ParametrosDesarrollo = {
|
||||
id: 'SENDA',
|
||||
nombre: 'Senda',
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'fechaEntrega', // unidades sin fecha en TERM => N null (mensualidades "—")
|
||||
fuenteDeN: 'fechaEntrega', // N = meses hasta TERM. OBRA (captura ESCRITURACION)
|
||||
// Último recurso si la unidad no trae fecha: mediana del inventario
|
||||
// disponible (2026-09) = 6 mensualidades
|
||||
nDefault: 6,
|
||||
cuotaMantenimiento: null,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
propuestas: [
|
||||
@@ -236,6 +242,9 @@ const SOLANA: ParametrosDesarrollo = {
|
||||
nombre: 'Solana',
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'porUnidad', // MESES PARA FIRMA APROX del inventario (1–4)
|
||||
// Último recurso si la unidad no trae meses para firma: las casas vendibles
|
||||
// del inventario Excel están en 1 mes
|
||||
nDefault: 1,
|
||||
cuotaMantenimiento: null,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
propuestas: [
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface EstadoRestaurar {
|
||||
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */
|
||||
descuento?: number
|
||||
propuestaIndex?: number
|
||||
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */
|
||||
numMensualidades?: number | null
|
||||
}
|
||||
|
||||
/** Estado central de la cotización: desarrollo, unidad, acabados, apartado y descuentos */
|
||||
@@ -96,6 +98,7 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
}
|
||||
setDescuentos(base)
|
||||
setPropuestaIndex(estado.propuestaIndex ?? 0)
|
||||
setNumMensualidades(estado.numMensualidades ?? null)
|
||||
}
|
||||
|
||||
const nueva = () => {
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0 en el Excel, N = 4)', ()
|
||||
describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
|
||||
const unidad = porCodigo(UNIDADES_SENDA, 'SENDA-411-25-A101')
|
||||
|
||||
it('N = null y mensualidades "—" con nota; enganche y pago final sí se calculan', () => {
|
||||
it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => {
|
||||
expect(unidad.fechaEntrega).toBeNull()
|
||||
const [bancario] = calcularPropuestas({
|
||||
unidad,
|
||||
@@ -177,13 +177,13 @@ describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: descuentosDefault('SENDA'),
|
||||
})
|
||||
expect(bancario.numMensualidades).toBeNull()
|
||||
expect(bancario.mensualidad).toBeNull()
|
||||
expect(bancario.mensualidadesTotal).toBeNull()
|
||||
expect(bancario.notaMensualidades).toBe('sin fecha de entrega')
|
||||
// Antes: numMensualidades/mensualidad null y "— (sin fecha de entrega)"
|
||||
expect(bancario.numMensualidades).toBe(DESARROLLOS['SENDA'].nDefault)
|
||||
expect(bancario.notaMensualidades).toBeUndefined()
|
||||
expect(bancario.mensualidad).not.toBeNull()
|
||||
expect(bancario.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
|
||||
expect(bancario.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
|
||||
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeNull()
|
||||
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -209,11 +209,12 @@ describe('SOLANA — casa 6 (L = 2,560,000, apartado 20,000, mesesParaFirma = 1)
|
||||
expect(p.pagoFinal).toBeCloseTo(2304000, 2)
|
||||
})
|
||||
|
||||
it('INFONAVIT: enganche 0% con guarda (no resta el apartado)', () => {
|
||||
it('INFONAVIT: enganche 0% y el apartado se descuenta de las mensualidades', () => {
|
||||
const [, p] = calcular()
|
||||
expect(p.enganche).toBe(0)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(108000, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(2432000, 2)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2560000, 2)
|
||||
})
|
||||
|
||||
it('CONTADO', () => {
|
||||
@@ -240,19 +241,115 @@ describe('SOLANA — casa 9 (L = 2,605,000, mesesParaFirma = 1)', () => {
|
||||
descuentos: descuentosDefault('SOLANA'),
|
||||
})
|
||||
|
||||
it('las 4 propuestas al centavo', () => {
|
||||
it('las 4 propuestas al centavo (Infonavit descuenta el apartado)', () => {
|
||||
const [bancario, infonavit, contado, semi] = calcular()
|
||||
expect(bancario.enganche).toBeCloseTo(110250, 2)
|
||||
expect(bancario.mensualidadesTotal).toBeCloseTo(130250, 2)
|
||||
expect(bancario.pagoFinal).toBeCloseTo(2344500, 2)
|
||||
expect(infonavit.enganche).toBe(0)
|
||||
expect(infonavit.mensualidadesTotal).toBeCloseTo(130250, 2)
|
||||
expect(infonavit.mensualidadesTotal).toBeCloseTo(110250, 2)
|
||||
expect(infonavit.pagoFinal).toBeCloseTo(2474750, 2)
|
||||
expect(contado.enganche).toBeCloseTo(2324500, 2)
|
||||
expect(contado.pagoFinal).toBeCloseTo(260500, 2)
|
||||
expect(semi.enganche).toBeCloseTo(1282500, 2)
|
||||
expect(semi.mensualidadesTotal).toBeCloseTo(1042000, 2)
|
||||
expect(semi.pagoFinal).toBeCloseTo(260500, 2)
|
||||
for (const p of [bancario, infonavit, contado, semi]) {
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2605000, 2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cuadre del total: apartado y enganche personalizado', () => {
|
||||
const TIPOS = [
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'CONTADO',
|
||||
'SEMI_CONTADO',
|
||||
] as const
|
||||
const CASOS = [
|
||||
{ desarrollo: 'DIAGONAL', precio: 4_415_000 },
|
||||
{ desarrollo: 'ATELIER', precio: 3_997_500 },
|
||||
{ desarrollo: 'ESTANZA', precio: 2_437_250 },
|
||||
{ desarrollo: 'SENDA', precio: 2_451_750 },
|
||||
{ desarrollo: 'SOLANA', precio: 2_560_000 },
|
||||
]
|
||||
|
||||
const unidadDe = (desarrollo: string, precio: number): Unidad => ({
|
||||
codigo: `${desarrollo}-TEST`,
|
||||
modelo: 'X',
|
||||
desarrollo,
|
||||
fechaEntrega: null,
|
||||
mesesParaFirma: null,
|
||||
supEstacionamiento: 0,
|
||||
supConstruccion: 0,
|
||||
supExcedente: 0,
|
||||
precioSinAcabados: precio,
|
||||
precioConAcabados: precio,
|
||||
status: 'DISPONIBLE',
|
||||
})
|
||||
|
||||
const calcular = (caso: (typeof CASOS)[number], tipo: (typeof TIPOS)[number], apartado: number, enganchePct?: number) =>
|
||||
calcularPropuestas({
|
||||
unidad: unidadDe(caso.desarrollo, caso.precio),
|
||||
acabados: false,
|
||||
apartado,
|
||||
descuentos: descuentosDefault(caso.desarrollo),
|
||||
enganchePct: enganchePct ?? null,
|
||||
}).find((p) => p.tipo === tipo)!
|
||||
|
||||
it('las 4 propuestas de cada desarrollo cuestan lo que el precio final: ni más ni menos', () => {
|
||||
for (const caso of CASOS) {
|
||||
for (const tipo of TIPOS) {
|
||||
const p = calcular(caso, tipo, APARTADO_DEFAULT)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(p.precioFinal, 2)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('nunca hay bloques negativos y el total es el precio (apartados y enganches extremos)', () => {
|
||||
for (const caso of CASOS) {
|
||||
for (const tipo of TIPOS) {
|
||||
for (const apartado of [0, APARTADO_DEFAULT, 200_000]) {
|
||||
for (const enganchePct of [undefined, 0.05, 0.2, 0.5, 1]) {
|
||||
const p = calcular(caso, tipo, apartado, enganchePct)
|
||||
expect(p.enganche).toBeGreaterThanOrEqual(0)
|
||||
expect(p.mensualidadesTotal ?? 0).toBeGreaterThanOrEqual(0)
|
||||
expect(p.pagoFinal).toBeGreaterThanOrEqual(0)
|
||||
expect(totalPagado(p, apartado)).toBeCloseTo(p.precioFinal, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('enganche personalizado alto (20%) no infla el total: sigue siendo el precio', () => {
|
||||
const u101 = unidadDe('DIAGONAL', 4_415_000)
|
||||
const [bancario] = calcularPropuestas({
|
||||
unidad: u101,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: descuentosDefault('DIAGONAL'),
|
||||
enganchePct: 0.2,
|
||||
})
|
||||
// Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000
|
||||
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2)
|
||||
expect(bancario.mensualidadesTotal).toBe(0)
|
||||
expect(bancario.enganche).toBeCloseTo(0.2 * 4_415_000 - APARTADO_DEFAULT, 2)
|
||||
})
|
||||
|
||||
it('apartado mayor al enganche: el enganche queda en 0 y el total cuadra', () => {
|
||||
const u101 = unidadDe('DIAGONAL', 4_415_000)
|
||||
const [bancario] = calcularPropuestas({
|
||||
unidad: u101,
|
||||
acabados: false,
|
||||
// 2.5% de 4,415,000 = 110,375 → con apartado 200,000 el neto sería −89,625
|
||||
apartado: 200_000,
|
||||
descuentos: descuentosDefault('DIAGONAL'),
|
||||
})
|
||||
expect(bancario.enganche).toBe(0)
|
||||
expect(bancario.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - bancario.pagoFinal, 2)
|
||||
expect(totalPagado(bancario, 200_000)).toBeCloseTo(4_415_000, 2)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -261,11 +261,16 @@ describe('enganche personalizado (mínimo 2.5% del valor de la propiedad)', () =
|
||||
})
|
||||
|
||||
describe('validarEnganchePct', () => {
|
||||
it('null es válido (sin override); mínimo 2.5%; máximo 100%', () => {
|
||||
it('null es válido (sin override); rango válido del Excel: 2.5% a 5%', () => {
|
||||
expect(validarEnganchePct(null)).toBeNull()
|
||||
expect(validarEnganchePct(2.5)).toBeNull()
|
||||
expect(validarEnganchePct(50)).toBeNull()
|
||||
expect(validarEnganchePct(5)).toBeNull()
|
||||
expect(validarEnganchePct(2.4)).toBe('El enganche mínimo es 2.5% del valor de la propiedad')
|
||||
expect(validarEnganchePct(101)).toBe('El enganche no puede ser mayor al 100%')
|
||||
expect(validarEnganchePct(5.5)).toBe(
|
||||
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
|
||||
)
|
||||
expect(validarEnganchePct(20)).toBe(
|
||||
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,16 +23,22 @@ export const APARTADO_DEFAULT = 20000
|
||||
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
|
||||
export const ENGANCHE_MINIMO_PCT = 0.025
|
||||
|
||||
/** Enganche máximo que permite el Excel: 5% (lista de validación 0%, 2.5%, 5%) */
|
||||
export const ENGANCHE_MAXIMO_PCT = 5
|
||||
|
||||
/**
|
||||
* Mensaje de error si el enganche capturado (en PORCENTAJE, 2.5 = 2.5%) es
|
||||
* inválido; null si es válido. null de entrada = sin override (default Excel).
|
||||
* El rango válido es el del Excel: 2.5% a 5%.
|
||||
*/
|
||||
export function validarEnganchePct(pct: number | null): string | null {
|
||||
if (pct === null) return null
|
||||
if (!Number.isFinite(pct) || pct < 2.5) {
|
||||
return 'El enganche mínimo es 2.5% del valor de la propiedad'
|
||||
}
|
||||
if (pct > 100) return 'El enganche no puede ser mayor al 100%'
|
||||
if (pct > ENGANCHE_MAXIMO_PCT) {
|
||||
return 'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -100,6 +106,10 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
|
||||
* - fijo: nFijo (entrega inmediata)
|
||||
* - porUnidad: mesesParaFirma del inventario (null si falta)
|
||||
* - fechaEntrega: meses desde hoy (null si la unidad no tiene fecha)
|
||||
*
|
||||
* Cuando el inventario no trae el dato se usa el plazo por defecto del
|
||||
* desarrollo (`nDefault`), para que la mensualidad y el total SIEMPRE se
|
||||
* calculen (antes salían "— (sin fecha de entrega)" y sin total).
|
||||
*/
|
||||
export function resolverNumMensualidades(
|
||||
unidad: Unidad,
|
||||
@@ -109,9 +119,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +134,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,
|
||||
@@ -140,18 +188,18 @@ function calcularPropuestaConfig(
|
||||
? Math.max(ENGANCHE_MINIMO_PCT, enganchePct)
|
||||
: cfg.pctEnganche
|
||||
|
||||
// Enganche: pct sobre su base, neto del apartado (salvo la guarda de SOLANA)
|
||||
// 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 =
|
||||
cfg.engancheCeroSinApartado && pctEnganche === 0
|
||||
? 0
|
||||
: pctEnganche * baseEnganche - apartado
|
||||
const engancheBruto = cfg.engancheCeroSinApartado && pctEnganche === 0 ? 0 : 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) {
|
||||
@@ -160,23 +208,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
|
||||
// (ya no hay nada que diferir; el cliente paga de más al inicio)
|
||||
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,
|
||||
@@ -186,7 +237,7 @@ function calcularPropuestaConfig(
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad,
|
||||
mensualidadesTotal,
|
||||
mensualidadesTotal: mensualidades,
|
||||
pagoFinal,
|
||||
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { compararUnidades } from './comparadorUnidades'
|
||||
import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations'
|
||||
import { UNIDADES_SEED } from '../data/unidades'
|
||||
import { UNIDADES_ATELIER } from '../data/unidades-atelier'
|
||||
import { DESARROLLOS } from '../data/desarrollos'
|
||||
import { formatoMoneda } from './formatters'
|
||||
|
||||
describe('folioCotizacion', () => {
|
||||
@@ -106,10 +107,11 @@ describe('compararUnidades', () => {
|
||||
expect(r.a.precioLista).toBe(diagonal.precioSinAcabados)
|
||||
})
|
||||
|
||||
it('total null cuando el plazo no se puede conocer (sin fecha de entrega)', () => {
|
||||
it('una unidad sin fecha de entrega usa el plazo por defecto del desarrollo', () => {
|
||||
const sinFecha = { ...diagonal, fechaEntrega: null }
|
||||
const r = compararUnidades(sinFecha, diagonal, 'CREDITO_BANCARIO', APARTADO_DEFAULT, true)
|
||||
expect(r.a.total).toBeNull()
|
||||
expect(r.a.propuesta.numMensualidades).toBe(DESARROLLOS['DIAGONAL']?.nDefault)
|
||||
expect(r.a.total).not.toBeNull()
|
||||
expect(r.b.total).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ const respuesta = (data: unknown, status = 200) =>
|
||||
const almacenamiento = new Map<string, string>()
|
||||
beforeEach(() => {
|
||||
almacenamiento.clear()
|
||||
window.history.replaceState({}, '', '/')
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => almacenamiento.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
|
||||
@@ -88,4 +89,58 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
||||
})
|
||||
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('no deja cotizar una unidad que ya no está disponible: avisa y bloquea las acciones', async () => {
|
||||
const vendida = { ...unidad, codigo: 'U-900', status: 'VENDIDO' as const }
|
||||
const cotizacionVendida = {
|
||||
id: 'cot-vendida',
|
||||
folio: 'COT-VENDID',
|
||||
unidadCodigo: vendida.codigo,
|
||||
desarrollo: 'DIAGONAL',
|
||||
clienteNombre: 'Cliente Antiguo',
|
||||
clienteTelefono: null,
|
||||
clienteEmail: null,
|
||||
tipoPropuesta: 'BANCARIO',
|
||||
acabados: false,
|
||||
apartado: 20000,
|
||||
descuento: 0,
|
||||
precioLista: vendida.precioConAcabados,
|
||||
precioFinal: 0,
|
||||
enganche: 0,
|
||||
numMensualidades: 12,
|
||||
mensualidad: 1000,
|
||||
pagoFinal: 0,
|
||||
createdAt: '2026-09-01T00:00:00.000Z',
|
||||
}
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidad, vendida]))
|
||||
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([cotizacionVendida]))
|
||||
return Promise.reject(new TypeError(`fetch inesperado: ${url}`))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
// Prellenado desde Mi día con una unidad que ya se vendió
|
||||
window.history.replaceState({}, '', `/?unidad=${vendida.codigo}`)
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CotizadorPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
// El prellenado NO la preselecciona y lo dice
|
||||
expect(await screen.findByText(/ya no está disponible \(estatus: VENDIDO\)/)).toBeTruthy()
|
||||
|
||||
// Retomar la cotización vieja de esa unidad: se restaura, pero avisa y
|
||||
// deja Guardar/Compartir/Generar PDF bloqueados
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Ver historial/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Retomar' }))
|
||||
|
||||
const alerta = await screen.findByRole('alert')
|
||||
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`)
|
||||
expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
|
||||
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
|
||||
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,18 +21,22 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
||||
import { calcularVigencia } from '../lib/vigencia'
|
||||
import { folioCotizacion } from '../lib/folio'
|
||||
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
||||
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
|
||||
import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations'
|
||||
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
|
||||
import {
|
||||
getMisCotizaciones,
|
||||
guardarCotizacion,
|
||||
urlPublicaCotizacion,
|
||||
ErrorApiCotizacion,
|
||||
TIPO_A_API,
|
||||
TIPO_DE_API,
|
||||
type CotizacionGuardada,
|
||||
} from '../services/api'
|
||||
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
|
||||
import type { Cliente } from '../types'
|
||||
import type { Cliente, Unidad } from '../types'
|
||||
|
||||
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
|
||||
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
|
||||
|
||||
const esquemaCliente = z.object({
|
||||
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
|
||||
@@ -162,11 +166,21 @@ export default function CotizadorPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Preselecciona la unidad cuando el inventario terminó de cargar
|
||||
// Preselecciona la unidad cuando el inventario terminó de cargar. Solo
|
||||
// unidades DISPONIBLES: si la pedida ya se apartó/vendió (o no está en el
|
||||
// inventario) se avisa y no se preselecciona nada.
|
||||
useEffect(() => {
|
||||
if (unidadPendiente === null || cargando) return
|
||||
const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
|
||||
if (encontrada) {
|
||||
if (!encontrada) {
|
||||
setAviso(
|
||||
`La unidad ${unidadPendiente} no está en el inventario actual: elige otra unidad disponible.`,
|
||||
)
|
||||
} else if (!esDisponible(encontrada)) {
|
||||
setAviso(
|
||||
`La unidad ${encontrada.codigo} ya no está disponible (estatus: ${encontrada.status}). Elige otra unidad disponible.`,
|
||||
)
|
||||
} else {
|
||||
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
|
||||
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
|
||||
setUnidad(encontrada)
|
||||
@@ -180,7 +194,9 @@ export default function CotizadorPage() {
|
||||
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
||||
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
|
||||
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
|
||||
const puedeGenerarPdf = Boolean(unidad) && !errorEnganche && !guardando
|
||||
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
|
||||
const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
|
||||
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando
|
||||
|
||||
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
|
||||
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
|
||||
@@ -234,6 +250,8 @@ export default function CotizadorPage() {
|
||||
* Persiste la cotización en el API. Si no responde, la guarda localmente
|
||||
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
|
||||
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
|
||||
* Si el servidor RECHAZA la cotización (unidad no disponible, datos inválidos)
|
||||
* devuelve null: se avisa y no se guarda nada.
|
||||
*/
|
||||
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
|
||||
if (!unidad || !propuestaActiva) return null
|
||||
@@ -247,6 +265,7 @@ export default function CotizadorPage() {
|
||||
acabados,
|
||||
apartado: apartadoEfectivo,
|
||||
descuento: propuestaActiva.descuento,
|
||||
...(numMeses !== null ? { numMensualidades: numMeses } : {}),
|
||||
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
|
||||
...(oportunidadId !== null ? { oportunidadId } : {}),
|
||||
})
|
||||
@@ -256,6 +275,12 @@ export default function CotizadorPage() {
|
||||
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
|
||||
return conFolio
|
||||
} catch (err) {
|
||||
// El servidor contestó y rechazó la cotización: no se guarda ni local
|
||||
if (err instanceof ErrorApiCotizacion) {
|
||||
toast.error(err.message)
|
||||
setAviso(err.message)
|
||||
return null
|
||||
}
|
||||
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
|
||||
const local = cotizacionLocal(cliente)
|
||||
void guardarCotizacionLocal(local)
|
||||
@@ -272,17 +297,17 @@ export default function CotizadorPage() {
|
||||
setGuardando(true)
|
||||
try {
|
||||
const guardada = await guardarEnApi(cliente)
|
||||
let folio: string | undefined
|
||||
// Rechazada por el servidor (p. ej. la unidad dejó de estar disponible):
|
||||
// no se genera el PDF
|
||||
if (!guardada) return
|
||||
const folio = guardada.folio ?? folioCotizacion(guardada.id)
|
||||
let qrDataUrl: string | undefined
|
||||
if (guardada) {
|
||||
folio = guardada.folio ?? folioCotizacion(guardada.id)
|
||||
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
|
||||
if (!esCotizacionLocal(guardada)) {
|
||||
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
|
||||
margin: 1,
|
||||
width: 200,
|
||||
})
|
||||
}
|
||||
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
|
||||
if (!esCotizacionLocal(guardada)) {
|
||||
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
|
||||
margin: 1,
|
||||
width: 200,
|
||||
})
|
||||
}
|
||||
generarPdf({
|
||||
unidad,
|
||||
@@ -293,7 +318,7 @@ export default function CotizadorPage() {
|
||||
propuestas: propuestaActiva ? [propuestaActiva] : [],
|
||||
cliente,
|
||||
desarrollo: params,
|
||||
...(folio ? { folio } : {}),
|
||||
folio,
|
||||
...(qrDataUrl ? { qrDataUrl } : {}),
|
||||
vigencia: calcularVigencia(),
|
||||
})
|
||||
@@ -402,12 +427,17 @@ export default function CotizadorPage() {
|
||||
apartado: c.apartado,
|
||||
descuento: c.descuento,
|
||||
propuestaIndex: idx >= 0 ? idx : 0,
|
||||
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null,
|
||||
})
|
||||
setValue('nombre', c.clienteNombre, { shouldValidate: true })
|
||||
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
|
||||
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
|
||||
setUltimaGuardada(c)
|
||||
setAviso(`Cotización ${c.folio ?? ''} retomada.`)
|
||||
setAviso(
|
||||
esDisponible(u)
|
||||
? `Cotización ${c.folio ?? ''} retomada.`
|
||||
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`,
|
||||
)
|
||||
}
|
||||
|
||||
const handleNueva = () => {
|
||||
@@ -442,6 +472,17 @@ export default function CotizadorPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{unidadNoDisponible && unidad && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-800"
|
||||
>
|
||||
La unidad {unidad.codigo} no está disponible (estatus: {unidad.status}): no se puede
|
||||
cotizar ni apartar. Elige otra unidad disponible; Guardar, Compartir y Generar PDF
|
||||
quedan bloqueados.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid items-start gap-6 md:grid-cols-1 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
|
||||
{/* Panel de configuración */}
|
||||
<aside className="space-y-6 lg:sticky lg:top-24">
|
||||
@@ -549,7 +590,7 @@ export default function CotizadorPage() {
|
||||
id="enganche"
|
||||
type="number"
|
||||
min={2.5}
|
||||
max={100}
|
||||
max={ENGANCHE_MAXIMO_PCT}
|
||||
step={0.5}
|
||||
placeholder="Según propuesta"
|
||||
value={enganchePct ?? ''}
|
||||
@@ -568,7 +609,8 @@ export default function CotizadorPage() {
|
||||
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
Aplica a crédito bancario e Infonavit/Cofinavit; mínimo 2.5%
|
||||
Aplica a crédito bancario e Infonavit/Cofinavit; rango válido 2.5% a{' '}
|
||||
{ENGANCHE_MAXIMO_PCT}% (lo que permite el Excel)
|
||||
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -87,10 +87,27 @@ export interface GuardarCotizacionPayload {
|
||||
acabados: boolean
|
||||
apartado?: number
|
||||
descuento?: number
|
||||
/** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */
|
||||
numMensualidades?: number
|
||||
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
|
||||
oportunidadId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Error del API al guardar una cotización (status HTTP + mensaje del servidor).
|
||||
* Se distingue de los fallos de red: el servidor SÍ contestó y rechazó la
|
||||
* cotización (p. ej. la unidad ya no está disponible), así que no se guarda.
|
||||
*/
|
||||
export class ErrorApiCotizacion extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ErrorApiCotizacion'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL pública de una cotización (destino del QR del PDF). Debe ser ABSOLUTA:
|
||||
* un QR con ruta relativa ("/api/...") no es navegable al escanearlo desde el
|
||||
@@ -111,7 +128,11 @@ export async function guardarCotizacion(payload: GuardarCotizacionPayload): Prom
|
||||
body: JSON.stringify(payload),
|
||||
}))
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
if (!resp.ok) {
|
||||
// El API explica el rechazo (unidad no disponible, datos inválidos, …)
|
||||
const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null
|
||||
throw new ErrorApiCotizacion(resp.status, cuerpo?.error ?? `No se pudo guardar la cotización (HTTP ${resp.status})`)
|
||||
}
|
||||
return (await resp.json()) as CotizacionGuardada
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,12 @@ export interface ParametrosDesarrollo {
|
||||
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
|
||||
/** N fijo cuando fuenteDeN === 'fijo' (entrega inmediata / term. obra) */
|
||||
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.
|
||||
*/
|
||||
nDefault?: number
|
||||
/** Cuota mensual de mantenimiento (MXN); null => se muestra "PENDIENTE" */
|
||||
cuotaMantenimiento: number | null
|
||||
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
|
||||
|
||||
Reference in New Issue
Block a user