From 852182fabc64457766152e26a0f6a7ff0eb16fbe Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Sat, 12 Sep 2026 07:09:15 +0000 Subject: [PATCH] =?UTF-8?q?fix(6-mejoras):=20subcategor=C3=ADa+apartado=20?= =?UTF-8?q?libre+PDF+coacreditado+docs+dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Subcategoría en modal descarte (catálogo completo + select) 2. Apartado sin monto mínimo (eliminada validación de 0,000) 3. PDF solo con propuesta elegida (no las 4) 4. Co-acreditado: checkbox 'no tiene' valida sin campos obligatorios 5. Documentos apartado: previsualizar + descargar (endpoint + frontend) 6. Oportunidades: sin duplicados, orden Confirmar→Afluencia→Cotizar→Aparto(verde)→Descartar --- .../src/controllers/apartados.controller.ts | 36 ++++ .../controllers/cotizaciones.controller.ts | 3 +- api-gateway/src/routes/index.ts | 4 +- apps/alta-clientes/src/lib/schemas.test.ts | 24 +++ apps/alta-clientes/src/lib/schemas.ts | 50 ++++-- apps/cotizador/src/hooks/useCotizacion.ts | 13 +- apps/cotizador/src/lib/calculations.test.ts | 21 +-- apps/cotizador/src/lib/calculations.ts | 14 -- apps/cotizador/src/lib/pdf.ts | 9 +- apps/cotizador/src/pages/CotizadorPage.tsx | 15 +- apps/mi-dia/src/pages/MiDiaPage.tsx | 169 +++++++++++++----- apps/mi-dia/src/services/mi-dia.ts | 15 ++ 12 files changed, 261 insertions(+), 112 deletions(-) diff --git a/api-gateway/src/controllers/apartados.controller.ts b/api-gateway/src/controllers/apartados.controller.ts index 0d708b0..8c47387 100644 --- a/api-gateway/src/controllers/apartados.controller.ts +++ b/api-gateway/src/controllers/apartados.controller.ts @@ -154,3 +154,39 @@ export async function listarDocumentosApartado(req: Request, res: Response): Pro }) res.json(documentos) } + +/** + * GET /api/apartados/:itemId/documentos/:docId/archivo — sirve el archivo del + * documento (PDF/imagen) para previsualizarlo o descargarlo desde "Mis + * apartados". El documento debe pertenecer al cliente ligado al apartado. + */ +export async function descargarDocumentoApartado(req: Request, res: Response): Promise { + const itemId = String(req.params['itemId']) + const docId = String(req.params['docId']) + const cliente = await prisma.cliente.findFirst({ where: { mondayApartadoId: itemId } }) + if (!cliente) { + res.status(404).json({ error: 'Apartado sin cliente ligado' }) + return + } + const documento = await prisma.documento.findFirst({ where: { id: docId, clienteId: cliente.id } }) + if (!documento) { + res.status(404).json({ error: 'Documento no encontrado' }) + return + } + // Guarda anti path-traversal: la ruta guardada vive dentro de uploadsDir + const rutaAbsoluta = path.resolve(config.uploadsDir, documento.ruta) + if (!rutaAbsoluta.startsWith(config.uploadsDir + path.sep)) { + res.status(400).json({ error: 'Ruta de archivo inválida' }) + return + } + if (!fs.existsSync(rutaAbsoluta)) { + res.status(404).json({ error: 'Archivo no encontrado en disco' }) + return + } + res.setHeader('Content-Type', documento.mime || 'application/octet-stream') + res.setHeader( + 'Content-Disposition', + `inline; filename="${documento.nombreArchivo.replace(/[^\w.\- ]+/g, '_')}"`, + ) + res.sendFile(rutaAbsoluta) +} diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index ce33d4d..e4d765b 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -16,7 +16,8 @@ import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js' const crearCotizacionSchema = z.object({ unidadCodigo: z.string().min(1), acabados: z.boolean(), - apartado: z.number().positive().optional(), + // Sin mínimo: el vendedor puede apartar con cualquier monto, incluso $0 + apartado: z.number().min(0, 'El apartado no puede ser negativo').optional(), clienteNombre: z.string().min(1), clienteEmail: z.string().email().optional(), clienteTelefono: z.string().min(7).optional(), diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 5a9b199..5c76dac 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -48,7 +48,7 @@ import { cambiarEtapaOportunidadEndpoint, perderOportunidadEndpoint, } from '../controllers/oportunidades.controller.js' -import { cambiarEstadoApartadoEndpoint, listarDocumentosApartado, subirDocumentosApartado, uploadDocumentosApartado } from '../controllers/apartados.controller.js' +import { cambiarEstadoApartadoEndpoint, listarDocumentosApartado, subirDocumentosApartado, descargarDocumentoApartado, uploadDocumentosApartado } from '../controllers/apartados.controller.js' import { correrRecordatorios, listarRecordatorios } from '../controllers/recordatorios.controller.js' import { cambiarEstadoLeadEndpoint, @@ -214,6 +214,8 @@ apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint) // 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) +// Archivo de un documento (previsualizar / descargar desde "Mis apartados") +apiRouter.get('/apartados/:itemId/documentos/:docId/archivo', descargarDocumentoApartado) // Notas del apartado apiRouter.post('/apartados/:id/notas', crearNotaEndpoint) apiRouter.get('/apartados/:id/notas', listarNotasEndpoint) diff --git a/apps/alta-clientes/src/lib/schemas.test.ts b/apps/alta-clientes/src/lib/schemas.test.ts index 908eb50..1bd180c 100644 --- a/apps/alta-clientes/src/lib/schemas.test.ts +++ b/apps/alta-clientes/src/lib/schemas.test.ts @@ -35,6 +35,7 @@ const generalesValidos: DatosGenerales = { tipoVivienda: 'PROPIA', tipoCompra: 'VIVIR_AHI', actividadOcupacion: 'AGENTE DEL MINISTERIO PUBLICO FEDERAL', + usuarioEnkontrol: '', } describe('regex de identificadores', () => { @@ -128,6 +129,16 @@ describe('coacreditadoSchema', () => { expect(r.success).toBe(true) }) + it('acepta datos ocultos a medias cuando el toggle está apagado', () => { + const conBasura = { + ...base, + tieneCoacreditado: false as const, + generales: { ...base.generales, fecha: '', email: 'no-es-correo', codigoPostal: 'abc' }, + } + const r = coacreditadoSchema.safeParse(conBasura) + expect(r.success).toBe(true) + }) + it('exige el nombre del co-acreditado cuando el toggle está activo', () => { const r = coacreditadoSchema.safeParse({ ...base, tieneCoacreditado: true }) expect(r.success).toBe(false) @@ -141,6 +152,19 @@ describe('coacreditadoSchema', () => { }) expect(r.success).toBe(true) }) + + it('no exige usuarioEnkontrol del co-acreditado (no se captura en el bloque compacto)', () => { + const sinUsuarioEnkontrol = JSON.parse( + JSON.stringify({ + ...base, + tieneCoacreditado: true, + generales: { ...base.generales, nombreCompleto: 'MARIA LOPEZ PEREZ' }, + }), + ) as typeof base & { tieneCoacreditado: true } + delete (sinUsuarioEnkontrol.generales as Record).usuarioEnkontrol + const r = coacreditadoSchema.safeParse(sinUsuarioEnkontrol) + expect(r.success).toBe(true) + }) }) describe('referenciasSchema', () => { diff --git a/apps/alta-clientes/src/lib/schemas.ts b/apps/alta-clientes/src/lib/schemas.ts index 4787fd4..b1af13a 100644 --- a/apps/alta-clientes/src/lib/schemas.ts +++ b/apps/alta-clientes/src/lib/schemas.ts @@ -112,33 +112,47 @@ export const economicosSchema = z.object({ }) // Datos generales del co-acreditado: el nombre se exige vía superRefine solo -// cuando el toggle está activo; el resto se valida solo si se captura +// cuando el toggle está activo; el resto se valida solo si se captura. La +// fecha y usuarioEnkontrol no se capturan en el bloque compacto y no bloquean. const coacreditadoGeneralesSchema = datosGeneralesSchema.innerType().extend({ nombreCompleto: z.string().trim().max(200), curp: curpOpcional, rfc: rfcOpcional, email: emailOpcional, + fecha: textoOpcional, fechaNacimiento: textoOpcional, celular: textoOpcional, + usuarioEnkontrol: textoOpcional.optional(), }) -export const coacreditadoSchema = z - .object({ - tieneCoacreditado: z.boolean(), - generales: coacreditadoGeneralesSchema, - empleo: empleoSchema, - economicos: economicosSchema, - }) - .superRefine((datos, ctx) => { - // Los datos del co-acreditado solo se exigen si el toggle está activo - if (datos.tieneCoacreditado && datos.generales.nombreCompleto.trim().length < 3) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['generales', 'nombreCompleto'], - message: 'El nombre del co-acreditado es requerido', - }) - } - }) +export const coacreditadoSchema = z.union([ + // Checkbox "El cliente no tiene co-acreditado" MARCADO: los bloques están + // ocultos y pueden quedar a medias (o faltar); NO se valida nada. + z.object({ + tieneCoacreditado: z.literal(false), + generales: z.any(), + empleo: z.any(), + economicos: z.any(), + }), + // Checkbox SIN marcar (hay co-acreditado): se validan los bloques; el único + // campo obligatorio es el nombre (el resto es opcional). + z + .object({ + tieneCoacreditado: z.literal(true), + generales: coacreditadoGeneralesSchema, + empleo: empleoSchema, + economicos: economicosSchema, + }) + .superRefine((datos, ctx) => { + if (datos.generales.nombreCompleto.trim().length < 3) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['generales', 'nombreCompleto'], + message: 'El nombre del co-acreditado es requerido', + }) + } + }), +]) const referenciaSchema = z.object({ nombre: textoOpcional, diff --git a/apps/cotizador/src/hooks/useCotizacion.ts b/apps/cotizador/src/hooks/useCotizacion.ts index 8718cc0..e8cdd49 100644 --- a/apps/cotizador/src/hooks/useCotizacion.ts +++ b/apps/cotizador/src/hooks/useCotizacion.ts @@ -2,11 +2,9 @@ import { useMemo, useState } from 'react' import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types' import { APARTADO_DEFAULT, - APARTADO_MINIMO, calcularPropuestas, obtenerPrecioLista, resolverNumMensualidades, - validarApartado, validarEnganchePct, type PropuestaResultado, } from '../lib/calculations' @@ -23,10 +21,8 @@ interface UseCotizacionResult { setAcabados: (v: boolean) => void apartado: number setApartado: (v: number) => void - /** Apartado aplicado a los cálculos (acotado al mínimo cuando la captura es inválida) */ + /** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */ apartadoEfectivo: number - /** Mensaje de error de la captura del apartado; null si es válida */ - errorApartado: string | null /** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */ enganchePct: number | null setEnganchePct: (v: number | null) => void @@ -125,9 +121,9 @@ export function useCotizacion(): UseCotizacionResult { [unidad, acabados], ) - // Capturas por debajo del mínimo no se usan: los cálculos se acotan - const errorApartado = validarApartado(apartado) - const apartadoEfectivo = Math.max(apartado, APARTADO_MINIMO) + // Sin mínimo de apartado: el vendedor puede apartar con cualquier monto, + // incluso $0; solo se acota a >= 0 como guarda contra capturas raras + const apartadoEfectivo = Math.max(0, apartado) // Enganche personalizado: null = default del Excel; inválido no entra al cálculo const errorEnganche = validarEnganchePct(enganchePct) @@ -149,7 +145,6 @@ export function useCotizacion(): UseCotizacionResult { apartado, setApartado, apartadoEfectivo, - errorApartado, enganchePct, setEnganchePct, errorEnganche, diff --git a/apps/cotizador/src/lib/calculations.test.ts b/apps/cotizador/src/lib/calculations.test.ts index c54fe9d..dd621df 100644 --- a/apps/cotizador/src/lib/calculations.test.ts +++ b/apps/cotizador/src/lib/calculations.test.ts @@ -5,10 +5,8 @@ import { obtenerPrecioLista, totalPagado, indiceMenorTotal, - validarApartado, validarEnganchePct, APARTADO_DEFAULT, - APARTADO_MINIMO, } from './calculations' import { UNIDADES_SEED } from '../data/unidades' import type { Unidad } from '../types' @@ -124,19 +122,12 @@ describe('apartado editable', () => { }) }) -describe('validarApartado', () => { - it('acepta el mínimo y montos superiores', () => { - expect(validarApartado(APARTADO_MINIMO)).toBeNull() - expect(validarApartado(50000)).toBeNull() - }) - - it('rechaza montos por debajo del mínimo', () => { - expect(validarApartado(19999)).toBe('El apartado mínimo es $20,000') - expect(validarApartado(0)).toBe('El apartado mínimo es $20,000') - }) - - it('rechaza valores no finitos', () => { - expect(validarApartado(NaN)).toBe('El apartado mínimo es $20,000') +describe('apartado sin mínimo', () => { + it('acepta cualquier monto, incluso $0', () => { + const conCero = calcular({ creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }, 0) + expect(conCero.length).toBeGreaterThan(0) + // El enganche ya no se descuenta del apartado (no hay apartado que descontar) + expect(conCero[0].enganche).toBeCloseTo(0.025 * 4579000, 2) }) }) diff --git a/apps/cotizador/src/lib/calculations.ts b/apps/cotizador/src/lib/calculations.ts index e3017d3..eae7d2d 100644 --- a/apps/cotizador/src/lib/calculations.ts +++ b/apps/cotizador/src/lib/calculations.ts @@ -20,9 +20,6 @@ import { DESARROLLO_DEFAULT, obtenerDesarrollo } from '../data/desarrollos' export const APARTADO_DEFAULT = 20000 -/** Monto mínimo de apartado aceptado por el cotizador */ -export const APARTADO_MINIMO = 20000 - /** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */ export const ENGANCHE_MINIMO_PCT = 0.025 @@ -48,17 +45,6 @@ export function aplicaEnganchePersonalizado(cfg: PropuestaConfig): boolean { return cfg.modoMensualidades !== 'ninguna' && cfg.pctEnganche > 0 && cfg.pctEnganche <= 0.05 } -/** - * Mensaje de error si el apartado capturado es inválido; null si es válido. - * La UI acota el valor al mínimo para los cálculos y muestra este mensaje. - */ -export function validarApartado(apartado: number): string | null { - if (!Number.isFinite(apartado) || apartado < APARTADO_MINIMO) { - return 'El apartado mínimo es $20,000' - } - return null -} - // Descuentos por defecto de DIAGONAL (fracción, no porcentaje). // Para otros desarrollos usar descuentosDefault(desarrollo) de data/desarrollos. export const DESCUENTOS_DEFAULT: DescuentosPropuestas = { diff --git a/apps/cotizador/src/lib/pdf.ts b/apps/cotizador/src/lib/pdf.ts index 68e5815..a1c56e2 100644 --- a/apps/cotizador/src/lib/pdf.ts +++ b/apps/cotizador/src/lib/pdf.ts @@ -10,6 +10,7 @@ export interface PdfInput { acabados: boolean apartado: number precioLista: number + /** Propuestas a imprimir; desde la app llega SOLO la propuesta elegida */ propuestas: PropuestaResultado[] cliente: Cliente /** Parámetros del desarrollo; por defecto DIAGONAL */ @@ -143,12 +144,18 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro } : { pagos: 'N/A', total: 'N/A' } + // Con una sola propuesta (la elegida por el vendedor) no se numera + const encabezado = + propuestas.length === 1 + ? `PROPUESTA COMERCIAL — ${p.titulo.toUpperCase()}` + : `PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}` + autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, styles: { fontSize: 8 }, headStyles: { fillColor: [37, 99, 235] }, - head: [[`PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`, '%', 'PAGOS MENSUALES', 'TOTAL']], + head: [[encabezado, '%', 'PAGOS MENSUALES', 'TOTAL']], body: [ ['DESCUENTO', formatoPorcentaje(p.descuento), '', ''], ['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)], diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx index 7659056..65d7b91 100644 --- a/apps/cotizador/src/pages/CotizadorPage.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.tsx @@ -65,7 +65,6 @@ export default function CotizadorPage() { apartado, setApartado, apartadoEfectivo, - errorApartado, enganchePct, setEnganchePct, errorEnganche, @@ -181,7 +180,7 @@ export default function CotizadorPage() { const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] // Total pagado por el cliente con la propuesta elegida; null si no se puede conocer const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null - const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !errorEnganche && !guardando + const puedeGenerarPdf = Boolean(unidad) && !errorEnganche && !guardando // ---------- Preselección por defecto: la propuesta de menor total pagado ---------- // Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta @@ -290,7 +289,8 @@ export default function CotizadorPage() { acabados, apartado: apartadoEfectivo, precioLista, - propuestas, + // El PDF imprime SOLO la propuesta elegida por el vendedor + propuestas: propuestaActiva ? [propuestaActiva] : [], cliente, desarrollo: params, ...(folio ? { folio } : {}), @@ -530,20 +530,15 @@ export default function CotizadorPage() { { const v = Number(e.target.value) setApartado(Number.isFinite(v) && v >= 0 ? v : 0) }} - className={errorApartado ? campoInputError : campoInput} + className={campoInput} /> - {errorApartado ? ( -

{errorApartado}

- ) : ( -

{formatoMoneda(apartado)}

- )} +

