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

@@ -27,7 +27,7 @@ npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.s
| Método | Ruta | Descripción |
|---|---|---|
| GET | `/api/health` | `{status, monday: 'up'|'down'}` |
| GET | `/api/unidades` | Inventario desde Monday (`?disponibles=true` filtra) |
| GET | `/api/unidades` | Inventario desde Monday (`?disponibles=true` filtra; `?desarrollo=` filtra por DIAGONAL/ATELIER/ESTANZA/SENDA/SOLANA) |
| GET | `/api/unidades/:codigo` | Una unidad (404 si no existe) |
| POST | `/api/cotizaciones` | Calcula propuesta (server-side), persiste y crea Oportunidad en Monday |
| GET | `/api/cotizaciones` · `/api/cotizaciones/:id` | Consulta |

View File

@@ -0,0 +1,23 @@
# Catálogo de unidades — Desarrollo ATELIER
ATELIER es un desarrollo de **departamentos** de Frasa Desarrollos (manzana 32, lote 1). Inventario: 34 departamentos; al 21-ago-2026 solo **4 están DISPONIBLES** (deptos 10, 17, 27 y 34), 1 vendido y 29 escriturados. Entrega inmediata: la obra ya terminó, por lo que las mensualidades son **un solo pago** en todas las propuestas.
## Modelos
- **SOHO** (prototipos E-A / E-B) — ~52–53 m² habitables. Precios de lista entre $2,059,713 y $2,666,831 MXN.
- **TRIBECA** (prototipos 2R-A / 2R-B / 2R-C) — ~75–83 m² habitables. Precios entre $2,985,326 y $4,451,040 MXN. Los 4 departamentos disponibles son TRIBECA ($3,750,500 a $4,250,600).
ATELIER tiene **precio único** por departamento: no existe distinción con/sin acabados (cocina y clóset). El apartado estándar es $20,000 MXN y se descuenta del enganche. Cuota de mantenimiento: pendiente de definir.
## Esquema de propuestas
Las 4 propuestas de ATELIER son **CONTADO, SEMI CONTADO, COFINAVIT y BANCARIO** (no hay Infonavit). Descuentos por defecto: 0% en las cuatro (editables en el cotizador). N = 1 pago siempre.
- **Contado** — enganche 90% del precio final menos apartado; pago final 10%. Sin mensualidades.
- **Semi contado** — enganche 50% del precio final menos apartado; mensualidades 40% en 1 pago; pago final 10%.
- **Cofinavit** — enganche 2.5% del precio final menos apartado; mensualidades 2.5% en 1 pago; pago final 95%. (Es la estructura del Infonavit de DIAGONAL pero calculada sobre precio final, sin el quirk del precio de lista.)
- **Bancario** — enganche 5% del **precio de lista** menos apartado; mensualidades = remanente (precio final − pago final − enganche − apartado) en 1 pago; pago final 90% del precio final.
Ejemplo validado (depto 10, precio $3,997,500, apartado $20,000): Contado enganche $3,577,750 / pago final $399,750; Semi enganche $1,978,750 / mensualidad $1,599,000 / pago final $399,750; Cofinavit enganche $79,937.50 / mensualidad $99,937.50 / pago final $3,797,625; Bancario enganche $179,875 / mensualidad $199,875 / pago final $3,597,750.
Vigencia de la cotización: 3 días naturales; 7 días para formalizar tras el apartado. No incluye gastos de escrituración.

View File

@@ -0,0 +1,23 @@
# Catálogo de unidades — Desarrollo ESTANZA
ESTANZA es un desarrollo de **casas** de Frasa Desarrollos (manzana 92, lote 32). Inventario: 64 casas; al 21-ago-2026 hay **13 DISPONIBLES** (9 DUPLO A y 4 DUPLO B), 2 apartadas, 5 vendidas y 44 escrituradas.
## Modelos
- **DUPLO A** — 69.06 m² de construcción (63.99 m² habitables), terreno tipo 56.17 m², 1 cajón. Precios entre $2,248,242 y $2,752,486 MXN.
- **DUPLO B** — 69.18 m² de construcción (64.05 m² habitables), terreno tipo 63.83 m², 1 cajón. Precios entre $2,241,564 y $2,653,052 MXN.
Las casas disponibles van de $2,345,652 a $2,581,017 MXN. ESTANZA tiene **precio único** por casa (sin distinción de acabados). Apartado estándar $20,000 MXN (se descuenta del enganche). Cuota de mantenimiento: pendiente de definir. 7 casas marcadas "R.U.V." aplican a Infonavit tradicional.
## Esquema de propuestas
Las mismas **4 propuestas que DIAGONAL** (Bancario, Infonavit-Cofinavit, Contado, Semi contado), con descuentos por defecto 0% en las cuatro (editables). El plazo N es fijo: **1 mensualidad** (terminación de obra = 1 mes en todo el inventario).
- **Bancario** — enganche 2.5% del precio final menos apartado; mensualidades 7.5% del precio final ÷ N; **pago final 90% del precio de LISTA** (quirk heredado: con descuento > 0 la suma no cuadra, así está en el Excel).
- **Infonavit-Cofinavit** — enganche 2.5% del precio final menos apartado; mensualidades 2.5% del precio final ÷ N; **pago final 95% del precio de LISTA** (mismo quirk).
- **Contado** — enganche 90% del precio final menos apartado; pago final 10%. Sin mensualidades.
- **Semi contado** — enganche 50% del precio final menos apartado; mensualidades 40% del precio final ÷ N; pago final 10%.
Ejemplo validado (casa 52, precio $2,437,250, apartado $20,000, N=1): Bancario enganche $40,931.25 / mensualidad $182,793.75 / pago final $2,193,525; Infonavit enganche $40,931.25 / mensualidad $60,931.25 / pago final $2,315,387.50; Contado enganche $2,173,525 / pago final $243,725; Semi contado enganche $1,198,625 / mensualidad $974,900 / pago final $243,725.
Vigencia de la cotización: 3 días naturales; 7 días para formalizar. No incluye gastos de escrituración.

