diff --git a/api-gateway/src/controllers/apartados.controller.test.ts b/api-gateway/src/controllers/apartados.controller.test.ts index 5a66f32..9c6912c 100644 --- a/api-gateway/src/controllers/apartados.controller.test.ts +++ b/api-gateway/src/controllers/apartados.controller.test.ts @@ -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']) + }) }) diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts index cc15c91..dc719d6 100644 --- a/api-gateway/src/controllers/apartados.controller.ts +++ b/api-gateway/src/controllers/apartados.controller.ts @@ -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 = { + 'application/pdf': 'pdf', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', +} + +// Storage propio: archiva por item de Monday (uploads/apartados//) +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 { + 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 { + 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) +} diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index caf83c1..b97b7c2 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -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 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) } diff --git a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx index d3ca5fe..07cc5e4 100644 --- a/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx +++ b/apps/alta-clientes/src/components/AltaAfluenciaForm.tsx @@ -1,6 +1,7 @@ import { useState, type FormEvent } from 'react' import { z } from 'zod' import { crearAfluencia, type LeadResumen } from '../services/api' +import BusquedaLead from './BusquedaLead' import { DESARROLLOS_LEAD, ESTADOS_CIVILES, @@ -78,6 +79,36 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro const modelosDelDesarrollo = MODELOS_POR_PROYECTO[form.desarrollo as keyof typeof MODELOS_POR_PROYECTO] ?? [] + /** + * Jala los datos de un lead u oportunidad ya capturados en Monday: + * nombre/contacto siempre; origen, desarrollo y modelo solo si el valor del + * lead existe entre las opciones del formulario. + */ + const prellenarDesdeLead = (lead: LeadResumen) => { + setForm((prev) => { + const desarrollo = + lead.desarrollo !== undefined && (DESARROLLOS_LEAD as readonly string[]).includes(lead.desarrollo) + ? lead.desarrollo + : prev.desarrollo + const modelos = MODELOS_POR_PROYECTO[desarrollo as keyof typeof MODELOS_POR_PROYECTO] ?? [] + const modelo = + lead.modeloInteres !== undefined && (modelos as readonly string[]).includes(lead.modeloInteres) + ? lead.modeloInteres + : '' + return { + ...prev, + nombre: lead.nombre, + telefono: lead.telefono, + email: lead.email, + origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen, + desarrollo, + modelo, + } + }) + setErrores({}) + setAviso('') + } + const validar = (): boolean => { const nuevos: Errores = {} if (!form.nombre.trim()) nuevos.nombre = 'El nombre es obligatorio' @@ -180,6 +211,12 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente }: Pro return (
void enviar(e)} noValidate> +
+ +

+ Si el interesado ya está en Monday (lead u oportunidad), sus datos se prellenan solos. +

+