mogos propuesta · v1.0 · jul 2026

Cotizaciones · multiempresa

Hoy la cotización no sale del edificio.

El botón dice «Enviar al cliente». Lo que hace es cambiar un estado de DRAFT a PENDING y guardar una fecha. No hay correo, no hay PDF, no hay WhatsApp — el módulo entero tiene cero referencias a notificaciones. Lo que pidieron Pedro y Andy es lo mismo visto desde dos lados: que la cotización se convierta en un documento que llega a una persona, con el membrete de quien la emite.

Origen
Pedro · 21 jul · Andy/David · 26 jul
Alcance
Documento + entrega · rediseño del flujo
No entra
4 proyectos, con spec propio
Riesgo
Bajo · aditivo, con arnés
Página pública

Todos los nombres, clientes, montos y cuentas de las maquetas son ficticios — esta página es pública y no lleva datos reales de nadie. Y el nombre legal, la dirección, el logo y los datos bancarios de la empresa de China son marcadores: falta decidir si quien factura es la entidad de China continental o la de Hong Kong. Nada del diseño cambia cuando lleguen: son filas en una tabla.

01El hallazgo
verificado en el código

Antes de diseñar nada mapeamos el sistema completo — Prisma, el módulo del API, el admin, la app del cliente, la generación de PDF, permisos, pagos y notificaciones. Cinco cosas cambiaron el planteamiento, y las cinco están verificadas contra el código, no supuestas.

01

«Enviar al cliente» no envía nada

Cambia el estado y estampa sentToClientAt. Eso es todo. Y el admin muestra «Cotización reenviada al cliente». El toast miente. Así que lo de Pedro no es adjuntar un PDF al correo: no hay correo.

service-quotes.service.ts:830-860 · cero refs a email/notificaciones en el módulo

02

No hay generador de PDF — pero sí hay plantilla

En todo el monorepo existe un solo PDF real: la nota de entrega firmada. Y hay 329 líneas de A4 listo para imprimir que nada en el código lee. Encima, el correo de Postmark dice textualmente «Adjuntamos el PDF de tu cotización»: promete un adjunto que nunca existió.

pdf/quotation/template.html · postmark/new-quotation/content.html:326

03

La tarifa Mogos sí la ve el cliente

El selector % / USD del asistente es una ilusión del frontend: suma la tarifa dentro del precio y le pega « (Tarifa servicio: 5%)» a la descripción. Y el filtro por rol nunca toca el objeto de la factura. Lo que Pedro pidió esconder está impreso en la línea.

create-service-quote-schema.ts:194 · client-view.mapper.ts (sin projectQuotation)

04

En chino saldrían cuadritos

La imagen de Docker instala FreeFont, Liberation y emoji. No instala fonts-noto-cjk. Cualquier 中文 que pase hoy por el generador sale ▯▯▯. Una línea del Dockerfile es la diferencia entre que la proforma exista o no.

apps/api/Dockerfile:80-82

05

No existe ninguna noción de empresa

Cero. Lo más cercano es un enum de dos valores atado a cuentas bancarias que el flujo de cotizaciones nunca escribe — así que todas las facturas de servicio están cayendo silenciosamente en US_LLC. Y el logo es una constante base64 de 276 KB dentro del bundle del API: un PNG de 5260 px que se muestra a 140.

FinancialEntityEnum · pdf.constants.ts

06

Dos cosas ya construidas que nadie usa

El centro de acciones existe y es la home del admin: cinco tipos de acción, Tomar/Soltar, realtime, atajos de teclado. Y el modelo Lead —nombre, teléfono, encargado, ligado al chat de WhatsApp— lleva meses llenándose solo, sin controlador ni pantalla. Es, campo por campo, el cuadro de Pedro.

/acciones · model Lead + create-lead.action.ts

02La empresa emisora
un membrete, no un inquilino

Una empresa aquí no es un inquilino, es un membrete: la respuesta a «¿quién emite este documento y a quién le pagan?». Esa distinción es lo que separa una semana de trabajo de un mes. Un membrete es una fila y una cláusula where. Un inquilino obliga a mover la empresa activa al token firmado, re-scopear los cinco atajos globales de MANAGER y poner companyId en las quince tablas que mueven dinero o carga.

// una fila por empresa. el PDF lee de aquí, no de tres archivos distintos.
model Company {
  code            String   // MOGOS_VE · MOGOS_CN
  legalName       String   // "Mogos C.A."
  tradeName       String   // lo que sale grande en el documento
  taxId           String?
  logoUrl         String   // CDN. ya no base64 en el bundle.
  addressLines    String[]
  defaultLocale   DocumentLocaleEnum   // ES | EN_ZH
  defaultCurrency CurrencyEnum
  financialEntity FinancialEntityEnum? // cierra el agujero: hoy TODO cae en US_LLC
  bankAccounts    CompanyBankAccount[]
}

