Suite 101 · arquitectura
Tres apps hablan del mismo trabajo: quote101 lo cotiza, dash101 lo cobra y quell101 lo instala. Hasta ahora cada una guardaba su propia copia del cliente y de la lista de piezas, y nadie sabía cuál era la buena. Estos cuatro contratos hacen que sea una sola: la obra se liga al proyecto, el ítem lleva cuántas piezas son y cuántas faltan por colocar, y el cliente se reconoce por parecido y se fusiona.
No era que faltaran pantallas. Era que la misma cosa vivía tres veces. Un cliente capturado en el cotizador y otro capturado en dash101 eran dos renglones sin relación aunque fueran la misma persona; la lista de piezas de una cotización y la lista de ítems de un proyecto eran dos listas que nadie cruzaba; y la obra de quell101 —la que tiene el plano y las fotos— no sabía a qué proyecto pertenecía.
La consecuencia práctica: al cliente se le cobraba desde un renglón y se le instalaba desde otro, y si los dos no coincidían, nadie se enteraba hasta el final.
La migración 0010_obras.sql le cuelga a quell_projects una columna proyecto_id que apunta a proyectos, con un índice único parcial: una obra se liga a lo más a un proyecto, y un proyecto a lo más a una obra. No es una relación de muchos a muchos a propósito. Si una obra pudiera pertenecer a dos proyectos, la pregunta «¿a quién se le cobra esto?» dejaría de tener una sola respuesta.
La migración 0011_cantidad.sql agrega items.cantidad, entero, por omisión 1. Sirve para lo que pidió Mike: veinte puertas del mismo acabado y el mismo precio son un renglón con cantidad veinte, no veinte renglones.
Regla que no se puede olvidar: items.monto es el importe de la línea completa, no el precio de una pieza. Si son 20 puertas de 1,500, monto es 30,000 y cantidad es 20. El precio_venta del proyecto sigue siendo la suma de los monto, sin multiplicar por nada.
Se dice así de fuerte porque el error contrario no truena: si alguien tratara monto como el precio unitario y multiplicara, el precio de venta del proyecto saldría veinte veces más alto con cara de correcto, y se descubriría al cobrarle al cliente. Hay una prueba que lo cuida por nombre, «la cantidad viaja y el precio de venta NO se multiplica otra vez». Las pantallas sí muestran el precio por pieza, pero lo calculan al vuelo dividiendo.
La misma migración le cuelga a quell_elements una columna item_id. Con eso, poner un elemento en el plano de quell101 deja de ser un apunte suelto: consume una pieza de un ítem vendido.
De ahí sale GET /orgs/:o/obras/:id/sin-ubicar, que contesta los ítems vendidos del proyecto ligado a esa obra a los que todavía les faltan piezas, con cuántas faltan de cuántas. En la pantalla de quell101 es un menú —«¿es uno de los vendidos?»— que dice Puerta de clóset — faltan 8 de 20. Cuando ya se ubicaron las veinte, el ítem deja de ofrecerse y la API contesta 409 si se insiste.
El motor valida las tres cosas del lado del servidor, no de la pantalla: que el ítem sea de esa obra, que esté vendido, y que todavía falten piezas. Una pantalla vieja o una llamada a mano no pueden colarse.
Dos rutas nuevas, sin migración: GET /orgs/:o/clientes/parecidos?nombre=… y POST /orgs/:o/clientes/:id/fusionar. La comparación normaliza: ignora mayúsculas, acentos, puntuación y palabras de forma jurídica, así que «Constructora Ramírez S.A. de C.V.» y «constructora ramirez sa de cv» son la misma.
La decisión de diseño importante es cuándo se pregunta. No se hace una limpieza nocturna que junte clientes por su cuenta —fusionar mal es difícil de deshacer—: se pregunta en el momento de crear, con el nombre a medio escribir, cuando quien captura sabe la respuesta. «¿No te refieres a Constructora Ramírez?», con dos salidas: «sí, ya está» —y se usa el que existe— o «no, es otro: créalo». La fusión de duplicados que ya existen es un botón aparte, y mueve el historial completo al que se queda.
| Ruta | Contrato | Para qué |
|---|---|---|
GET /orgs/:o/obrasGET /orgs/:o/obras?sueltas=1 | 0.22.0 | Las obras de quell101 de la empresa; con sueltas=1, sólo las que no tienen proyecto. Es la lista que sale al crear un proyecto en dash101. |
GET /orgs/:o/obras/de-proyecto/:id | 0.22.0 | La obra de un proyecto, para pintar la liga que lo abre en quell101. |
POST /orgs/:o/obras/:id/ligarDELETE /orgs/:o/obras/:id/ligar | 0.22.0 | Ligar y desligar. El índice único rechaza el segundo intento. |
GET /orgs/:o/clientes/parecidos | 0.23.0 | Los clientes que se parecen a un nombre, antes de crear otro. |
POST /orgs/:o/clientes/:id/fusionar | 0.23.0 | Mover proyectos, cotizaciones y movimientos de un cliente duplicado al bueno. |
GET /orgs/:o/obras/:id/sin-ubicar | 0.24.0 | Los ítems vendidos a los que les faltan piezas por colocar en el plano. |
POST /orgs/:o/items/exportar | 0.24.1 | Ahora también escribe cantidad: la que cotizó quote101 cruza sin recapturarse. |
GET /orgs/:o/:tabla?limite= | 0.24.2 | Pedir una lista más larga, hasta 5,000 filas. Sin el parámetro, las 500 de siempre. |
Las de obras se montan antes del CRUD genérico de tablas (montarObras(rutas)). Si se montaran después, /obras/de-proyecto/:id se la comería la ruta genérica /:tabla/:id y contestaría «no existe la obra de-proyecto».
Esta sección no describe una función nueva: describe una trampa de esta API que costó tres reportes del mismo defecto, y que cualquiera que escriba una pantalla nueva se va a volver a encontrar.
Toda lista de la API viene topada en 500 filas, ordenadas por creado_at. El tope no avisa: contesta 200 con menos renglones de los que hay. Lo único que lo delata es total, que viene en la respuesta desde siempre y que nadie miraba.
Al guardar la lista de ítems de un proyecto, dash101 pedía los ítems para saber cuáles ya existían y cuáles eran nuevos. Y los ítems nunca se borran: DELETE /orgs/:o/items/:id contesta 403 items_nunca_se_borran, a propósito, porque un ítem borrado deja el historial del proyecto sin cuadrar. Quitar uno lo pasa a cancelado.
Junten las dos cosas. En un proyecto muy editado los cancelados se acumulan, empujan a los vivos recientes fuera del tope de 500, sus identificadores dejan de verse, y dash101 los da por nuevos y los vuelve a crear. Desde la pantalla se veía exactamente lo que reportó Mike: «los duplica» y «no hay manera de borrar ítems».
La regla que sale de aquí: una lectura de lista que sirva para decidir «esto ya existe» está mal si no filtra. Hay que pedir el subconjunto que importa —del proyecto, y vivos— y no confiar en recortar una lista global del lado de la pantalla.
El arreglo fueron tres cosas, no una: se pide sólo estado = 'vendido' de ese proyecto; un identificador que viene de la pantalla nunca se convierte en alta —si no aparece en la lista se intenta revivir con PATCH y sólo si eso falla se crea—; y la lectura del proyecto trae sus propios ítems en vez de recortarlos de una lista de la empresa.
Ese arreglo tapó el lado del guardado y dejó el de la lectura. Horas después, Mike abrió un proyecto suyo y vio lo contrario: la pantalla decía «Sin ítems» y el precio de venta seguía en $6,473,790.
No faltaba nada, y esa cifra era la correcta. Vale la pena entender por qué las dos cosas podían ser ciertas a la vez: precio_venta no lo cuenta la pantalla sumando lo que ve, lo calcula la API con un SUM en la base sobre los ítems vendidos. La lista, en cambio, se pedía sin filtrar el estado, y el tope va ordenado de la fila más vieja a la más nueva: en un proyecto muy editado, los cancelados son justo los más viejos. Llenaban las 500 y empujaban a los vivos fuera de la respuesta.
Es la misma trampa que la vez anterior, y ahí está la lección de verdad: un tope silencioso no produce un defecto, produce una familia de defectos, uno por cada lugar donde alguien lista para decidir algo. Taparlos de uno en uno no acaba.
Por eso el contrato 0.24.2 no cambia el tope: le da salida. ?limite= deja pedir hasta 5,000 filas, y sin el parámetro todo sigue en 500, así que ninguna pantalla que ya funciona cambia de comportamiento. El tope se queda porque protege —una lista sin techo es una manera de tumbar el Durable Object desde una pantalla—; lo que se quita es la obligación de adivinar.
La regla completa: una lista que sirve para decidir se pide comparando total contra las filas que llegaron, y si falta algo se vuelve a pedir con ?limite=. Si aun así falta, se truena. Una pantalla que truena se arregla; una pantalla que enseña de menos se cree.
En dash101 eso es listarCompleto(), y se usa en los dos lugares donde la lista decide: la lectura del proyecto y su guardado. Donde sólo se pinta —la lista de proyectos— se sigue usando la lectura normal, filtrada a los vivos: que un renglón de detalle salga corto se nota y no rompe nada, y tronar la pantalla principal de una empresa con años de trabajo sería peor que el defecto.
339 pruebas de la API en verde, con las dos migraciones comprobadas aparte con sqlite3 en memoria: que la columna nace, que el índice único rechaza la segunda liga, y que ninguna cifra vieja se movió. En dash101, 106 pruebas, y en la puerta de quell101, 29. La lista ciega tiene su propio archivo, pruebas/tope-de-listas.spec.ts, que siembra 505 cancelados y 3 vivos —la forma exacta del proyecto de Mike— y comprueba las dos cosas a la vez: que en las 500 filas de la respuesta no viene un solo vivo, y que el precio de venta sale correcto de todos modos.
Además, el recorrido con navegador de dash101 tiene dos pasos nuevos que hacen a mano lo que hizo Mike: editar la lista de ítems de un proyecto, quitar un renglón, guardar, y volver a entrar a comprobar que se quedó así. Estaba probado en memoria y aun así falló en la vida real; el recorrido es lo que hace que la próxima vez se ponga en rojo antes de que lo vea él.
Una última, que es de proceso y no de código: durante tres mezclas seguidas dash101 no llegó a producción, porque el paso de medición buscaba el texto literal de una marca que se había quitado, y la puerta de publicación —con razón— no suelta producción si staging no mide bien. El arreglo fue dejar de medir la marca por un nombre comercial y medirla por el título, el logotipo de la pantalla de entrada y que el archivo se sirva. La lección queda escrita en el muro: el mensaje de un commit no es prueba de nada; la prueba es el despliegue medido.