feat: docs en apartados, enganche seleccionable y prefill de clientes
- Mis apartados (Mi día): botón Documentos con precarga del expediente — captura del cotizador y captura de pago apartado (obligatorios) más documentos generales; los archivos se guardan en el cliente ligado al apartado (se crea la ficha mínima si no existe). Endpoints GET/POST /apartados/:id/documentos (multipart, 10 MB, pdf/jpg/png) - Cotizador: se oculta el editor de descuento (usan los % del Excel); enganche seleccionable con mínimo 2.5% del valor de la propiedad, solo en esquemas con crédito (bancario/Infonavit); mensualidades se recalculan con el enganche elegido y nunca quedan en negativo - Cotizador: buscador de cliente en Monday (lead u oportunidad) que prellena nombre/contacto/desarrollo y vincula la cotización a la oportunidad al guardar - Alta Afluencia: buscador que jala nombre, contacto, origen, desarrollo y modelo desde leads y oportunidades existentes - buscarLeads devuelve desarrollo y modelo de interés - Landing: el módulo Clientes vuelve a llamarse Cobranza - Tests: 456 API + 94 cotizador + 14 mi-dia + 116 alta-clientes, verde
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cambiarEstadoApartado,
|
||||
FASES_APARTADO_PERMITIDAS,
|
||||
TIPOS_DOCUMENTO_APARTADO,
|
||||
} from './apartados.controller.js'
|
||||
|
||||
/** Monday mockeado: registra cambios de columna y movimientos de grupo */
|
||||
@@ -65,4 +66,8 @@ describe('cambiarEstadoApartado (POST /api/apartados/:id/estado)', () => {
|
||||
'Cancelacion Apartado',
|
||||
])
|
||||
})
|
||||
|
||||
it('los tipos de documento de la precarga son capturas obligatorias + generales', () => {
|
||||
expect(TIPOS_DOCUMENTO_APARTADO).toEqual(['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import multer from 'multer'
|
||||
import { z } from 'zod'
|
||||
import { config } from '../config/index.js'
|
||||
import { mondayService, type MondayService } from '../services/monday.service.js'
|
||||
import { moverAGrupoSegunEstado } from '../services/grupos-estado.service.js'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import { checkboxParaTipoDocumento } from './clientes.controller.js'
|
||||
|
||||
/**
|
||||
* Pipeline Enkontrol del tablero Apartado: las fases que el vendedor puede
|
||||
@@ -51,3 +57,124 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
|
||||
await cambiarEstadoApartado(mondayService, itemId, body.estado)
|
||||
res.json({ itemId, estado: body.estado })
|
||||
}
|
||||
|
||||
// ---------- Precarga de documentos del expediente (desde "Mis apartados") ----------
|
||||
|
||||
/**
|
||||
* Tipos de documento de la precarga de apartados. Los dos primeros son los
|
||||
* obligatorios del proceso; OTRO cubre los documentos generales (INE, CURP,
|
||||
* comprobantes, etc.). El tipo viaja en el campo `tipo` del multipart.
|
||||
*/
|
||||
export const TIPOS_DOCUMENTO_APARTADO = ['CAPTURA_COTIZADOR', 'CAPTURA_PAGO_APARTADO', 'OTRO'] as const
|
||||
|
||||
const MIME_PERMITIDOS_APARTADO = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
|
||||
const EXT_POR_MIME_APARTADO: Record<string, string> = {
|
||||
'application/pdf': 'pdf',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
}
|
||||
|
||||
// Storage propio: archiva por item de Monday (uploads/apartados/<itemId>/)
|
||||
const storageApartados = multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
const dir = path.join(config.uploadsDir, 'apartados', String(req.params['itemId']))
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
cb(null, dir)
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
||||
const ext = EXT_POR_MIME_APARTADO[file.mimetype] ?? 'bin'
|
||||
cb(null, `${tipo}_${randomUUID()}.${ext}`)
|
||||
},
|
||||
})
|
||||
|
||||
export const uploadDocumentosApartado = multer({
|
||||
storage: storageApartados,
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!MIME_PERMITIDOS_APARTADO.has(file.mimetype)) {
|
||||
cb(new Error(`Tipo de archivo no permitido: ${file.mimetype}`))
|
||||
return
|
||||
}
|
||||
cb(null, true)
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Cliente local ligado al apartado de Monday; si aún no existe (apartado
|
||||
* creado sin pasar por el alta progresiva), se crea la ficha mínima para que
|
||||
* los documentos tengan dónde vivir.
|
||||
*/
|
||||
async function clienteDeApartado(itemId: string, capturadoPorId?: string) {
|
||||
const existente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
|
||||
if (existente) return existente
|
||||
const nombre = await mondayService.obtenerNombreItem(itemId)
|
||||
return prisma.cliente.create({
|
||||
data: {
|
||||
nombreCompleto: nombre,
|
||||
curp: '',
|
||||
rfc: '',
|
||||
proyecto: '',
|
||||
payloadJson: '{}',
|
||||
mondayApartadoId: itemId,
|
||||
capturadoPorId: capturadoPorId ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/apartados/:itemId/documentos — multipart, campo `archivos` (hasta
|
||||
* 10) + campo `tipo`. Los documentos se guardan en el CLIENTE ligado al
|
||||
* apartado (Documento.clienteId); si el tipo tiene checkbox en el tablero
|
||||
* Apartado (CURP/RFC/Comprobante), se prende en Monday.
|
||||
*/
|
||||
export async function subirDocumentosApartado(req: Request, res: Response): Promise<void> {
|
||||
const itemId = String(req.params['itemId'])
|
||||
const cliente = await clienteDeApartado(itemId, req.usuarioToken?.sub)
|
||||
|
||||
const archivos = (req.files ?? []) as Express.Multer.File[]
|
||||
const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase()
|
||||
const documentos = await Promise.all(
|
||||
archivos.map((f) =>
|
||||
prisma.documento.create({
|
||||
data: {
|
||||
clienteId: cliente.id,
|
||||
tipo,
|
||||
nombreArchivo: f.originalname,
|
||||
ruta: path.relative(config.uploadsDir, f.path),
|
||||
mime: f.mimetype,
|
||||
tamano: f.size,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Prende el checkbox del documento en el item de Apartado (si el tipo tiene
|
||||
// checkbox); un fallo de Monday no rompe la subida.
|
||||
const checkbox = checkboxParaTipoDocumento(tipo)
|
||||
if (checkbox !== null) {
|
||||
try {
|
||||
await mondayService.cambiarValorColumna(config.monday.boards.apartado, itemId, checkbox, true)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`No se pudo marcar el checkbox "${checkbox}" del apartado ${itemId}:`, err)
|
||||
}
|
||||
}
|
||||
res.status(201).json(documentos)
|
||||
}
|
||||
|
||||
/** GET /api/apartados/:itemId/documentos — documentos del cliente ligado (vacío si aún no hay) */
|
||||
export async function listarDocumentosApartado(req: Request, res: Response): Promise<void> {
|
||||
const itemId = String(req.params['itemId'])
|
||||
const cliente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } })
|
||||
if (!cliente) {
|
||||
res.json([])
|
||||
return
|
||||
}
|
||||
const documentos = await prisma.documento.findMany({
|
||||
where: { clienteId: cliente.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json(documentos)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ const crearCotizacionSchema = z.object({
|
||||
clienteTelefono: z.string().min(7).optional(),
|
||||
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
|
||||
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
|
||||
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
|
||||
// esquemas con crédito (bancario/Infonavit)
|
||||
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
|
||||
// Item del tablero Oportunidades al que queda vinculada la cotización
|
||||
// (botón "Cotizar" de Mi día manda el id; sin él se busca por teléfono)
|
||||
oportunidadId: z.string().trim().min(1).optional(),
|
||||
@@ -51,7 +54,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
|
||||
|
||||
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
||||
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento)
|
||||
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento, body.enganchePct)
|
||||
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
||||
|
||||
const cotizacion = await prisma.cotizacion.create({
|
||||
|
||||
@@ -114,6 +114,10 @@ export interface ResultadoBusquedaLead {
|
||||
estado: string
|
||||
origen: string
|
||||
fuente: FuenteLead
|
||||
/** Desarrollo de interés ('' si no tiene); prellena afluencia/cotizador */
|
||||
desarrollo: string
|
||||
/** Modelo de interés ('' si no tiene) */
|
||||
modeloInteres: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +135,8 @@ export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonda
|
||||
email: l.email,
|
||||
estado: l.estado,
|
||||
origen: l.origen,
|
||||
desarrollo: l.desarrollo ?? '',
|
||||
modeloInteres: l.modeloInteres ?? '',
|
||||
updatedAt: l.updatedAt,
|
||||
fuente: 'lead' as const,
|
||||
})),
|
||||
@@ -141,6 +147,8 @@ export function buscarLeads(leads: LeadMonday[], oportunidades: OportunidadMonda
|
||||
email: o.email,
|
||||
estado: o.etapa,
|
||||
origen: o.origen,
|
||||
desarrollo: o.desarrollo ?? '',
|
||||
modeloInteres: o.modeloInteres ?? '',
|
||||
updatedAt: o.updatedAt,
|
||||
fuente: 'oportunidad' as const,
|
||||
})),
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
cambiarEtapaOportunidadEndpoint,
|
||||
perderOportunidadEndpoint,
|
||||
} from '../controllers/oportunidades.controller.js'
|
||||
import { cambiarEstadoApartadoEndpoint } from '../controllers/apartados.controller.js'
|
||||
import { cambiarEstadoApartadoEndpoint, listarDocumentosApartado, subirDocumentosApartado, uploadDocumentosApartado } from '../controllers/apartados.controller.js'
|
||||
import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js'
|
||||
import {
|
||||
cambiarEstadoLeadEndpoint,
|
||||
@@ -142,6 +142,10 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
|
||||
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
|
||||
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
|
||||
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
|
||||
// Precarga de documentos del expediente desde "Mis apartados": se guardan en
|
||||
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
|
||||
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
|
||||
apiRouter.post('/apartados/:itemId/documentos', uploadDocumentosApartado.array('archivos', 10), subirDocumentosApartado)
|
||||
|
||||
apiRouter.get('/mi-dia/resumen', resumenMiDiaEndpoint)
|
||||
|
||||
|
||||
@@ -203,14 +203,25 @@ function calcularPropuestaConfig(
|
||||
apartado: number,
|
||||
descuento: number,
|
||||
n: number | null,
|
||||
enganchePct: number | null = null,
|
||||
): PropuestaResultado {
|
||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||
|
||||
// % de enganche efectivo: el override del asesor (mínimo 2.5%) solo aplica a
|
||||
// esquemas con crédito (bancario/Infonavit: 2.5–5%); Contado (90%) y
|
||||
// Semi Contado (50%) son estructuras fijas del Excel
|
||||
const aplicaOverride =
|
||||
enganchePct !== null &&
|
||||
cfg.modoMensualidades !== 'ninguna' &&
|
||||
cfg.pctEnganche > 0 &&
|
||||
cfg.pctEnganche <= 0.05
|
||||
const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche
|
||||
|
||||
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
|
||||
const enganche =
|
||||
cfg.engancheCeroSinApartado && cfg.pctEnganche === 0
|
||||
cfg.engancheCeroSinApartado && pctEnganche === 0
|
||||
? 0
|
||||
: cfg.pctEnganche * baseEnganche - apartado
|
||||
: pctEnganche * baseEnganche - apartado
|
||||
|
||||
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
|
||||
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
|
||||
@@ -231,9 +242,11 @@ function calcularPropuestaConfig(
|
||||
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
||||
break
|
||||
case 'porcentaje':
|
||||
mensualidadesTotal = precioFinal * (1 - cfg.pctEnganche - cfg.pctPagoFinal)
|
||||
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
|
||||
break
|
||||
}
|
||||
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
|
||||
mensualidadesTotal = Math.max(0, mensualidadesTotal)
|
||||
mensualidad = mensualidadesTotal / n
|
||||
}
|
||||
}
|
||||
@@ -306,6 +319,8 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
|
||||
* 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.
|
||||
* `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con
|
||||
* crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo.
|
||||
*/
|
||||
export function calcularPropuesta(
|
||||
unidad: UnidadCotizable,
|
||||
@@ -313,6 +328,7 @@ export function calcularPropuesta(
|
||||
acabados: boolean,
|
||||
apartado: number = APARTADO_DEFAULT,
|
||||
descuento?: number,
|
||||
enganchePct?: number,
|
||||
): PropuestaResultado {
|
||||
const params = obtenerDesarrollo(unidad.desarrollo)
|
||||
const tipoInterno = TIPO_PROPUESTA_API[tipo]
|
||||
@@ -323,5 +339,5 @@ export function calcularPropuesta(
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = resolverNumMensualidades(unidad, params)
|
||||
const d = descuento ?? cfg.descuentoDefault
|
||||
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n)
|
||||
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user