model CompanyUser { userId, companyId, isDefault  @@id([userId, companyId]) }
model ServiceQuote { …  companyId String?  // nullable · backfill = MOGOS_VE }

CompanyUser sirve para todos: Andy tiene una fila, Pedro una, tú dos. El cliente tiene una. Y la visibilidad es un filtro por defecto, no un muro: las listas llegan filtradas por la empresa activa, igual que hoy los fletes se filtran por agente. Quien tenga el enlace directo la ve — eso es una decisión consciente, no un descuido.

El mismo documento, dos membretes

Mogos C.A.

J-500832109
Quinta Orión, La Castellana
Chacao 1060, Caracas, Venezuela
0424-1856592 · info@mogosgroup.com

code · MOGOS_VE

Idioma del documentoEspañol
MonedaUSD
Entidad contableVE
Corredor por defectoCN → VE
BancoBank of America · Mogolain LLC
DocumentoCotización

[Nombre legal · China]

[Registro fiscal]
[Dirección · Guangzhou / Hong Kong]
[Teléfono] · [correo]

code · MOGOS_CN falta el dato

Idioma del documentoEN / 中文
MonedaUSD + RMB
Entidad contableHK (por confirmar)
Corredor por defectoCN → VE
Banco[cuenta Hong Kong]
DocumentoProforma Invoice

La prueba de que no rompió nada: se siembra MOGOS_VE con los valores que ya están escritos a mano en tres archivos, más el bloque bancario que hoy vive en el resumen de flete. Se rellena companyId en todas las cotizaciones existentes. El día 1, nadie nota absolutamente nada. Ese es el criterio de aceptación.

Y no hay un tercer selector

El admin ya carga dos —corredor y entidad de finanzas— y el propio código documenta el dolor de que choquen. Si tienes una sola empresa, la empresa activa es implícita y no ves nada. Solo tú y Samuel verían un cambio de empresa, discreto, junto al idioma.

  • Andy nunca ve un selector. Tiene una empresa.
  • Pedro tampoco.
  • Tú, un menú de dos líneas donde ya está el idioma.

Lo que se arregla de paso

  • La entidad contable deja de ser mentira. Hoy toda factura de servicio cae en US_LLC porque nadie escribe el campo. Con la empresa emisora, sale del membrete.
  • Los datos de la empresa dejan de estar triplicados en tres archivos que ya divergieron (uno con tildes, otro sin, uno con banco y dos sin).
  • El logo sale del bundle del API — 276 KB menos, y por fin intercambiable.
03El motor de documentos
una extracción, no una reescritura

Hoy el servicio de PDF son 666 líneas con un método soldado a la nota de entrega, y el logo incrustado como constante. El cambio es una extracción pura, en tres movimientos. El primero es el arnés de seguridad: si la nota de entrega sigue saliendo idéntica, la abstracción es correcta.

renderHtmlToPdf(html, opts)

Solo la parte de Puppeteer: pool de navegador, página, A4, márgenes → Buffer. La nota de entrega sigue funcionando byte por byte llamando a esto. Cero cambio de comportamiento.

DocumentsService.render()

Recibe { type, data, company, locale }, elige plantilla, le entrega la empresa y devuelve { buffer, filename }. Es el único punto que sabe de documentos.

Plantillas como funciones puras

(data, company, locale) => html, con tres parciales compartidos: letterhead, moneyBlock, bankBlock. Los datos de la empresa aparecen una vez en el código.

Sobre los idiomas, el repo ya tiene el precedente y es bueno: la etiqueta de identificación de cajas escribe ADDRESS / 地址 fijo, con este comentario — «el artículo físico lo leen dos audiencias a la vez». Misma regla aquí: un mapa de etiquetas por locale, ES y EN_ZH. Una plantilla, dos juegos de etiquetas, cero i18n en la capa de documentos.

Dos cosas de infraestructura sin las cuales esto no existe

QuéPor qué
fonts-noto-cjk Una línea en el Dockerfile, junto a fonts-liberation. Sin ella cada carácter chino sale ▯. Es literalmente la diferencia entre que la proforma funcione y que salga rota.
Caché del logo El logo pasa a URL, como debe ser. Pero el render espera networkidle0, que hoy resuelve al instante porque no hay activos remotos. Con un CDN lento, el PDF se cuelga hasta el timeout de 30 s. Así que el motor descarga el logo una vez y lo cachea en memoria como data URI, con la marca de texto como respaldo. La URL es la fuente de verdad; el caché es lo que la hace segura.
04La proforma, para Andy
EN / 中文 · USD + RMB

Esta es la estructura del formato que Andy genera hoy a mano, ya como plantilla del sistema. Los términos de pago, el incoterm y el tiempo de entrega viven como valores por defecto de la empresa, con excepción por cotización — porque «30% advance / 70% before shipping» es lo mismo casi siempre, y volver a escribirlo cada vez es trabajo que no aporta.

PROFORMA INVOICE形式发票
No. PI20260726A
Date 日期2026-07-26
Currency 币种USD
Valid Until 有效期7 days
Exchange Rate 汇率1 USD = 6.82 RMB
Seller & Buyer 买卖双方

Seller / 卖方

[Nombre legal · China]

[Dirección]
Contact / 联系人: Andy
Phone / 电话: 188 1409 3323
Email / 邮箱: andy@…

Buyer / 买方

Cliente

[Empresa]
Country / 国家: Venezuela
Phone / 电话: +58 …
Email / 邮箱: …
Product Details 产品明细
No.序号 Description品名 Specifications规格型号 Qty数量 Unit单位 Unit Price单价 Amount金额
1 Sea freight · 40HQ container Nansha → La Guaira 1 cntr 2,400.0016,368.00 2,400.0016,368.00
2 Customs clearance Export documentation 1 svc 180.001,227.60 180.001,227.60
Charges Summary 费用汇总
Goods Value / 货物金额USD 2,580.00RMB 17,595.60
Subtotal / 小计USD 2,580.00RMB 17,595.60
TOTAL AMOUNT / 总计金额USD 2,580.00RMB 17,595.60
Payment & Delivery 付款与交货
Payment Terms 付款方式30% advance payment / 70% before shipping
Trade Terms 贸易条款FOB Nansha
Delivery Time 交货时间15–30 days after first payment
Remarks 备注
[Nombre comercial · China]

Authorized Signature 授权签字

El tipo de cambio se escribe, no se raspa. Andy negocia el suyo — puso 6.82 a mano. Hay un módulo de tasas en el sistema, pero solo trae BCV y no tiene CNY. Construir un raspador de yuanes para esto sería resolver un problema que nadie tiene: un campo con el valor por defecto de la empresa, editable, y listo.

05La cotización, para Pedro
español · misma plantilla

La misma plantilla, otro membrete, otro juego de etiquetas — y una diferencia deliberada: sin montos por producto. El cliente venezolano ya negoció el total por WhatsApp; el desglose por unidad solo invita a renegociar y delata que el precio trae margen adentro. En la proforma sí van, porque ahí la línea es un flete o una aduana, no una fábrica.

COTIZACIÓNMogos C.A. · J-500832109
COT-20260727-0014
Fecha27 de julio, 2026
Vigente hasta10 de agosto, 2026
ServicioBúsqueda de productos
RutaChina → Venezuela · marítimo
Cliente

Facturar a

Cliente de ejemplo

V-00.000.000
+58 400-0000000
cliente@ejemplo.com

Atendido por

Pedro

Ventas · Mogos Group
pedro@mogosgroup.com
Productos
# Producto Especificaciones Proveedor Cantidad
1Laptop Lenovo ThinkPad16 GB RAM · 512 GB SSDShenzhen Yitai20
2Repuestos de motosKit completo · según listaGuangzhou Motor4
Subtotal productosUSD 12,600.00
Búsqueda de productos línea propiaUSD 25.00
SeguroUSD 100.00
Descuento (5%)− USD 630.00
TOTALUSD 12,095.00
Datos para el pago
BancoBank of America
TitularMogolain LLC
Cuenta•••• •••• 0673
SWIFTBOFAUS3N
Mogos C.A. · Importamos futuro

Firma autorizada

Qué ve el cliente, exactamente

  • Sí: el nombre del proveedor elegido — se mantiene tu decisión del 12 de julio, aunque Pedro pidió taparlo.
  • Sí: la tarifa como su propia línea, al lado de Aduana o Seguro.
  • Sí: los pagos y el saldo pendiente — pero filtrados: sin anulados, sin identificadores internos.
  • No: el precio por producto. Ni unitario ni total de línea (dividir es fácil: o está el detalle o no está).
  • No: los proveedores descartados, los enlaces de Alibaba, las notas internas. Eso ya se tapa hoy.

La fuga que se cierra

El filtro por rol tapa proveedor y notas internas, pero nunca toca el objeto de la factura: hoy el cliente recibe cada línea con su texto —incluida la coletilla de la tarifa— y cada fila de pago cruda, anuladas incluidas.

Se añade projectQuotation al mismo mapper, con su prueba: un cliente nunca recibe un precio por producto, nunca un calculationDetails, nunca un pago anulado.

06La entrega
nada de esto se inventa

Toda esta cadena ya existe y ya funciona — es exactamente la tubería de la nota de entrega firmada. No estamos inventando la entrega: estamos apuntando la cotización hacia ella.

1
DRAFT → PENDING ya existe

Cambia el estado y estampa sentToClientAt. Lo único que hace hoy el botón.

2
documents.render() nuevo

Elige plantilla según el tipo de servicio, membrete según companyId, idioma según la empresa. Devuelve el PDF.

3
S3 → quote.documentUrl patrón probado

Mismo uploadBase64File con ContentDisposition: inline que ya sube las notas de entrega al CDN.

4
Notificación en la app patrón probado

Enlace profundo al visor que ya vive en /account/documents/view. El cliente no sale de la plataforma.

5
Correo con el PDF adjunto 3 bugs de paso

El método de adjuntos ya está escrito y nadie lo llama. Se conecta — y se arreglan tres cosas que el cliente está viendo hoy.

Los tres bugs que se arreglan al pasar por ahí

Qué pasa hoyQué se hace
El correo siempre dice «rechazada» Compara un enum de Prisma contra la cadena 'approved', así que la condición nunca es cierta. Va en el asunto del correo: «Tu cotización COT-… ha sido rechazada», incluso cuando se ganó.
Los enlaces del correo son un 404 Apuntan a /app/quotations/:id, ruta que no existe en la app del cliente. El propio código lleva el TODO puesto.
El pie del correo ignora la empresa Está escrito a mano —«© 2025 Mogos · Caracas, Venezuela»— y descarta las variables de empresa que el servicio sí envía. Para un segundo membrete, el pie tiene que leerlas.

Y el modo de fallo importa. Si Chromium se queda sin memoria, la cotización igual se envía — pero a diferencia de la nota de entrega, que se queda sin PDF para siempre y en silencio, aquí queda un documentGeneratedAt vacío y la pantalla ofrece «Reintentar». Es la diferencia entre un hueco invisible y uno que se ve.

07El flujo, rediseñado
contar los clics

El asistente de hoy está organizado por la forma de la base de datos —producto solicitado, luego opciones propuestas, luego cargos, luego factura— y no por la cabeza de Pedro. Pedro no piensa «un producto, y después sus opciones». Piensa «esta laptop, de este chino, a este precio». Una sola frase. La plataforma la parte en dos pasos y dos ventanas modales.

Hoy

15

clics · 4 acordeones
2 ventanas modales · 16 campos

Hoy4 pasos
1Detalles del usuario
Buscar y elegir cliente.
2Solicitud+ modal
Tipo de servicio · tipo de envío · «Agregar producto»◱ diálogo · 4 campos · ruta sí/no · tarjeta de corredor
3Opciones propuestas+ modal
Interruptor de cobro · pestaña por producto · «Agregar proveedor»◱ diálogo · 8 campos · radio «recomendado» · tira de tarifa (modo + valor)
4Cargos y resumen
Cargos adicionales · total · enviar
Propuesto3 secciones · 0 modales
1Para quién
Cliente · qué servicio. La empresa emisora y la ruta se derivan; hay un enlace para cambiarlas.
2Qué le cotizas
ProductoCantProveedorPrecio U.
Laptop Lenovo20Shenzhen Yitai630.00
Producto…   

+ comparar otra opción  ·  el caso raro, no un paso

3Cuánto
Tarifa de servicio (%) · cargos · descuento (% o monto) · Total
×Opciones propuestasdesaparece
Su contenido cabe en una columna de la fila.

El movimiento es uno solo: el producto y su proveedor son una fila. Se escribe en la tabla, directo, como en Excel — que es de donde viene Pedro.

Qué desaparece

Se vaPor qué
El paso 3 completo«Opciones propuestas» era un paso porque el modelo tiene dos tablas. El usuario no debería pagar por eso.
Las dos ventanas modalesSe escribe en línea. Un modal para agregar una fila a una tabla es un modal de más.
Marcas sugeridasPedro lo pidió y explicó por qué: «de igual forma creo que lo puedo poner en especificaciones o en el nombre del producto». Un campo cuyo contenido cabe en otro campo es un campo de más.
CBM, peso, alto, ancho, largoPedro pidió quitar el CBM. Los otros cuatro hoy dan error 400 si los mandas al crear, porque el DTO en línea no los declara: llevan meses siendo campos que no se pueden usar.
El paso de rutaEl corredor se deriva de la empresa emisora más el tipo de envío. China→Venezuela es el 95%. Queda un enlace «cambiar ruta» para el 5%.
La tira de tarifa por productoPasa a ser un solo número en la sección 3. Como dijo Pedro, «eso siempre va a ser un porcentaje»: es una negociación, no una por producto.
La coletilla (Tarifa servicio: 5%)La tarifa deja de ser un modificador del precio del producto y pasa a ser su propia línea. Que es donde el cliente ya espera verla.

Y el descuento en porcentaje no se inventa: el patrón ya existe para el descuento por lealtad de los fletes, que guarda { percentage, basis } y lo vuelve a derivar en cada reconstrucción. Las cotizaciones de servicio simplemente no lo alcanzan. Se reusa.

08Qué NO hacemos
y por qué
×

Un paso de «Gestiones de Compra»

Es un tipo de servicio, no una pantalla. Lo que cambia es qué columnas muestra la misma tabla: el cliente ya trae su proveedor, así que esa columna viene bloqueada y la tarifa es un porcentaje del monto. Un valor más en el selector, cero pantallas nuevas.

×

Un campo de «margen» en el asistente

Pedro calcula el margen en LARC, contra lo que se le pagó al proveedor. Eso es la tanda de la ganancia real. Meterlo aquí a medias produce dos verdades sobre el mismo número, y la de la plataforma sería la falsa.

×

Dictado de voz

Salió en la reunión, y el propio Pedro lo descartó: «en verdad lo puedo rellenar así sin ningún problema». El problema nunca fue escribir, fue la cantidad de pantallas. Quitar nueve clics resuelve lo que el dictado disimularía.

×

Mejorar el pegado de la conversación de WhatsApp

Regla permanente de este módulo: ese campo no se mejora, se elimina. La conversación ya vive en la base de datos, con su hilo y su teléfono. Cualquier botón de portapapeles o parser legitima transcribir a mano un dato que ya está a una tabla de distancia.

×

Un tercer selector en la barra

Ya hay dos y el código documenta el dolor de que choquen. La empresa activa es implícita cuando solo tienes una — es decir, para todo el mundo menos dos personas.

09Qué se puede romper
y cómo lo sabemos
RiesgoArnés
Sacar Puppeteer de su método Es el cambio más delicado, porque la nota de entrega firmada es un documento legal que sale hoy. Prueba de regresión con archivo dorado: el PDF debe renderizar idéntico antes y después de la extracción. Si cambia un píxel, la extracción está mal.
Los índices de búsqueda por trigrama Ya se borraron cinco veces en migraciones anteriores. Cualquier migración de esta tanda pasa por la guarda de check:search-indexes antes de compilar.
El índice único parcial de «recomendado» Prisma no puede modelarlo y migrate dev propone borrarlo como deriva. La migración lo re-declara explícitamente.
Las pruebas E2E del camino dorado Hay cuatro para cotizaciones y el rediseño las rompe todas. Es bueno: son la lista de verificación de que el flujo nuevo hace lo mismo con menos pasos.
La fuente CJK infla la imagen fonts-noto-cjk pesa. Se instala la variante -core, no la completa, y se mide el tamaño de la imagen antes y después.
El cliente deja de ver el precio por producto Es un cambio visible para clientes que hoy sí lo ven. No es un bug, es una decisión — pero conviene avisarle a Pedro y a Jesús antes de que salga, no después.

Que la cotización deje de ser una fila y se vuelva un documento que llega.

Todo lo demás —el tablero de búsquedas, la ganancia real de las gestiones de compra, la conversión a orden, las cotizaciones de flete desde China— sale de aquí. Pero ninguno tiene sentido mientras el botón que dice «enviar» no envíe.

Queda fuera · 01

El tablero de búsquedas. Lead ya existe y ya se llena solo; le falta pantalla.

Queda fuera · 02

La ganancia real: cuenta por pagar al proveedor, el 3% de Alibaba, los $45 de transferencia.

Queda fuera · 03

Cotización pagada → orden con packing list. El centro de acciones ya está construido.

Queda fuera · 04

Cotizaciones de flete completo, consolidado y aduana desde China. Y las comisiones de vendedores.

mogos · propuesta de cotizaciones multiempresa · v1.0 Fuentes: reuniones del 21 y 26 de julio · Estatus de Búsquedas 2026 · Quotation China.pdf