{formatoMoneda(apartado)}

diff --git a/apps/mi-dia/src/pages/MiDiaPage.tsx b/apps/mi-dia/src/pages/MiDiaPage.tsx index 66a72fc..246efda 100644 --- a/apps/mi-dia/src/pages/MiDiaPage.tsx +++ b/apps/mi-dia/src/pages/MiDiaPage.tsx @@ -21,6 +21,7 @@ import { SLOTS_DOCS_APARTADO, componerTelefono, LADAS, + SUBCATEGORIAS_NO_COMPRA, textoDiasRestantes, urlAfluenciaParaOportunidad, urlCotizadorParaOportunidad, @@ -29,6 +30,7 @@ import { type ClaveLada, type FaseApartado, type LeadMiDia, + type MotivoNoCompra, type OportunidadMiDia, type ResumenMiDia, type SlotDocApartado, @@ -40,6 +42,7 @@ import { convertirLead, crearLead, descartarLead, + fetchArchivoDocumentoApartado, fetchResumenMiDia, listarDocumentosApartado, subirDocumentoApartado, @@ -140,7 +143,7 @@ function EnlacesContacto({ nombre, telefono }: { nombre: string; telefono: strin } -/** Fila de oportunidad con botones de fase del funnel (Agendo Cita lleva fecha) */ +/** Fila de oportunidad: acciones en orden (Agendo Cita lleva fecha opcional) */ function FilaOportunidad({ op, onEtapa, @@ -162,7 +165,6 @@ function FilaOportunidad({ }) { const [conCita, setConCita] = useState(!!op.fechaCita) const [fechaCita, setFechaCita] = useState(op.fechaCita ?? '') - const fases = ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto'] as const return (
  • @@ -200,16 +202,25 @@ function FilaOportunidad({
    - {/* Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */} + {/* 1. Confirmar cita (etapa Agendo Cita): al primer toque pide la fecha */} - {/* Afluencia: abre el formulario de alta afluencia prellenado */} + {conCita && ( + setFechaCita(e.target.value)} + className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20" + /> + )} + {/* 2. Afluencia: abre el formulario de alta afluencia prellenado */} - {fases.map((f) => - f === 'Agendo Cita' ? ( - - - {conCita && ( - setFechaCita(e.target.value)} - className="rounded-lg border border-[#D1D5DB] bg-white px-2 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20" - /> - )} - - ) : ( - - ), - )} + {/* 3. Cotizar: cambia la etapa y abre el cotizador con los datos prellenados */} + + {/* 4. Aparto: marca la etapa (verde) */} + + {/* 5. Descartar */}