View File

@@ -0,0 +1,27 @@
# Catálogo de unidades — Desarrollo SENDA
SENDA es un desarrollo de **casas** de Frasa Desarrollos. Los códigos de unidad tienen la forma `manzana-lote-casa` (ej. 411-1-7 = manzana 411, lote 1, casa 7). Inventario: **139 casas**; al 19-ago-2026 hay **43 DISPONIBLES**, 1 apartada, 56 vendidas y 39 bloqueadas.
## Modelos
- **HIMALAYA** (prototipo UNE-C) — 69.33 m² de construcción. Precios $2,287,000 – $2,468,483 MXN. 14 disponibles.
- **SANTIAGO** (prototipo LAUREL) — 63.95 m². Precios $2,450,000 – $2,750,000 MXN. 6 disponibles.
- **ESMERALDA PB / PA** (prototipo CUADRUPLEX) — 55.2 / 55.78 m². Precio único $1,948,000 (PB) y $1,850,000 (PA). 23 disponibles.
- **SANTA CRUZ** (prototipo DUPLO) — 2 unidades, ambas vendidas.
Las casas disponibles van de $1,850,000 a $2,700,000 MXN. SENDA tiene **precio único** (precio preventa; sin distinción de acabados). Apartado estándar $20,000 MXN, que se descuenta del enganche. Cuota de mantenimiento: pendiente de definir.
## Esquema de propuestas
Las 4 propuestas (Bancario, Infonavit-Cofinavit, Contado, Semi contado) calculan **todo sobre el precio final** (sin el quirk del precio de lista de DIAGONAL):
- **Bancario** — enganche **5%** del precio final menos apartado; mensualidades 5% del precio final ÷ N; pago final 90%. Descuento default 0% (editable).
- **Infonavit-Cofinavit** — enganche 2.5% del precio final menos apartado; mensualidades 2.5% ÷ N; pago final 95%. Descuento default 0% (editable).
- **Contado** — descuento **2% fijo**; enganche 90% del precio final menos apartado; pago final 10%. Sin mensualidades.
- **Semi contado** — descuento **1% fijo**; esquema **50 / 45 / 5**: enganche 50% del precio final menos apartado, mensualidades 45% ÷ N, pago final **5%** (en DIAGONAL es 40% y 10%).
N = meses aproximados a la escritura, calculado como (fecha de entrega − hoy) / 30.4 días. **Ojo:** las unidades que aún no tienen fecha de terminación de obra (todas las CUADRUPLEX 411-25-* y varias más) no permiten calcular mensualidades; el cotizador muestra "—" en ese concepto, pero enganche y pago final sí se calculan.
Ejemplo validado (casa 411-1-7, precio $2,451,750, N=4, sin apartado registrado): Bancario enganche $122,587.50 / mensualidad $30,646.88 / pago final $2,206,575; Infonavit enganche $61,293.75 / mensualidad $15,323.44 / pago final $2,329,162.50; Contado (2% desc) precio final $2,402,715, enganche $2,162,443.50, pago final $240,271.50; Semi (1% desc) precio final $2,427,232.50, enganche $1,213,616.25, mensualidad $273,063.66, pago final $121,361.63.
Vigencia de la cotización: 3 días naturales; 7 días para formalizar tras el apartado. No incluye gastos de escrituración.

View File

@@ -0,0 +1,25 @@
# Catálogo de unidades — Desarrollo SOLANA
SOLANA es un desarrollo de **casas** de Frasa Desarrollos en dos etapas: SOLANA I (ubicaciones 1A–5A) y SOLANA II (ubicaciones 6–10). Inventario: **10 casas**; al 21-ago-2026 quedan **3 DISPONIBLES** (casas 6, 7 y 9, todas MIRADOR de etapa SOLANA II), 3 vendidas y 4 escrituradas.
## Modelos
- **PANORAMA** (prototipo UNE-C) — 69.33 m² de construcción, 2 cajones. Precios $2,767,518 – $2,990,445 MXN (etapa SOLANA I; sin disponibles).
- **MIRADOR** (prototipos LAUREL, DUPLO-A, DUPLO-B) — 56.17 – 69.33 m². Precios $2,560,000 – $3,400,000 MXN. Las 3 disponibles ($2,560,000 a $2,605,000) son MIRADOR.
SOLANA tiene **precio único** (precio preventa; sin distinción de acabados). Apartado estándar $20,000 MXN, que se descuenta del enganche. Cuota de mantenimiento: pendiente.
## Esquema de propuestas
Las 4 propuestas (Bancario, Infonavit, Contado, Semi contado) con descuentos por defecto 0% (editables; cada propuesta admite dos conceptos de descuento que se suman). Todo se calcula sobre el **precio final** (sin el quirk del precio de lista de DIAGONAL):
- **Bancario** — enganche **5%** del precio final menos apartado; mensualidades 5% del precio final ÷ N; pago final 90%.
- **Infonavit** — **enganche 0%** (no se descuenta apartado porque no hay enganche); mensualidades 5% del precio final ÷ N; pago final 95%.
- **Contado** — enganche 90% del precio final menos apartado; pago final 10%. Sin mensualidades.
- **Semi contado** — enganche 50% del precio final menos apartado; mensualidades 40% ÷ N; pago final 10%.
N no viene de una fecha de entrega: es el dato **"meses para firma aprox"** del inventario, propio de cada casa (1 a 4 meses).
Ejemplo validado (casa 6, precio $2,560,000, N=1, apartado $20,000): Bancario enganche $108,000 / mensualidad $128,000 / pago final $2,304,000; Infonavit enganche $0 / mensualidad $128,000 / pago final $2,432,000; Contado enganche $2,284,000 / pago final $256,000; Semi contado enganche $1,260,000 / mensualidad $1,024,000 / pago final $256,000.
Vigencia de la cotización: 3 días naturales; 7 días para formalizar tras el apartado. No incluye gastos de escrituración.

