Antes de diseñar nada, contamos. Esto es cuántas veces se llenó cada columna del Excel de Pedro entre enero y junio de 2026 — leído con un parser de CSV de verdad, porque varias filas llevan saltos de línea dentro de las comillas y contar líneas da un número inflado.
La columna peor llenada es la más reveladora: fecha de cierre, 9 de 51. No es que los tratos no cerraran — es que nadie volvió a la hoja a escribir la fecha. Y ese es el patrón completo: cada columna de ese cuadro es una segunda transcripción de algo que ya pasó en otro lado. El nombre y el teléfono ya están en la conversación de WhatsApp. Los adjuntos literalmente se llaman WhatsApp Image 2026-02-04 at 3.44.18 PM.jpeg — ya son mensajes. El encargado es quien atiende el chat. La fecha de cierre es cuándo se aprobó la cotización.
Nadie sostiene un espejo a mano. Por eso el espejo está roto donde está roto.
La primera versión de esta propuesta creaba una tabla ProductSearch aparte, unida a la cotización por una llave foránea compuesta. El argumento sonaba bien: una búsqueda es una orden de trabajo, una cotización es su resultado. Estaba mal, y la señal estaba a la vista.
Aquella FK (productSearchId, clientId) → product_searches(id, clientId) existía por
una sola razón: impedir que dos filas dijeran cosas distintas sobre el mismo cliente. Es
maquinaria elegante cuyo único trabajo era reconciliar una división que yo mismo había
introducido.
Cuando la solución existe solo para arreglar un problema que creó tu propia separación, la separación estaba mal. Borrar la pieza sale más barato que blindarla.
Y todo lo que iba a construir ya existía
| Lo que iba a crear | Lo que ya está en ServiceQuote |
|---|---|
| lastQuoteSentAt · el reloj | sentToClientAt |
| el encargado | assignedAgentId |
| request | originalRequest |
| las notas | internalNotes |
| la fecha de cierre | approvedAt · rejectedAt |
| el motivo de rendirse | ServiceQuoteStatusHistory.reason |
| un servicio de rollup del estado | paymentStatus, ya calculado y adjuntado en la lista y el detalle |
| filtrar por estado y por cobro | los dos ya están en el DTO de filtros |
La escalera entera de estatus ya es filtrable hoy, porque status y paymentStatus son los dos parámetros de la lista. Iba a construir un servicio de rollup para producir algo que el módulo ya devuelve.
Los tres argumentos que tenía, y por qué no aguantan
| Lo que argumenté | Lo que resultó ser |
|---|---|
| «Una búsqueda puede cerrarse sin producir cotización» | Es una cotización que nunca salió de los estados tempranos. CANCELLED ya existe. |
| «Una búsqueda produce varias cotizaciones» | Pedro dijo «modificamos y la mandamos». Eso es duplicar: una acción, no una relación uno-a-muchos. Mi argumento más fuerte era el más flojo. |
| «El enum compartido se ensucia» | Un CHECK de una línea. Elegí una tabla entera para no escribir una restricción. |
Lo que sí cuesta la tabla única
Cada búsqueda es ahora una fila en la lista de cotizaciones — que es literalmente lo que Pedro dijo que no quería: «no llenar la plataforma de cotizaciones y cotizaciones que no van a ningún lado».
Pero eso es un problema de vista, no de almacenamiento: la lista abre filtrada. Y releyendo la reunión, se quejaba del trabajo de montar cotizaciones, no de que existieran filas.
Lo que se gana
- Ninguna tabla nueva
- Ningún paso de «convertir»
- Ninguna llave compuesta, ni cambio en la guarda de índices
- Ningún servicio de rollup, ni historial paralelo, ni generador de folios
- Una sola lista, un solo detalle, un solo permiso
// 1 · dos estados antes de que exista precio. en inglés, como el resto del enum. enum ServiceQuoteStatusEnum { NEW // llegó la solicitud, nadie la ha trabajado (POR EMPEZAR) SOURCING // se está buscando proveedor (CONVERSACION) DRAFT PENDING APPROVED REJECTED EXPIRED CANCELLED ← ya existían } // 2 · que los dos nuevos solo valgan para búsqueda de producto CHECK (status NOT IN ('NEW','SOURCING') OR "serviceType" = 'PRODUCT_SEARCH') // 3 · el ancla al chat, y la procedencia cuando se reusa una vieja model ServiceQuote { sourceMessageId String? // el mensaje del cliente que la originó reusedFromId String? // de cuál se duplicó } // 4 · para poder anclar mensajes (los "Recursos" de Pedro) y notas y bitácora enum EntityTypeEnum { … SERVICE_QUOTE } // hoy: 0 ocurrencias
Eso es todo. Tres valores de enum, dos columnas anulables y una restricción. Ninguna tabla, ningún índice nuevo más allá de los que ya cubren status y createdAt.
Por qué NEW y SOURCING
El enum entero está en inglés y así se queda. SOURCING no es un anglicismo forzado: es la palabra del oficio y el esquema ya la usa — ServiceQuoteTypeEnum.CUSTOM_SOURCING.
Y no chocan con DRAFT: borrador significa «la cotización está montada, sin enviar». Estos dos son antes de que haya precio.
El CHECK hace el trabajo de la tabla
Era mi única objeción real a compartir el enum: que la proforma de flete de Andy pudiera quedar «en búsqueda». La restricción lo hace imposible a nivel de motor, en una línea.
Prisma no ve los CHECK ni los recrea — va en migración manual, como el índice único parcial que el repo ya tiene.
La primera versión guardaba conversationId. Es insuficiente: una conversación es de un cliente, no de una petición. Si el mismo cliente pide jacuzzis en enero y pantallas LED en junio, los dos están en el mismo hilo — abrir la búsqueda te dejaría en seis meses de mensajes sin saber dónde empieza esta.
sourceMessageId String? // el mensaje del cliente que originó la búsqueda
Una sola columna, y da cuatro cosas:
| Qué da | Cómo |
|---|---|
| La fecha real | Cuándo preguntó el cliente, no cuándo alguien se acordó de anotarlo. La Fecha Inicio de su cuadro, pero verdadera. |
| El punto exacto del chat | Abrir la búsqueda te deja con el scroll puesto en ese mensaje. |
| La conversación | Por transitividad: Message.conversationId. |
| Los adjuntos relevantes | La media que rodea a ese mensaje, no la de seis meses. |
Y evita repetir el error de la llave compuesta: guardar conversationId junto a sourceMessageId serían otra vez dos verdades sobre el mismo hecho, porque el mensaje ya sabe de qué conversación es.
Los «Recursos» sin tabla nueva
Message ya tiene entityType + entityId. Falta que EntityTypeEnum incluya SERVICE_QUOTE — hoy tiene cero ocurrencias. Un valor de enum, y puedes anclar mensajes a la búsqueda: las fotos de WhatsApp de Pedro, sin inventar un almacén paralelo.
De paso desbloquea EntityNote, Log y las notificaciones sobre cotizaciones, que hoy tampoco se pueden enganchar. Un valor de enum, cuatro agujeros cerrados.
Fecha de cierre estaba en 9 de 51 porque era una consecuencia disfrazada de tarea. Lo mismo el estatus. Lo que ve Pedro sale de cruzar dos cosas que el módulo ya devuelve juntas en cada consulta: el estado del trato y el estado del cobro.
| Lo que se ve | status · el trato | paymentStatus · el cobro |
|---|---|---|
| Nueva | NEW | — |
| En búsqueda | SOURCING | — |
| Cotizada | PENDING | — |
| En seguimiento | PENDING + reloj | — |
| Aceptada, sin cobrar | APPROVED | PENDING PARTIAL |
| Ganada | APPROVED | PAID CLOSED |
| Perdida | REJECTED | — |
| Rendida | CANCELLED + motivo | ← lo único que marca un humano |
Dos enums, cuatro palabras compartidas
Es la trampa de este dominio. PENDING existe en los dos y significa cosas opuestas: en el trato es «enviada, esperando respuesta»; en el cobro es «facturada, sin cobrar». CANCELLED también está en ambos. Y no los une una llave foránea: el enlace es por convención (entityType = SERVICE_QUOTE).
Por eso este documento nunca dice «cotización» a secas cuando habla de estados: dice el trato o el cobro.
Ya no hace falta un rollup
La versión anterior materializaba el estatus con un servicio propio. Sobra: son dos columnas que el API ya devuelve y ya deja filtrar. La escalera es una función de presentación, no un dato nuevo que mantener sincronizado.
Y desaparece el bug que traía: sobre un conjunto vacío, «todas rechazadas» era verdadero por vacuidad y toda búsqueda nueva nacía Perdida. Sin conjunto, no hay vacuidad.
«Lleva N días callado» cambia solo con el paso del tiempo. Materializarlo obligaría a un trabajo programado que recorra la tabla cada noche — y a que el tablero mienta entre corrida y corrida. No hace falta: la columna que se necesita ya está.
WHERE status = 'PENDING' AND "sentToClientAt" <= now() - interval '7 days' -- sentToClientAt existe desde el primer día del módulo
Cambiar el umbral de 7 a 10 días no exige recalcular ni una fila. Se guarda un hecho, que no caduca; no un estado, que sí. Y como «Seguimiento cliente» solo aparece en 2 de las 51 filas del Excel, la prueba de que nadie lo iba a marcar a mano ya está hecha.
La columna Encargado de Pedro dice «Verónica» y «Victoria». Son personal de Mogos, y quién puede encargarse de una búsqueda tiene que salir del RBAC — no de cualquier lado.
assignedAgentId acepta el UUID de cualquier usuario. Puedes asignarle una
cotización a un cliente. El módulo de clientes sí tiene validateAgent; el de
cotizaciones nunca lo usó.
Y hay una confusión de vocabulario que conviene deshacer: en este repo RoleEnum.AGENT es otra cosa — el agente comercial que lleva la cartera de un cliente (User.agentId, relación ClientAgent). Verónica no es eso. Es quien trabaja este ítem.
El nombre de la columna se queda
assignedAgentId se llama igual en Conversation, Lead y ServiceQuote. Renombrar solo uno rompe la coherencia; renombrar los tres arrastra el buzón.
Se arregla lo que importa: se valida, y en la UI se llama «Encargado», nunca «Agente».
Un predicado, dos consumidores
La lista de a quién puedes asignar no se inventa: sale de userCan(u, 'SERVICE_QUOTES', 'UPDATE') — la misma función que usa el guardia del endpoint.
Así la lista y el permiso no pueden divergir. Por el seed actual eso es Ventas y Gerencia de Ventas, más quien administre.
La buena noticia: assignedAgentId ya existe en el modelo, en los tres DTOs y en el filtro de la lista. Ningún componente del admin lo muestra ni lo edita. No hay que construirlo — hay que sacarlo a la superficie. Y eso es lo que hace posibles la ficha «Mías» y los carriles del centro de acciones.
Un Excel abre en todo. Pedro entra, ve 51 filas y tiene que decidir cuáles le tocan hoy — y esa decisión, repetida cada mañana, es la que se deja de tomar. Así que el tablero no abre en la tabla: abre en lo que le reclama algo. Lo cerrado existe, pero detrás de una ficha.
| Cliente | Qué pidió | Espera | Encargado | Estado | |
|---|---|---|---|---|---|
| Perla Marina | Tubos recolección de sangre | 14 d | VS | En seguimiento | Recordar |
| Alba Mora | Sillas de fiesta · 50 transp. 100 blancas | 9 d | VE | Aceptada, sin cobrar | Cobrar |
| Hedirberto Reyes | Madejas de 50 metros | 3 d | — | Nueva | Tomarla |
| Ángel Moya | Productos de gimnasio | 2 d | VS | En búsqueda | Cotizar |
| Guadalupe Torrealba | Juguetes variados | 1 d | VE | Cotizada | Ver |
Cinco columnas, y la más útil es la que su Excel nunca tuvo: Espera — cuánto lleva la búsqueda parada en el estado en que está. Es la única cifra que convierte una lista en una cola. Sin ella, «14 días callado» y «ayer» se ven exactamente igual, que es justo lo que pasa hoy.
Por qué es una lista y no un kanban
Un kanban promete que mueves la tarjeta. Aquí el estado se lee del trato y del cobro: arrastrar una tarjeta a «Ganada» sin que haya entrado la plata sería mentirle al tablero, y el sistema la devolvería a su carril en el siguiente refresco.
No es una preferencia estética. Es lo único coherente con el modelo. Lo que sí se puede empujar es el hecho — cobrar, aprobar, rendirse — y para eso está la última columna.
Las dos fichas rojas son el dinero
En seguimiento y Sin cobrar son los dos huecos por donde se escapa la plata: una cotización que nadie contestó, y un trato aceptado que nunca pagó.
En el Excel ninguno era visible — «seguimiento» se marcó 2 veces en 51 filas, y «aceptado pero sin pagar» ni siquiera existía como concepto. Aquí se cuentan solos, en la cabecera.
Cada fila tiene exactamente una acción, y la decide el estado. No un menú de tres puntos con seis opciones de las que cinco no aplican: la que toca, escrita con el verbo que Pedro usaría. El admin ya tiene este patrón —la tarjeta de próxima acción de una cotización— y esto es lo mismo, llevado a la fila.
| Estado | La acción | Qué hace, sin preguntar nada más |
|---|---|---|
| Nueva | Tomarla T | Se asigna a quien la toma y pasa a En búsqueda. Un clic, sin diálogo. |
| En búsqueda | Cotizar C | Abre el asistente con el cliente, la ruta y la solicitud ya puestos. La misma fila avanza: no hay nada que enlazar. |
| Cotizada | Ver | Nada que hacer: la pelota está donde el cliente. Sale del carril de trabajo. |
| En seguimiento | Recordar R | Manda el recordatorio por WhatsApp. Si no hay ventana abierta, con plantilla; si la hay, texto libre. |
| Aceptada, sin cobrar | Cobrar B | Genera y envía el enlace de pago. Ese mecanismo ya existe. |
| Ganada · Perdida | — | Archivo. Solo se buscan, para reusarlas. |
Y la salida de emergencia, que también es una sola: Rendirse — pasa a CANCELLED con un motivo, que se guarda en el historial que ya existe. Es el único estado que un humano marca en todo el sistema, y por eso pide la razón: es la que separa «no lo conseguimos» de «el cliente desapareció».
Los pasos que Pedro deja de dar
Un botón sobre el mensaje del cliente. Cliente, encargado y solicitud original vienen puestos, y la fecha es la del mensaje.
Avanza cuando avanza el trato o entra el cobro. Nadie lo teclea nunca.
Inicio, actualización y cierre. Las tres eran consecuencias disfrazadas de tarea.
Nadie marca «seguimiento»: la fila se pone en rojo sola a los N días de enviada.
No hay que convertir nada: la búsqueda es la cotización, un poco más adelante.
Y las notas, si hacen falta. Nada más en todo el recorrido.
Las Nuevas sin dueño y las que llevan demasiado calladas aparecen además en el centro de acciones — que ya existe, ya es la pantalla de inicio del admin, y ya tiene carriles, «Tomar / Soltar» y atajos de teclado. Es una fuente más en un feed que se calcula al leer: no hay cola que mantener. Pedro no tiene que acordarse de entrar al tablero para que el tablero le hable.
SOURCING es donde vive el trabajo de verdad — días o semanas preguntándole a fábricas. Y ahí aparece una pregunta que el modelo no contestaba: Verónica encuentra un proveedor prometedor al segundo día, antes de que exista ninguna cotización. ¿Dónde guarda el link?
Un link vive en la opción de proveedor, que cuelga de un producto estructurado. Pero en NEW y SOURCING la solicitud es texto libre: «Cuatrimotos de 125cc o 110 a gasolina». Todavía no hay producto.
La respuesta: pegar el link estructura el producto
Cuando encuentra un proveedor, ya sabe para qué producto es — eso va implícito en haberlo encontrado. Así que no se le piden dos cosas. Pega el link, y el sistema crea el producto con el nombre pre-llenado desde la solicitud, más la opción con su proveedor.
Buscar es ir estructurando. La transición no es un paso administrativo: es lo que ella ya está haciendo.
Y por eso el estado avanza solo
- NEW → SOURCING · alguien se la asigna
- SOURCING · se acumulan productos y opciones
- → DRAFT · hay al menos una opción con precio: ya hay algo que cotizar
- → PENDING · se envía
Nadie mueve un selector de estado en todo el recorrido.
Esto además explica por qué Link del proveedor solo estaba en 3 de 51 filas del Excel: no es que no encontrara proveedores — es que la hoja no era el sitio donde guardarlos, y acababan en WeChat, en Alibaba o en la cabeza. Que es exactamente el problema que Pedro contó en la reunión: «mandé una cotización en enero… ahora el tema es ubicar al proveedor, porque ya han pasado 6 meses».
La prueba de si el diseño sirve es si el cuadro de Pedro cabe dentro. Sometimos las 51 filas, columna por columna. Nueve entran directo, dos se borran, dos necesitan una decisión — y una cosa se pierde.
| Columna | Dónde cae |
|---|---|
| Cliente | clientId |
| Fecha Inicio | createdAt, sobreescrito al importar |
| Teléfono | del usuario |
| Estatus | el enum |
| Productos de fábrica | originalRequest |
| Encargado | assignedAgentId — solo dos personas a mapear: Verónica (15) y Victoria Sabatini (8) |
| Notas | internalNotes |
| Fecha de actualización | updatedAt |
| Fecha de cierre | approvedAt · rejectedAt |
| Persona de contacto Elementos principales | Se borran. 0 de 51 en seis meses. |
Los 41 «Recursos» de 19 filas son nombres, no archivos — Captura de pantalla (1).png.
Los archivos viven en el Drive de Pedro, y los mensajes de WhatsApp de enero puede que la
plataforma ni los tenga.
Se importa el nombre como texto en las notas: conserva la pista de que había una captura, sin fingir que tienes el archivo. Quien los quiera, los sube a mano después.
Las tres decisiones de la importación
| El problema | Cómo se resuelve |
|---|---|
| 47 clientes son nombres, no usuarios | Es la que decide cuánto aterriza de verdad, porque clientId es obligatorio. Se emparejan por nombre y los que no emparejen no se importan, se reportan. No se inventan usuarios para cuadrar una cifra. |
| 16 filas sin estatus | El propio CSV da la pista: ninguna tiene fecha de cierre, las 16 tienen producto y 13 tienen encargado. Están abiertas y alguien las trabajaba → las 13 con encargado entran como SOURCING, las otras 3 como NEW. |
| Dos formatos de fecha, y tres cierres imposibles | Inicio y cierre vienen en DD/MM/AAAA; actualización en AAAA/MM/DD. Cada columna es consistente, así que el parser lo resuelve. Lo que no se resuelve solo: tres filas cierran antes de empezar (inicio 05/01, cierre 02/01). Se importan marcadas; no se inventa una fecha plausible. |
Y el pago de todo esto: con la historia dentro, el pedido de Pedro de reutilizar una búsqueda vieja funciona desde el primer día — buscar «pantallas LED» y encontrar la de enero que no cerró. Con la tabla vacía, esa función no vale nada.
Una tabla nueva
Era la primera versión de esta propuesta. La llave compuesta que hacía falta para blindarla era la señal de que sobraba: maquinaria cuyo único trabajo era reconciliar una división propia.
Replicar las trece columnas
Dos llevan seis meses en cero y tres agonizan. Copiarlas sería honrar una aspiración que ya fracasó. Se conservan las tres que se llenan; el resto se deriva o se borra.
Un estado «seguimiento» que alguien marque
Aparece en 2 de 51 filas. Esa es toda la evidencia necesaria. Es el reloj, y el reloj no se olvida.
Un kanban con tarjetas que se arrastran
El estado se lee del trato y del cobro. Arrastrar una tarjeta a «Ganada» sin que haya entrado la plata sería mentirle al tablero. Lo que se empuja es el hecho, no la tarjeta.
Un menú de tres puntos en cada fila
Seis opciones de las que cinco no aplican es una decisión que le pasas al usuario para no tomarla tú. Una acción por estado, con el verbo que él usaría.
Un almacén de adjuntos paralelo
Los «Recursos» son mensajes de WhatsApp. Se anclan con el mecanismo polimórfico que ya existe en Message, en cuanto EntityTypeEnum conozca las cotizaciones.
Un importador bidireccional de Excel
Las 51 filas se importan una vez, como historia — que es lo que le da contenido de entrada al «reutilizar». Mantener el Excel vivo en paralelo sería reconstruir el problema.
| Riesgo | Arnés |
|---|---|
| Dos estados nuevos en un enum compartido | Es el riesgo central de la tabla única. Lo cierra el CHECK, más una prueba que intente poner SOURCING en una cotización de consolidación y espere el error del motor. Prisma no ve los CHECK: van en migración manual y no se recrean solos. |
| Los estados nuevos rompen guardas existentes | sendToClient exige DRAFT y aprobar exige PENDING. Hay que decidir explícitamente si NEW y SOURCING pueden saltar directo, o si pasan por DRAFT. Sin eso, una búsqueda se queda atascada. |
| Las listas existentes cambian de contenido | /service-quotes empieza a mostrar filas sin precio. La lista abre filtrada, pero hay que revisar los cuatro caminos dorados de E2E y las tarjetas de KPI, que hoy cuentan sobre el total. |
| Asignar a quien no debe | Hoy no se valida nada. Se añade la validación con userCan y una prueba que intente asignar a un CLIENT y espere un 400. |
| La importación de las 51 filas | Los clientes vienen por nombre, no por identificador, y hay 47 distintos. Se emparejan los que se pueda y el resto queda marcado para revisión — nunca inventando un usuario. En este entorno las siembras de producción se corren a mano, deliberadamente. |
| Encontrado de paso, fuera de alcance | generateQuoteCode() ordena los folios como texto, así que "COT-…-9999" > "COT-…-10000", y además lee-y-escribe sin bloqueo: dos vendedores creando a la vez calculan el mismo folio. No lo tocamos aquí, pero está vivo. |
El tablero no se llena. Se lee.
De trece columnas queda una que se escribe. Las otras doce o se derivan de algo que ya pasó, o se borran porque llevan seis meses demostrando que a nadie le importaban. Y la fila deja de nacer de acordarse de abrir un Excel: nace del mensaje donde el cliente ya escribió lo que quiere.
Se escribe
El producto. Y las notas, si hacen falta.
Se lee
Estatus, encargado, teléfono, las tres fechas, los adjuntos.
Se borra
Persona de contacto y Elementos principales. 0 de 51.
Cuesta
3 valores de enum · 2 columnas · 1 CHECK. Ninguna tabla.