mogos propuesta · v2.0 · jul 2026

Tablero de búsquedas

Trece columnas. Tres se llenan.

El cuadro de Estatus de Búsquedas tiene trece columnas. Dos llevan seis meses en cero. Tres agonizan. Solo quién, cuándo y qué se escriben siempre — y el cuándo es automático. Eso no es un tablero mal llenado: es un tablero de dos columnas con once columnas de aspiración atornilladas encima. La respuesta no es un tablero mejor. Es que no se escriba.

La fuente
51 registros · 47 clientes · ene–jun 2026
Se teclea
1 campo de 13
Tablas nuevas
Ninguna
El modelo
3 valores de enum · 2 columnas · 1 CHECK
01El cuadro, medido
51 registros · 6 meses

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.

Cliente51/51
Fecha Inicio51/51
Productos de fábrica50/51
Estatus35/51
Teléfono24/51
Encargado23/51
Recursos19/51
Fecha de actualización10/51
Fecha de cierre9/51
Notas6/51
Link del proveedor3/51
Persona de contacto0/51
Elementos principales0/51
se llena siempre a medias nunca · seis meses en cero

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.

02Una tabla, no dos
la versión anterior estaba mal

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.

La llave compuesta era el síntoma

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 crearLo que ya está en ServiceQuote
lastQuoteSentAt · el relojsentToClientAt
el encargadoassignedAgentId
requestoriginalRequest
las notasinternalNotes
la fecha de cierreapprovedAt · rejectedAt
el motivo de rendirseServiceQuoteStatusHistory.reason
un servicio de rollup del estadopaymentStatus, ya calculado y adjuntado en la lista y el detalle
filtrar por estado y por cobrolos 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
03El modelo, completo
cuatro líneas
// 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.

04El ancla es el mensaje, no el chat
una conversación dura meses

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é daCómo
La fecha realCuándo preguntó el cliente, no cuándo alguien se acordó de anotarlo. La Fecha Inicio de su cuadro, pero verdadera.
El punto exacto del chatAbrir la búsqueda te deja con el scroll puesto en ese mensaje.
La conversaciónPor transitividad: Message.conversationId.
Los adjuntos relevantesLa 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.

05El estatus se lee, no se marca
dos columnas que ya existen

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 vestatus · el tratopaymentStatus · el cobro
NuevaNEW
En búsquedaSOURCING
CotizadaPENDING
En seguimientoPENDING + reloj
Aceptada, sin cobrarAPPROVEDPENDING PARTIAL
GanadaAPPROVEDPAID CLOSED
PerdidaREJECTED
RendidaCANCELLED + 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.

06«En seguimiento» sin un cron
cero columnas nuevas

«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.

07El encargado
y un agujero abierto

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.

Hoy no se valida

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.

08El tablero de Pedro
abre en trabajo, no en datos

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.

Búsquedas
10 activas · 3 sin tocar
+ Nueva búsqueda
Requieren algo 10 Nuevas 3 En seguimiento 5 Sin cobrar 2 Mías 6 Archivo · ganadas y perdidas
Cliente Qué pidió Espera Encargado Estado
Perla MarinaTubos recolección de sangre 14 dVS En seguimientoRecordar
Alba MoraSillas de fiesta · 50 transp. 100 blancas 9 dVE Aceptada, sin cobrarCobrar
Hedirberto ReyesMadejas de 50 metros 3 d NuevaTomarla
Ángel MoyaProductos de gimnasio 2 dVS En búsquedaCotizar
Guadalupe TorrealbaJuguetes variados 1 dVE CotizadaVer

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.

09Una acción por estado
nunca un menú

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.

EstadoLa acciónQué hace, sin preguntar nada más
NuevaTomarla T Se asigna a quien la toma y pasa a En búsqueda. Un clic, sin diálogo.
En búsquedaCotizar C Abre el asistente con el cliente, la ruta y la solicitud ya puestos. La misma fila avanza: no hay nada que enlazar.
CotizadaVer Nada que hacer: la pelota está donde el cliente. Sale del carril de trabajo.
En seguimientoRecordar R Manda el recordatorio por WhatsApp. Si no hay ventana abierta, con plantilla; si la hay, texto libre.
Aceptada, sin cobrarCobrar 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

Crear la fila del mensaje

Un botón sobre el mensaje del cliente. Cliente, encargado y solicitud original vienen puestos, y la fecha es la del mensaje.

Mover el estado solo

Avanza cuando avanza el trato o entra el cobro. Nadie lo teclea nunca.

Las tres fechas automáticas

Inicio, actualización y cierre. Las tres eran consecuencias disfrazadas de tarea.

Detectar el silencio el reloj

Nadie marca «seguimiento»: la fila se pone en rojo sola a los N días de enviada.

Pasar a cotización la misma fila

No hay que convertir nada: la búsqueda es la cotización, un poco más adelante.

1
Escribir el producto lo único

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.

10Mientras busca
la fase más larga

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».

11Vaciar el Excel
9 de 13 entran directo

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.

ColumnaDónde cae
ClienteclientId
Fecha IniciocreatedAt, sobreescrito al importar
Teléfonodel usuario
Estatusel enum
Productos de fábricaoriginalRequest
EncargadoassignedAgentIdsolo dos personas a mapear: Verónica (15) y Victoria Sabatini (8)
NotasinternalNotes
Fecha de actualizaciónupdatedAt
Fecha de cierreapprovedAt · rejectedAt
Persona de contacto
Elementos principales
Se borran. 0 de 51 en seis meses.
Lo que se pierde: los archivos

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 problemaCó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.

12Qué NO hacemos
y por qué
×

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.

13Qué se puede romper
y cómo lo sabemos
RiesgoArné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.

mogos · propuesta del tablero de búsquedas · v2.0 Fuente: Estatus de Búsquedas 2026 · 51 registros · reunión con Pedro, 21 de julio