View File

@@ -1,15 +1,22 @@
# Las 4 propuestas del cotizador Frasa
El cotizador genera siempre 4 propuestas comerciales para la misma unidad. Todas parten del **precio de lista** (sin o con acabados, según lo que elija el cliente) y del **apartado** (por defecto $20,000 MXN), que ya se pagó y se descuenta del enganche.
El cotizador genera 4 propuestas comerciales para la misma unidad. Todas parten del **precio de lista** (sin o con acabados, según lo que elija el cliente; en ATELIER, ESTANZA, SENDA y SOLANA hay un solo precio) y del **apartado** (por defecto $20,000 MXN), que ya se pagó y se descuenta del enganche.
**Este documento describe el esquema de DIAGONAL.** Los demás desarrollos usan la misma estructura pero con porcentajes y reglas propios; ver el catálogo de cada desarrollo:
- **ATELIER** — Contado / Semi contado / **Cofinavit** (no hay Infonavit) / Bancario. Bancario con enganche 5% sobre precio de lista. Entrega inmediata: las mensualidades son 1 solo pago. Descuentos default 0%.
- **ESTANZA** — las mismas 4 de DIAGONAL, pero el enganche se calcula sobre el precio final y el **pago final de Bancario/Infonavit sobre el precio de lista**; N fijo = 1 mensualidad. Descuentos default 0%.
- **SENDA** — Bancario con enganche 5%; Semi contado 50/45/**5**; Contado con 2% y Semi con 1% de descuento default; todo sobre precio final (sin quirk de lista). Unidades sin fecha de entrega: no se pueden calcular mensualidades ("—").
- **SOLANA** — Bancario con enganche 5%; **Infonavit con enganche 0%**; N = "meses para firma" de cada casa (1–4). Descuentos default 0%.
Elementos comunes a las 4 propuestas:
- **Descuento**: fracción sobre el precio de lista. Valores por defecto: Bancario 0%, Infonavit-Cofinavit 0%, Contado 2%, Semi-contado 1%.
- **Descuento**: fracción sobre el precio de lista. Valores por defecto en DIAGONAL: Bancario 0%, Infonavit-Cofinavit 0%, Contado 2%, Semi-contado 1%.
- **Monto de descuento** = precio de lista × descuento.
- **Precio final** = precio de lista − monto de descuento.
- **Número de mensualidades (N)**: meses completos desde hoy hasta la fecha de entrega de la unidad (mínimo 1). Las mensualidades se pagan durante la construcción.
## Propuesta 1 — Crédito Bancario
## Propuesta 1 — Crédito Bancario (DIAGONAL)
Para clientes que liquidarán con crédito hipotecario bancario a la entrega.
@@ -17,7 +24,7 @@ Para clientes que liquidarán con crédito hipotecario bancario a la entrega.
- Pago final (contra entrega, cubierto por el crédito) = 90% del precio final con descuento.
- Mensualidades = precio final − apartado − enganche − pago final, dividido entre N.
## Propuesta 2 — Crédito Infonavit / Cofinavit
## Propuesta 2 — Crédito Infonavit / Cofinavit (DIAGONAL)
Para clientes que liquidarán con crédito Infonavit o Cofinavit (Infonavit + banco) a la entrega.
@@ -25,7 +32,7 @@ Para clientes que liquidarán con crédito Infonavit o Cofinavit (Infonavit + ba
- Pago final (contra entrega, cubierto por el crédito) = 95% del precio final con descuento.
- Mensualidades = precio de **lista** − pago final − apartado − enganche, dividido entre N. (Ojo: el Excel del cotizador usa aquí el precio de lista, no el precio final; así está validado y así se calcula.)
## Propuesta 3 — Contado
## Propuesta 3 — Contado (DIAGONAL)
Sin mensualidades: el cliente paga casi todo de inmediato.
@@ -33,7 +40,7 @@ Sin mensualidades: el cliente paga casi todo de inmediato.
- Pago final = 10% del precio final con descuento.
- Mensualidad: no aplica (N/A).
## Propuesta 4 — Semi-contado
## Propuesta 4 — Semi-contado (DIAGONAL)
Esquema 50 / 40 / 10 sobre el precio final con descuento.

View File

@@ -0,0 +1,189 @@
/**
* Importa los inventarios de ATELIER, ESTANZA, SENDA y SOLANA al board
* "Unidades" de Monday, desde reference/cotizadores/{DEV}.json.
*
* - Idempotente: salta items cuyo nombre (código prefijado) ya existe.
* - Crea la columna "Meses para firma" (numbers) si falta (la usa SOLANA).
* - create_labels_if_missing: la columna status "Disponibilidad" gana las
* etiquetas nuevas (VENDIDO, ESCRITURADO, APARTADO, BLOQUEADO).
* - Delay entre mutaciones para cuidar el rate limit.
*
* Uso: npx tsx scripts/importar-unidades.ts (desde api-gateway/)
*/
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { config } from '../src/config/index.js'
import { MondayService } from '../src/services/monday.service.js'
const REF_DIR = path.resolve(process.cwd(), '..', 'reference', 'cotizadores')
const DELAY_MS = 400
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
interface UnidadJson {
codigo: string
modelo: string | null
fechaEntrega: string | null
supEstacionamiento: number | null
supConstruccion: number | null
supExcedente: number | null
status: string
extras: Record<string, string | number | null>
}
interface DefinicionDesarrollo {
id: string
archivo: string
codigoMonday: (u: UnidadJson) => string
precio: (u: UnidadJson) => number | null
mesesParaFirma?: (u: UnidadJson) => number | null
}
const DESARROLLOS: DefinicionDesarrollo[] = [
{
id: 'ATELIER',
archivo: 'ATELIER.json',
codigoMonday: (u) => `ATELIER-${u.codigo}`,
precio: (u) => (typeof u.extras['precioLista'] === 'number' ? u.extras['precioLista'] : null),
},
{
id: 'ESTANZA',
archivo: 'ESTANZA.json',
codigoMonday: (u) => `ESTANZA-${u.codigo.replace(/^CASA-/, '')}`,
precio: (u) => (typeof u.extras['precioLista'] === 'number' ? u.extras['precioLista'] : null),
},
{
id: 'SENDA',
archivo: 'SENDA.json',
codigoMonday: (u) => `SENDA-${u.codigo}`,
precio: (u) => (typeof u.extras['precioPreventa'] === 'number' ? u.extras['precioPreventa'] : null),
},
{
id: 'SOLANA',
archivo: 'SOLANA.json',
codigoMonday: (u) => `SOLANA-${u.codigo}`,
precio: (u) => (typeof u.extras['precioPreventa'] === 'number' ? u.extras['precioPreventa'] : null),
mesesParaFirma: (u) =>
typeof u.extras['mesesParaFirma'] === 'number' ? u.extras['mesesParaFirma'] : null,
},
]
const svc = new MondayService()
const boardId = config.monday.boards.unidades
/** Columnas del board mapeadas por título */
async function columnasBoard(): Promise<Map<string, { id: string; title: string; type: string }>> {
const data = await svc.graphql<{ boards: { columns: { id: string; title: string; type: string }[] }[] }>(
`query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`,
{ ids: [boardId] },
)
return new Map((data.boards[0]?.columns ?? []).map((c) => [c.title, c]))
}
async function asegurarColumnaMeses(): Promise<void> {
const cols = await columnasBoard()
if (cols.has('Meses para firma')) return
const data = await svc.graphql<{ create_column: { id: string } }>(
`mutation ($boardId: ID!, $title: String!) {
create_column(board_id: $boardId, title: $title, column_type: numbers) { id }
}`,
{ boardId, title: 'Meses para firma' },
)
console.log(`Columna "Meses para firma" creada (${data.create_column.id})`)
}
/** Nombres de TODOS los items del board (paginado por cursor) */
async function itemsExistentes(): Promise<Set<string>> {
const nombres = new Set<string>()
let cursor: string | null = null
do {
const data: { boards: { items_page: { cursor: string | null; items: { name: string }[] } }[] } =
cursor === null
? await svc.graphql(
`query ($ids: [ID!]) { boards(ids: $ids) { items_page(limit: 500) { cursor items { name } } } }`,
{ ids: [boardId] },
)
: await svc.graphql(
`query ($ids: [ID!], $cursor: String!) {
boards(ids: $ids) { items_page(limit: 500, cursor: $cursor) { cursor items { name } } }
}`,
{ ids: [boardId], cursor },
)
const page = data.boards[0]?.items_page
if (!page) break
for (const item of page.items) nombres.add(item.name)
cursor = page.cursor
} while (cursor !== null)
return nombres
}
function columnValuesDe(
def: DefinicionDesarrollo,
u: UnidadJson,
cols: Map<string, { id: string; title: string; type: string }>,
): string {
const valores: Record<string, unknown> = {}
const poner = (titulo: string, valor: unknown) => {
const col = cols.get(titulo)
if (!col) throw new Error(`Columna "${titulo}" no existe en el board`)
valores[col.id] = valor
}
const precio = def.precio(u)
poner('Desarrollo', def.id)
poner('Modelo', u.modelo ?? '')
if (u.fechaEntrega) poner('Fecha entrega', { date: u.fechaEntrega })
if (u.supConstruccion !== null) poner('Sup. construcción', u.supConstruccion)
if (u.supExcedente !== null) poner('Sup. excedente', u.supExcedente)
if (u.supEstacionamiento !== null) poner('Sup. estacionamiento', u.supEstacionamiento)
if (precio !== null) {
// Desarrollos de precio único: ambos precios replican el precio de lista
poner('Precio sin acabados', precio)
poner('Precio con acabados', precio)
}
poner('Disponibilidad', { label: u.status })
const meses = def.mesesParaFirma?.(u)
if (meses !== null && meses !== undefined) poner('Meses para firma', meses)
return JSON.stringify(valores)
}
async function main(): Promise<void> {
await asegurarColumnaMeses()
const cols = await columnasBoard()
const existentes = await itemsExistentes()
console.log(`Board ${boardId}: ${existentes.size} items existentes`)
for (const def of DESARROLLOS) {
const raw = JSON.parse(await readFile(path.join(REF_DIR, def.archivo), 'utf8')) as {
unidades: UnidadJson[]
}
let creados = 0
let saltados = 0
for (const u of raw.unidades) {
const nombre = def.codigoMonday(u)
if (existentes.has(nombre)) {
saltados++
continue
}
const columnValues = columnValuesDe(def, u, cols)
await svc.graphql<{ create_item: { id: string } }>(
`mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) {
create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues, create_labels_if_missing: true) { id }
}`,
{ boardId, itemName: nombre, columnValues },
)
existentes.add(nombre)
creados++
if (creados % 25 === 0) console.log(` ${def.id}: ${creados} creados…`)
await sleep(DELAY_MS)
}
console.log(`${def.id}: ${creados} creados, ${saltados} ya existían (total ${raw.unidades.length})`)
}
svc.invalidarCacheUnidades()
console.log('Importación terminada.')
process.exit(0)
}
main().catch((err) => {
console.error('Falló la importación:', err instanceof Error ? err.message : err)
process.exit(1)
})

