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:
2026-09-04 22:32:40 +00:00
parent 1963c06d93
commit cbd50e2066
19 changed files with 700 additions and 44 deletions

View File

@@ -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)
}