feat(cotizador): multi-desarrollo — ATELIER, ESTANZA, SENDA, SOLANA + DIAGONAL

- Motor de cálculo parametrizado por desarrollo (enganches, descuentos, splits,
  fuente de N, quirk Infonavit-lista); DIAGONAL byte-idéntico
- 285 unidades (5 desarrollos) en seeds, API (?desarrollo=) y board Monday
- Selector de desarrollo en UI, propuestas por config, PDF multi-desarrollo
- Knowledge IA: catálogo por desarrollo + matriz de diferencias (10 docs)
- Tests: cotizador 53, api 99 — fixtures de los Excel validados al centavo
This commit is contained in:
2026-08-22 04:31:21 +00:00
parent 05c44478ec
commit afb454b54c
33 changed files with 2074 additions and 338 deletions

View File

@@ -21,7 +21,8 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
const [abierto, setAbierto] = useState(false)
const totales = propuestas.map((p) => totalPagado(p, apartado))
const ganador = indiceMenorTotal(totales)
// null (plazo desconocido) nunca gana el menor total
const ganador = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
const filas: Fila[] = [
{ etiqueta: 'Precio de lista', obtener: () => formatoMoneda(precioLista) },
@@ -34,19 +35,30 @@ export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulo
{
etiqueta: 'Mensualidad',
obtener: (p) =>
p.mensualidad === null
? 'N/A'
: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
p.notaMensualidades
? `— (${p.notaMensualidades})`
: p.mensualidad === null
? 'N/A'
: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
},
{
etiqueta: 'Total mensualidades',
obtener: (p) => (p.mensualidadesTotal === null ? 'N/A' : formatoMoneda(p.mensualidadesTotal)),
obtener: (p) =>
p.notaMensualidades
? '—'
: p.mensualidadesTotal === null
? 'N/A'
: formatoMoneda(p.mensualidadesTotal),
},
{ etiqueta: 'Pago final', obtener: (p) => formatoMoneda(p.pagoFinal) },
{ etiqueta: 'Total pagado', obtener: (_p, i) => formatoMoneda(totales[i]), destacada: true },
{
etiqueta: 'Total pagado',
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i])),
destacada: true,
},
{
etiqueta: 'Diferencia vs precio de lista',
obtener: (_p, i) => formatoMoneda(totales[i] - precioLista),
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i] - precioLista)),
},
]

View File

@@ -67,7 +67,9 @@ export function ProposalCard({ numero, titulo, propuesta, onDescuentoChange }: P
nota="incluye deducción del apartado"
valor={formatoMoneda(propuesta.enganche)}
/>
{propuesta.mensualidad !== null && propuesta.mensualidadesTotal !== null ? (
{propuesta.notaMensualidades ? (
<Renglon etiqueta="Mensualidades" nota={propuesta.notaMensualidades} valor="—" />
) : propuesta.mensualidad !== null && propuesta.mensualidadesTotal !== null ? (
<Renglon
etiqueta="Mensualidades"
nota={`${propuesta.numMensualidades} × ${formatoMoneda(propuesta.mensualidad)}`}

View File

@@ -3,7 +3,7 @@ import { formatoFechaCorta } from '../lib/formatters'
interface Props {
unidad: Unidad
numMeses: number
numMeses: number | null
}
function Fila({ etiqueta, valor }: { etiqueta: string; valor: string }) {
@@ -15,8 +15,12 @@ function Fila({ etiqueta, valor }: { etiqueta: string; valor: string }) {
)
}
const formatoM2 = (v: number | null): string =>
v === null ? '—' : `${v.toLocaleString('es-MX')} m²`
/** Tarjeta con la ficha técnica de la unidad seleccionada */
export function UnitInfoCard({ unidad, numMeses }: Props) {
const extras = unidad.extras ?? {}
return (
<section className="border border-neutral-200 bg-white p-6">
<h2 className="mb-4 text-xs font-bold tracking-widest text-neutral-900 uppercase">
@@ -24,11 +28,19 @@ export function UnitInfoCard({ unidad, numMeses }: Props) {
</h2>
<dl className="divide-y divide-neutral-200">
<Fila etiqueta="Modelo" valor={unidad.modelo} />
<Fila etiqueta="Sup. construcción" valor={`${unidad.supConstruccion.toLocaleString('es-MX')} m²`} />
<Fila etiqueta="Sup. excedente" valor={`${unidad.supExcedente.toLocaleString('es-MX')} m²`} />
<Fila etiqueta="Sup. estacionamiento" valor={`${unidad.supEstacionamiento.toLocaleString('es-MX')} m²`} />
<Fila etiqueta="Fecha de entrega (obra)" valor={formatoFechaCorta(unidad.fechaEntrega)} />
<Fila etiqueta="Meses restantes" valor={String(numMeses)} />
{extras['prototipo'] != null && <Fila etiqueta="Prototipo" valor={String(extras['prototipo'])} />}
{extras['etapa'] != null && <Fila etiqueta="Etapa" valor={String(extras['etapa'])} />}
{extras['manzana'] != null && <Fila etiqueta="Manzana" valor={String(extras['manzana'])} />}
{extras['lote'] != null && <Fila etiqueta="Lote" valor={String(extras['lote'])} />}
{extras['cajones'] != null && <Fila etiqueta="Cajones" valor={String(extras['cajones'])} />}
<Fila etiqueta="Sup. construcción" valor={formatoM2(unidad.supConstruccion)} />
<Fila etiqueta="Sup. excedente" valor={formatoM2(unidad.supExcedente)} />
<Fila etiqueta="Sup. estacionamiento" valor={formatoM2(unidad.supEstacionamiento)} />
<Fila
etiqueta="Fecha de entrega (obra)"
valor={unidad.fechaEntrega ? formatoFechaCorta(unidad.fechaEntrega) : '—'}
/>
<Fila etiqueta="Meses restantes" valor={numMeses === null ? '—' : String(numMeses)} />
</dl>
</section>
)