View File

@@ -12,7 +12,7 @@ const crearCotizacionSchema = z.object({
clienteNombre: z.string().min(1),
clienteEmail: z.string().email().optional(),
clienteTelefono: z.string().min(7).optional(),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
})
@@ -47,7 +47,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
precioLista,
precioFinal: propuesta.precioFinal,
enganche: propuesta.enganche,
numMensualidades: propuesta.numMensualidades,
numMensualidades: propuesta.numMensualidades ?? 0, // 0 => plazo desconocido (sin fecha de entrega)
mensualidad: propuesta.mensualidad,
pagoFinal: propuesta.pagoFinal,
},

View File

@@ -2,11 +2,16 @@ import type { Request, Response } from 'express'
import { mondayService } from '../services/monday.service.js'
import { NotFoundError } from '../utils/errors.js'
/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE */
/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE; ?desarrollo= filtra por desarrollo */
export async function listarUnidades(req: Request, res: Response): Promise<void> {
const unidades = await mondayService.getUnidades()
const disponibles = req.query['disponibles'] === 'true'
res.json(disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades)
const desarrollo = req.query['desarrollo']?.toString().toUpperCase()
let lista = disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades
if (desarrollo) {
lista = lista.filter((u) => (u.desarrollo || 'DIAGONAL').toUpperCase() === desarrollo)
}
res.json(lista)
}
/** GET /api/unidades/:codigo — 404 si no existe */

