// apps/admin/schemas/service-quotes/create-service-quote-schema.ts const finalUnitPrice = computeUnitPriceWithFee(supplier.unitPriceUsd, mode, value); … unitPriceUsd: finalUnitPrice, // ← se manda ESTE. el costo no viaja.
El comentario del propio archivo lo confirma: «the backend receives a single unitPriceUsd on each proposed option». Un solo número. El API no tiene forma de saber si es un costo o un precio con margen adentro — y ServiceQuoteProductOption tampoco: tiene un solo campo de precio, y no existe supplierCostUsd.
Buscamos margin, markup, utilidad en todo el módulo de cotizaciones: cero coincidencias. El margen no se puede calcular no porque falte la resta — falta el minuendo.
Y el producto tampoco tiene identidad
productName es texto libre VarChar(255) por cotización. «Jacuzzy» escrito en enero y «Jacuzzi» en junio son dos cadenas sin relación.
Aunque tuvieras los precios, no habría contra qué agruparlos. Son dos pérdidas distintas y hacen falta las dos.
Pero el dato ya se está grabando
Cada ServiceQuoteProductOption guarda proveedor, marca, precio, moneda, días de entrega, garantía, CBM, dimensiones, fotos, enlace y createdAt.
Ya es un punto de dato de sourcing. Guarda el número equivocado y cuelga de un texto libre. Eso es todo lo que está mal.
// 1 · el costo deja de destruirse. la tarifa vive aparte, el precio se calcula. model ServiceQuoteProductOption { supplierCostUsd Float // lo que pidió la fábrica ← NUEVO unitPriceUsd Float // lo que se le cobra al cliente (derivado) } model ServiceQuote { serviceFeeMode ServiceFeeModeEnum? // PERCENT | USD_PER_UNIT ← NUEVO serviceFeeValue Float? // hoy solo vive en el navegador } // 2 · identidad del producto. mismo molde que Supplier, a diez líneas de aquí. model SourcedProduct { name String normalizedName String @unique // minúsculas, sin acentos, espacios colapsados category MerchandiseCategoryEnum catalogProductId String? // si algún día se vuelve ficha de venta @@index([normalizedName(ops: raw("gin_trgm_ops"))], type: Gin) } model ServiceQuoteRequestedProduct { sourcedProductId String? }
Por qué no cuelga del catálogo
Era lo natural, y el esquema lo prohíbe con argumento escrito: «una ServiceQuote es un SERVICIO y su precio se descubre; un Product ya tiene monto conocido. Mezclarlos hacía imposible sacar estadísticas de ventas comparables contra el año pasado, que es justo lo que Andreína pidió».
Por eso Product.price es obligatorio. Colgar de ahí los productos que se están buscando llenaría el catálogo de fichas sin precio que nunca se vendieron.
Por qué sí una tabla nueva, esta vez
En el tablero de búsquedas rechazamos una tabla porque todo lo que necesitaba ya vivía en ServiceQuote. Aquí no hay candidato: Product es el catálogo de ventas y exige precio; productName es texto por cotización.
Y el molde está a diez líneas: Supplier resolvió exactamente este problema — texto libre convertido en entidad con normalizedName @unique.
Y la relación producto ↔ proveedor no se declara
Un producto tiene uno o más proveedores, claro. Pero cada opción ya graba (producto, proveedor, costo, fecha), así que «¿quién nos ha vendido jacuzzis?» es un DISTINCT sobre esas filas. Una tabla de unión crearía una segunda verdad que puede desfasarse de la historia real.
Y hay confirmación de que ese es el camino de la casa: las métricas de proveedor —timesQuoted, timesSelected, avgDeliveryDays, lastUsedAt— no están persistidas: se computan al leer con dos groupBy sobre las opciones. Le estamos agregando un eje a un patrón que ya existe.
Hay una casualidad afortunada: el frontend, además de inflar el precio, pega la tarifa a la descripción de la opción — "Laptop Lenovo (Tarifa servicio: 5%)". Y ya existe un regex que lo lee de vuelta, escrito para otra cosa. La procedencia quedó grabada.
| Lo que dice la fila | El costo original era |
|---|---|
| (Tarifa servicio: 5%) | precio ÷ 1.05 |
| (Tarifa servicio: USD 1.80/u) | precio − 1.80 |
| sin sufijo | el precio guardado ya es el costo |
Esa última línea cubre dos poblaciones de golpe: las del asistente con tarifa en cero, y todas las opciones agregadas desde el panel de detalle, que nunca pasan por la transformación. La regla es completa y no deja zona gris.
El histórico arranca con todo lo que Mogos ha cotizado, con un margen de céntimos por el ida y vuelta del flotante. La alternativa era empezar de cero.
La regla que ordena todo esto: el histórico no vive en una pantalla de análisis. Vive debajo del campo donde Verónica está a punto de escribir un precio. Un tablero de precios sería un sitio más al que acordarse de ir; una línea bajo el campo es algo que no se puede no ver.
01 · La fila que se acuerda
Elige el producto del catálogo y la fila se rellena con el mejor trato que Mogos ha conseguido — proveedor y costo, no el último precio. Es el ancla correcta: partir del mejor histórico y ver que hoy está 11% más caro le dice a Verónica exactamente qué negociar. La franja de abajo es la memoria, y el chispograma le da la tendencia sin abrir nada.
Y resuelve el favor que Pedro y Jesús se hacen por WhatsApp —«recomiéndame un proveedor de bicicletas»— sin que nadie pregunte.
02 · La aritmética, en una línea
Hoy la tarifa es una tira ámbar por producto, con un selector de modo y un campo. Pasa a ser un número por cotización —como dijo Pedro, «eso siempre va a ser un porcentaje»— y cada fila muestra la cuenta en una línea. Tres números y una flecha.
Lo importante es lo que ya no pasa: el costo deja de ser pisado. Se guarda, se muestra a quien cotiza, y nunca entra en lo que se le manda al cliente — que ve un solo número, como hoy.
03 · La ficha del producto buscado
| Proveedor | Cuándo | Costo | Entrega | Quién preguntó |
|---|---|---|---|---|
| Guangzhou Sun | 12 jun 2026 | 910.00 | 25 d | Verónica |
| Shenzhen Yitai | 02 may 2026 | 865.00 | 30 d | Verónica |
| Ningbo Aqua | 18 mar 2026 | 890.00 | 35 d | Victoria |
| Shenzhen Yitai | 04 mar 2026 | 820.00 | 28 d | Victoria |
Cinco columnas y tres cifras arriba. «Quién preguntó» no es decoración: cuando un precio se ve raro, lo primero que quieres es a quién preguntarle. Y de aquí sale «cotizar esto otra vez», que abre una cotización nueva con el producto y el último proveedor puestos.
| Hoy | Después |
|---|---|
| Empezar de cero cada vez que llega el mismo producto | La fila parte del mejor trato conseguido. Para un producto repetido, elegirlo es cotizarlo. |
| Preguntarle a Pedro por WhatsApp quién vende bicicletas | Es un DISTINCT. La respuesta está bajo el campo. |
| La tira ámbar de tarifa por producto | Un número por cotización, y la cuenta en una línea por fila. |
| Calcular el margen a mano en otra herramienta | Es una resta: precio − costo. Ambos guardados. |
| No saber si un proveedor te subió el precio | El chispograma, sin abrir nada. |
Los clics no bajan — el asistente ya lo dejamos en seis. Lo que cambia es que ahora cada fila que se escribe deja rastro, y la siguiente vez cuesta menos que la anterior. Eso es lo contrario de lo que pasa hoy, donde cada búsqueda empieza exactamente igual de vacía que la primera.
Un tablero de análisis de precios
Sería un sitio más al que acordarse de ir — y ya sabemos cómo termina eso: el cuadro de Pedro tiene dos columnas en cero desde hace seis meses. El dato va debajo del campo donde se decide, no en una pantalla aparte.
Un gráfico grande
La pregunta es «¿subió o bajó?», y eso cabe en 54 píxeles. Un gráfico con ejes y leyenda pide atención que esta decisión no merece.
Una tabla de unión producto ↔ proveedor
Cada opción ya graba el par con su fecha y su precio. Declararlo aparte sería una segunda verdad que puede desfasarse de la historia real.
Colgar el producto buscado del catálogo de ventas
El esquema lo prohíbe con argumento: rompería las estadísticas comparables que pidió Andreína. Se modela espejo de Supplier, con un enlace opcional al catálogo para cuando un producto sí se vuelva ficha de venta.
Un campo de margen que alguien teclee
Es una resta entre dos números que ya están guardados. Un tercer campo sería una tercera oportunidad de que no cuadre.
Mostrarle el costo al cliente
El costo se guarda en la fila, y la barrera es la misma que ya funciona: nunca entra en el objeto que arma el correo ni en la proyección del cliente. La plantilla, además, es posicional — físicamente no tiene dónde meterlo.
| Riesgo | Arnés |
|---|---|
| La moneda | El campo se llama unitPriceUsd y convive con un currency que acepta USD · BS · EUR · USDT. Para un histórico eso es una mina: comparar bolívares contra dólares a lo largo del tiempo no significa nada. Hay que decidir —antes de la migración— si se normaliza a USD al guardar o si el histórico separa por moneda. No hay opción de dejarlo como está. |
| El relleno del costo histórico | Un ida y vuelta de flotante deja céntimos de error. La migración registra qué regla aplicó a cada fila (sufijo de porcentaje, sufijo de monto, o sin sufijo) para que el resultado sea auditable y no un número que apareció. |
| El precio deja de ser lo que se escribe | Hoy unitPriceUsd es lo que se teclea; pasa a ser derivado. Todo lo que lo lee —las líneas de la factura, los totales, el recálculo— tiene que seguir dando el mismo número. Prueba de archivo dorado sobre cotizaciones existentes: mismo total antes y después. |
| Duplicados en el catálogo de buscados | Es el riesgo de darle identidad al producto. Lo ataja normalizedName @unique más el índice trigrama — el mismo par que ya evita proveedores duplicados. Aun así habrá «Jacuzzi» y «Jacuzzi 6 personas»: conviene poder fusionar dos fichas sin perder su historia. |
| El índice trigrama | Se han borrado cinco veces en este repo. Se declara en el esquema y pasa por la guarda de check:search-indexes antes de compilar. |
Cada precio que una fábrica dio, guardado en vez de borrado.
El dato ya se está capturando: alguien le pregunta a una fábrica, la fábrica responde un número, y ese número se escribe en la plataforma. Lo único que pasa es que el navegador lo pisa antes de guardarlo, y que no hay contra qué agruparlo. Arreglar esas dos cosas convierte seis meses de trabajo desechado en la única ventaja que un importador puede acumular.
Se guarda
El costo y la tarifa por separado. El precio se calcula.
Se agrupa
SourcedProduct, espejo de Supplier.
Se consulta
El histórico y el margen. Ninguna tabla de más.
Se decide antes
La moneda. Es lo único que no puede quedarse como está.