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:
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
|
||||
166
api-gateway/src/services/cotizacion-desarrollos.service.test.ts
Normal file
166
api-gateway/src/services/cotizacion-desarrollos.service.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ describe('MondayService', () => {
|
||||
precioConAcabados: 4579000,
|
||||
status: 'DISPONIBLE',
|
||||
desarrollo: 'SENDA',
|
||||
mesesParaFirma: null,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user