View File

@@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest'
import {
APARTADO_DEFAULT,
calcularPropuesta,
calcularPropuestas,
totalPagado,
type UnidadCotizable,
} from './cotizacion.service.js'
/**
* Fixtures de los 4 desarrollos nuevos, validados al centavo contra los Excel
* de producción (reference/cotizadores/*.md). Espejo de
* apps/cotizador/src/lib/calculations-desarrollos.test.ts.
*/
function unidad(parcial: Partial<UnidadCotizable> & Pick<UnidadCotizable, 'codigo' | 'desarrollo'>): UnidadCotizable {
return {
modelo: 'X',
fechaEntrega: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: 0,
precioConAcabados: 0,
status: 'DISPONIBLE',
...parcial,
}
}
const DESC_CERO = { creditoBancario: 0, creditoInfonavit: 0, contado: 0, semiContado: 0 }
describe('ATELIER — depto 10 (L = 3,997,500, apartado 20,000, N fijo = 1)', () => {
const depto10 = unidad({
codigo: 'ATELIER-10',
desarrollo: 'ATELIER',
fechaEntrega: '2026-01-21',
precioSinAcabados: 3997500,
precioConAcabados: 3997500,
})
it('BANCARIO: enganche 5% sobre LISTA, mensualidad remanente en 1 pago', () => {
const [p] = calcularPropuestas({
unidad: depto10,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: DESC_CERO,
})
expect(p?.enganche).toBeCloseTo(179875, 2)
expect(p?.mensualidadesTotal).toBeCloseTo(199875, 2)
expect(p?.pagoFinal).toBeCloseTo(3597750, 2)
})
it('COFINAVIT (vía tipo COFINAVIT del API): 2.5% / 95% sobre precio final', () => {
const p = calcularPropuesta(depto10, 'COFINAVIT', false, APARTADO_DEFAULT)
expect(p.titulo).toBe('Crédito Cofinavit')
expect(p.enganche).toBeCloseTo(79937.5, 2)
expect(p.mensualidadesTotal).toBeCloseTo(99937.5, 2)
expect(p.pagoFinal).toBeCloseTo(3797625, 2)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(3997500, 2)
})
})
describe('ESTANZA — casa 52 (L = 2,437,250): pago final sobre LISTA (quirk)', () => {
const casa52 = unidad({
codigo: 'ESTANZA-52',
desarrollo: 'ESTANZA',
precioSinAcabados: 2437250,
precioConAcabados: 2437250,
})
it('las 4 propuestas al centavo', () => {
const [bancario, infonavit, contado, semi] = calcularPropuestas({
unidad: casa52,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: DESC_CERO,
})
expect(bancario?.enganche).toBeCloseTo(40931.25, 2)
expect(bancario?.mensualidadesTotal).toBeCloseTo(182793.75, 2)
expect(bancario?.pagoFinal).toBeCloseTo(2193525, 2)
expect(infonavit?.enganche).toBeCloseTo(40931.25, 2)
expect(infonavit?.mensualidadesTotal).toBeCloseTo(60931.25, 2)
expect(infonavit?.pagoFinal).toBeCloseTo(2315387.5, 2)
expect(contado?.enganche).toBeCloseTo(2173525, 2)
expect(contado?.pagoFinal).toBeCloseTo(243725, 2)
expect(semi?.enganche).toBeCloseTo(1198625, 2)
expect(semi?.mensualidadesTotal).toBeCloseTo(974900, 2)
expect(semi?.pagoFinal).toBeCloseTo(243725, 2)
})
})
describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0, N = 4)', () => {
const casa = unidad({
codigo: 'SENDA-411-1-7',
desarrollo: 'SENDA',
fechaEntrega: '2026-12-19', // la N real se fuerza con numMeses en el test
precioSinAcabados: 2451750,
precioConAcabados: 2451750,
})
it('semi-contado 50/45/5 con desc 1% default', () => {
const p = calcularPropuesta(casa, 'SEMI_CONTADO', false, 0)
// N real depende de hoy; validamos montos que no dependen de N
expect(p.precioFinal).toBeCloseTo(2427232.5, 2)
expect(p.enganche).toBeCloseTo(1213616.25, 2)
expect(p.mensualidadesTotal).toBeCloseTo(1092254.625, 3)
expect(p.pagoFinal).toBeCloseTo(121361.625, 3)
})
it('bancario 5/5/90 con N forzada = 4', () => {
const [p] = calcularPropuestas({
unidad: casa,
acabados: false,
apartado: 0,
descuentos: DESC_CERO,
numMeses: 4,
})
expect(p?.enganche).toBeCloseTo(122587.5, 2)
expect(p?.mensualidad).toBeCloseTo(30646.875, 3)
expect(p?.pagoFinal).toBeCloseTo(2206575, 2)
})
})
describe('SENDA — unidad sin fecha de entrega', () => {
it('N = null, mensualidades "—" y total pagado null', () => {
const sinFecha = unidad({
codigo: 'SENDA-411-25-A101',
desarrollo: 'SENDA',
fechaEntrega: null,
precioSinAcabados: 1948000,
precioConAcabados: 1948000,
})
const p = calcularPropuesta(sinFecha, 'BANCARIO', false, APARTADO_DEFAULT)
expect(p.numMensualidades).toBeNull()
expect(p.mensualidad).toBeNull()
expect(p.notaMensualidades).toBe('sin fecha de entrega')
expect(totalPagado(p, APARTADO_DEFAULT)).toBeNull()
expect(p.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
expect(p.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
})
})
describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => {
const casa6 = unidad({
codigo: 'SOLANA-6',
desarrollo: 'SOLANA',
precioSinAcabados: 2560000,
precioConAcabados: 2560000,
mesesParaFirma: 1,
})
it('INFONAVIT con enganche 0% (guarda: no resta el apartado)', () => {
const p = calcularPropuesta(casa6, 'INFONAVIT', false, APARTADO_DEFAULT)
expect(p.enganche).toBe(0)
expect(p.numMensualidades).toBe(1)
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
expect(p.pagoFinal).toBeCloseTo(2432000, 2)
})
it('BANCARIO 5% sobre final', () => {
const p = calcularPropuesta(casa6, 'BANCARIO', false, APARTADO_DEFAULT)
expect(p.enganche).toBeCloseTo(108000, 2)
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
expect(p.pagoFinal).toBeCloseTo(2304000, 2)
})
})

View File

@@ -21,7 +21,7 @@ const U101: UnidadCotizable = {
precioSinAcabados: 4415000,
precioConAcabados: 4579000,
status: 'DISPONIBLE',
desarrollo: 'SENDA',
desarrollo: 'DIAGONAL',
}
const N = 14
@@ -130,7 +130,7 @@ describe('totalPagado', () => {
describe('indiceMenorTotal', () => {
it('contado (2% desc) tiene el menor total pagado', () => {
const propuestas = calcular()
const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT))
const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT) ?? 0)
expect(indiceMenorTotal(totales)).toBe(2)
})

View File

@@ -1,21 +1,24 @@
/**
* Cálculos replicados fielmente del Excel de producción "COTIZADOR" de
* Frasa Desarrollos (port de apps/cotizador/src/lib/calculations.ts).
* NO "corregir" las fórmulas: algunas usan precio de lista donde
* intuitivamente iría precio final; así está en el Excel y así debe quedar.
* Cálculos replicados fielmente de los Excel de producción "COTIZADOR" de
* Frasa Desarrollos (port parametrizado de apps/cotizador/src/lib/calculations.ts
* + src/data/desarrollos.ts). NO "corregir" las fórmulas: algunas usan precio
* de lista donde intuitivamente iría precio final; así está en los Excel y
* así debe quedar.
*/
export interface UnidadCotizable {
codigo: string
modelo: string
fechaEntrega: string // ISO 'YYYY-MM-DD' (terminación de obra)
fechaEntrega: string | null // ISO 'YYYY-MM-DD'; null si no aplica o se desconoce
supEstacionamiento: number
supConstruccion: number
supExcedente: number
precioSinAcabados: number
precioConAcabados: number
status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'
status: string // DISPONIBLE | APARTADA | VENDIDA | APARTADO | VENDIDO | ESCRITURADO | BLOQUEADO
desarrollo: string
/** Plazo de mensualidades cuando lo fija el inventario (SOLANA: meses para firma) */
mesesParaFirma?: number | null
}
export type TipoPropuesta =
@@ -33,7 +36,7 @@ export interface DescuentosPropuestas {
export const APARTADO_DEFAULT = 20000
// Descuentos por defecto de cada propuesta (fracción, no porcentaje)
// Descuentos por defecto de DIAGONAL (fracción, no porcentaje)
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
creditoBancario: 0,
creditoInfonavit: 0,
@@ -41,24 +44,123 @@ export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
semiContado: 0.01,
}
// ---------- Parametrización por desarrollo ----------
type BasePrecio = 'lista' | 'final'
type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
interface PropuestaConfig {
tipo: TipoPropuesta
claveDescuento: keyof DescuentosPropuestas
titulo: string
descuentoDefault: number
pctEnganche: number
engancheBase: BasePrecio
pctPagoFinal: number
pagoFinalBase: BasePrecio
modoMensualidades: ModoMensualidades
engancheCeroSinApartado?: boolean
}
interface ParametrosDesarrollo {
id: string
precioUnico: boolean
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
nFijo?: number
propuestas: PropuestaConfig[]
}
/**
* Espejo de apps/cotizador/src/data/desarrollos.ts (mantener sincronizados).
* Detalle celda por celda en reference/cotizadores/*.md.
*/
export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
DIAGONAL: {
id: 'DIAGONAL',
precioUnico: false,
fuenteDeN: 'fechaEntrega',
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' },
{ tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0.02, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' },
{ tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0.01, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
],
},
ATELIER: {
id: 'ATELIER',
precioUnico: true,
fuenteDeN: 'fijo',
nFijo: 1,
propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
{ tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' },
{ tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
],
},
ESTANZA: {
id: 'ESTANZA',
precioUnico: true,
fuenteDeN: 'fijo',
nFijo: 1,
propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'lista', modoMensualidades: 'porcentaje' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'lista', modoMensualidades: 'porcentaje' },
{ tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' },
{ tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
],
},
SENDA: {
id: 'SENDA',
precioUnico: true,
fuenteDeN: 'fechaEntrega',
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' },
{ tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0.02, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' },
{ tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0.01, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.05, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
],
},
SOLANA: {
id: 'SOLANA',
precioUnico: true,
fuenteDeN: 'porUnidad',
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 },
{ tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' },
{ tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
],
},
}
const DESARROLLO_DEFAULT = 'DIAGONAL'
function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo {
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]!
}
export interface PropuestaResultado {
tipo: TipoPropuesta
titulo: string
descuento: number // fracción (0.02 = 2%)
montoDescuento: number
precioFinal: number // precio lista - descuento
enganche: number // NETO del apartado (el apartado ya se pagó)
numMensualidades: number
mensualidad: number | null // null => N/A (Contado)
mensualidadesTotal: number | null // null => N/A (Contado)
numMensualidades: number | null // null => plazo desconocido (sin fecha de entrega)
mensualidad: number | null // null => N/A (Contado) o plazo desconocido
mensualidadesTotal: number | null // null => N/A (Contado) o plazo desconocido
pagoFinal: number
notaMensualidades?: string
}
export interface CotizacionInput {
unidad: UnidadCotizable
acabados: boolean // true => incluye cocina y closet
acabados: boolean // true => incluye cocina y closet (ignorado en precio único)
apartado: number
descuentos: DescuentosPropuestas
/** Permite forzar N en tests; por defecto se calcula desde fechaEntrega */
desarrollo?: string
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
numMeses?: number
}
@@ -70,6 +172,21 @@ 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) */
export function resolverNumMensualidades(
unidad: UnidadCotizable,
params: ParametrosDesarrollo,
): number | null {
switch (params.fuenteDeN) {
case 'fijo':
return params.nFijo ?? 1
case 'porUnidad':
return unidad.mesesParaFirma ?? null
case 'fechaEntrega':
return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null
}
}
function baseComun(precioLista: number, descuento: number) {
// Excel COTIZADOR: MONTO DE DESCUENTO y PRECIO FINAL C/DESC
const montoDescuento = precioLista * descuento
@@ -77,89 +194,60 @@ function baseComun(precioLista: number, descuento: number) {
return { montoDescuento, precioFinal }
}
/** PROPUESTA 1 — CRÉDITO BANCARIO */
function calcularBancario(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
/** Calcula una propuesta comercial con los parámetros dados */
function calcularPropuestaConfig(
cfg: PropuestaConfig,
precioLista: number,
apartado: number,
descuento: number,
n: number | null,
): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M17 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M19 — pago final 90% del precio final c/desc
const pagoFinal = 0.9 * precioFinal
// Excel COTIZADOR!M18 — remanente a mensualidades
const mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
const enganche =
cfg.engancheCeroSinApartado && cfg.pctEnganche === 0
? 0
: cfg.pctEnganche * baseEnganche - apartado
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
let mensualidadesTotal: number | null = null
let mensualidad: number | null = null
let notaMensualidades: string | undefined
if (cfg.modoMensualidades !== 'ninguna') {
if (n === null) {
notaMensualidades = 'sin fecha de entrega'
} else {
switch (cfg.modoMensualidades) {
case 'remanente':
mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
break
case 'remanenteLista':
// Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
break
case 'porcentaje':
mensualidadesTotal = precioFinal * (1 - cfg.pctEnganche - cfg.pctPagoFinal)
break
}
mensualidad = mensualidadesTotal / n
}
}
return {
tipo: 'CREDITO_BANCARIO',
tipo: cfg.tipo,
titulo: cfg.titulo,
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 2 — CRÉDITO INFONAVIT-COFINAVIT */
function calcularInfonavit(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M26 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M28 — pago final 95% del precio final c/desc
const pagoFinal = 0.95 * precioFinal
// Excel COTIZADOR!M27 — OJO: el Excel usa precioLista aquí, NO precioFinal. Replicar tal cual.
const mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
return {
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 3 — CONTADO (sin mensualidades) */
function calcularContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M36 — enganche 90% del precio final c/desc, neto del apartado
const enganche = 0.9 * precioFinal - apartado
// Excel COTIZADOR!M37 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: null, // N/A
mensualidadesTotal: null, // N/A
pagoFinal,
}
}
/** PROPUESTA 4 — SEMI CONTADO */
function calcularSemiContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M45 — enganche 50% del precio final c/desc, neto del apartado
const enganche = 0.5 * precioFinal - apartado
// Excel COTIZADOR!M46 — 40% del precio final a mensualidades (= 1 - 0.5 - 0.1)
const mensualidadesTotal = precioFinal * 0.4
// Excel COTIZADOR!M47 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'SEMI_CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidad,
mensualidadesTotal,
pagoFinal,
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
}
}
@@ -171,8 +259,10 @@ export function obtenerPrecioLista(unidad: UnidadCotizable, acabados: boolean):
/**
* Total pagado por el cliente en una propuesta:
* apartado + enganche (neto del apartado) + mensualidades + pago final.
* null cuando el total no se puede conocer (plazo desconocido).
*/
export function totalPagado(p: PropuestaResultado, apartado: number): number {
export function totalPagado(p: PropuestaResultado, apartado: number): number | null {
if (p.notaMensualidades) return null
return apartado + p.enganche + (p.mensualidadesTotal ?? 0) + p.pagoFinal
}
@@ -188,39 +278,32 @@ export function indiceMenorTotal(totales: number[]): number {
return indice
}
/** Calcula las 4 propuestas comerciales */
/** Calcula las propuestas comerciales del desarrollo de la unidad */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input
const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? calcularNumMensualidades(unidad.fechaEntrega)
return [
calcularBancario(precioLista, apartado, descuentos.creditoBancario, n),
calcularInfonavit(precioLista, apartado, descuentos.creditoInfonavit, n),
calcularContado(precioLista, apartado, descuentos.contado, n),
calcularSemiContado(precioLista, apartado, descuentos.semiContado, n),
]
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
return params.propuestas.map((cfg) =>
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n),
)
}
/** Mapa entre el nombre del API (body) y el nombre interno de la propuesta */
export const TIPO_PROPUESTA_API = {
BANCARIO: 'CREDITO_BANCARIO',
INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT',
COFINAVIT: 'CREDITO_INFONAVIT_COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI_CONTADO',
} as const
export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
const DESCUENTO_DEFAULT_POR_TIPO: Record<TipoPropuestaApi, number> = {
BANCARIO: DESCUENTOS_DEFAULT.creditoBancario,
INFONAVIT: DESCUENTOS_DEFAULT.creditoInfonavit,
CONTADO: DESCUENTOS_DEFAULT.contado,
SEMI_CONTADO: DESCUENTOS_DEFAULT.semiContado,
}
/**
* Calcula UNA propuesta (la elegida por el cliente) para persistir/cotizar.
* `descuento` opcional (fracción); si no viene, usa el default del tipo.
* Calcula UNA propuesta (la elegida por el cliente) para persistir/cotizar,
* con los parámetros del desarrollo de la unidad.
* `descuento` opcional (fracción); si no viene, usa el default de la propuesta.
*/
export function calcularPropuesta(
unidad: UnidadCotizable,
@@ -229,17 +312,14 @@ export function calcularPropuesta(
apartado: number = APARTADO_DEFAULT,
descuento?: number,
): PropuestaResultado {
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = calcularNumMensualidades(unidad.fechaEntrega)
const d = descuento ?? DESCUENTO_DEFAULT_POR_TIPO[tipo]
switch (tipo) {
case 'BANCARIO':
return calcularBancario(precioLista, apartado, d, n)
case 'INFONAVIT':
return calcularInfonavit(precioLista, apartado, d, n)
case 'CONTADO':
return calcularContado(precioLista, apartado, d, n)
case 'SEMI_CONTADO':
return calcularSemiContado(precioLista, apartado, d, n)
const params = obtenerDesarrollo(unidad.desarrollo)
const tipoInterno = TIPO_PROPUESTA_API[tipo]
const cfg = params.propuestas.find((p) => p.tipo === tipoInterno)
if (!cfg) {
throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`)
}
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = resolverNumMensualidades(unidad, params)
const d = descuento ?? cfg.descuentoDefault
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n)
}

View File

@@ -79,6 +79,7 @@ describe('MondayService', () => {
precioConAcabados: 4579000,
status: 'DISPONIBLE',
desarrollo: 'SENDA',
mesesParaFirma: null,
})
})

View File

@@ -254,17 +254,23 @@ export class MondayService {
return Number.isFinite(n) ? n : 0
}
const status = textoDe('Disponibilidad').toUpperCase()
// La columna "Meses para firma" es opcional (solo la usa SOLANA)
const colMeses = cols.get('Meses para firma')
const mesesTexto = colMeses ? (porId.get(colMeses.id)?.text ?? '') : ''
const mesesNum = Number(mesesTexto)
return {
codigo: item.name,
modelo: textoDe('Modelo'),
fechaEntrega: textoDe('Fecha entrega'),
fechaEntrega: textoDe('Fecha entrega') || null,
supEstacionamiento: numeroDe('Sup. estacionamiento'),
supConstruccion: numeroDe('Sup. construcción'),
supExcedente: numeroDe('Sup. excedente'),
precioSinAcabados: numeroDe('Precio sin acabados'),
precioConAcabados: numeroDe('Precio con acabados'),
status: status === 'APARTADA' || status === 'VENDIDA' ? status : 'DISPONIBLE',
// Se conserva el status real del board (VENDIDO, ESCRITURADO, BLOQUEADO, ...)
status: status || 'DISPONIBLE',
desarrollo: textoDe('Desarrollo'),
mesesParaFirma: mesesTexto !== '' && Number.isFinite(mesesNum) ? mesesNum : null,
}
}