Buscamos la conversión antes de diseñarla. No existe, y no existe de ninguna forma: no hay createOrderFromQuote, ni convertQuotation, ni toOrder, ni fromQuote. El módulo de cotizaciones tiene cero referencias a órdenes o fletes, y ServiceQuote no tiene orderId, ni freightId, ni containerId.
Y hay un solo sitio en toda la API donde nace una orden: orders.service.ts, dentro de una transacción con reintento. Uno.
-- el ciclo real, hoy Orden (creada a mano) ──consolidar N──▶ Flete ──▶ crea su factura sola (Quotation, $0, PENDING) -- lo que pide Pedro Cotización ──pagada──▶ ??? ──▶ Orden
La factura nace de la cosa física, no al revés. Eso no invalida lo que pide Pedro — la mayoría de sus cotizaciones sí terminan en un envío — pero significa que la conversión va contra el grano del modelo, y las cosas que van contra el grano se diseñan explícitas, no automáticas por debajo.
El precedente que existe, y por qué no lo copiamos
ProductSale.orderId enlaza una venta con la orden que mueve el carro. Pero es @unique —uno a uno— y su comentario explica por qué: «un carro físico es una orden y una venta». Además se enlaza a mano, con un endpoint de admin: tampoco es una conversión.
Aquí uno a uno sería falso: tres productos de tres proveedores viajan por separado. Una cotización puede producir varias órdenes.
Sorprendentemente poco. Crear una orden exige solo dos campos; todo lo demás —el usuario, el almacén destino, el flete, la dirección, el tracking, las cajas, el packing list— es opcional.
| Lo que exige una orden | ¿Lo tiene la cotización? |
|---|---|
| shippingType | Lo tiene, pero no es de fiar. Ver abajo. |
| originWarehouseId | No. La cotización tiene corridorId (China → Venezuela), no un almacén. ¿Yiwu, Guangzhou, Shenzhen? |
shippingType es obligatorio al crear la cotización de servicio, y el asistente
lo arranca en SEA. Pero Pedro dijo lo contrario en la misma reunión:
«aquí en este punto todavía no se sabe si va a ser marítimo o aéreo, nos faltan datos» — y
añadió cuándo sí se sabe: «ya cuando nosotros le pagamos al proveedor ya tenemos todo».
O sea: el campo se rellena meses antes de conocerse. La conversión no puede confiarlo; tiene que reconfirmarlo. Son dos preguntas, no una.
Del almacén de origen hay mejor noticia: el dato ya está modelado. WarehouseCorridor relaciona almacén y corredor con un role (ORIGIN · DESTINATION · TRANSIT) y una priority. El almacén de origen de un corredor es una consulta.
WarehouseCorridorRoleEnum.ORIGIN aparece una sola vez en toda la API, y es
dentro de un ejemplo de Swagger. Ningún código resuelve el almacén de origen desde el
corredor. El dato está, la consulta no existe.
Es la única pieza de lógica nueva de este proyecto: un resolutor de una consulta, con
priority como desempate. Y si el corredor viene vacío —hay cotizaciones antiguas sin
él— se pregunta, no se adivina.
Antes del disparador, una palabra que decide el diseño: «en ocasiones». No toda cotización de servicio pagada se vuelve una orden. El propio Pedro lo dijo: «hay casos puntualitos como ese, que el carajo dice: mira, mándamelo a esta dirección que yo hago el envío» — clientes que tienen su propia logística en China. La mayoría se convierten. No todas.
Eso solo ya descarta el automatismo ciego: un sistema que convierte siempre obliga a cancelar a mano las que no tocaban, que es más trabajo que crear a mano las que sí.
Y sobre cuándo, Pedro fue preciso: «yo a la hora de realizar el pago al proveedor, yo coloco aquí pago… y luego esto se convierte en un flete».
Ese evento no existe en la plataforma. Pagarle al proveedor es la cuenta por pagar, que hoy es un tab que dice «Próximamente». El pago que la plataforma sí conoce es el que hace el cliente sobre la cotización de servicio — y son cosas distintas: el cliente paga primero, Mogos le paga al proveedor después, y la mercancía se mueve al final.
Lo que hacemos
Una acción explícita, «Convertir en orden», que aparece en el centro de acciones en cuanto la cotización de servicio queda pagada. Alguien la ejecuta, y se le preguntan las dos cosas: el almacén de origen y confirmar aéreo o marítimo.
Cuando exista el pago al proveedor, esa misma acción puede dispararse sola — el trabajo no se tira, se le pone el disparador correcto.
Lo que no hacemos
Hacerlo automático hoy con el pago del cliente. Sería el mismo botón con otro significado, cambiado en silencio: se crearían órdenes de mercancía que todavía no está pagada al proveedor, y por tanto que nadie ha empezado a fabricar.
Pedro pidió automático, y lo tendrá. Pero automático desde el evento que él describió, no desde el que hay a mano.
model Order { serviceQuoteId String? // de qué cotización salió. N órdenes por cotización. }
Eso es todo el modelo. Va en la orden, no en la cotización, precisamente para que sean varias: tres productos de tres proveedores chinos salen en tres momentos distintos, y forzar @unique obligaría a mentir en el segundo envío.
Y da lo que hace falta en las dos direcciones: desde la cotización, «estas son sus órdenes»; desde la orden, «esta venía de aquí» — que es lo que hoy no se puede contestar y obliga a buscar a mano.
En la reunión Pedro dijo que al pagar ya se tiene el packing list — «Ya, sí, claro». A veces. Otras veces el proveedor lo manda días más tarde. Exigirlo para crear la orden convertiría un trámite ocasional en un bloqueo permanente.
No hace falta: packingListExtractionId y packingListFiles son opcionales al crear una orden. La orden nace sin él y lo recibe cuando llegue — con el extractor que ya existe, que lee el archivo y saca los CBM, que es exactamente la funcionalidad que Pedro mencionó.
El dinero se registra en su prefactura — ServiceQuote.amountPaid existe pero nadie lo escribe; el estado real se lee de paymentStatus. Aparece la acción.
Hereda el cliente. Resuelve el almacén de origen del corredor y reconfirma aéreo o marítimo, porque ese campo se rellenó cuando nadie lo sabía.
El extractor lee el archivo y saca los CBM. Ya funciona para las órdenes que se crean a mano.
Las dos acciones ya están en el centro de acciones. La orden entra al flujo normal sin nada especial.
La orden convertida no es una orden distinta. Desde el paso 3 es indistinguible de una creada a mano, y recorre el mismo camino hasta el contenedor. Eso es lo que hace que este proyecto sea pequeño.
Pedro pidió «un centro de acciones donde el equipo vea tareas pendientes: el cliente pagó, entonces conviértelo en orden y agrega el packing list». Ese centro ya está construido y es la pantalla de inicio del admin: cinco tipos de acción, carriles de «mías / sin tomar / de otros», «Tomar» y «Soltar», realtime y atajos de teclado.
| Tipo de acción | Estado |
|---|---|
| APPROVE_ORDER | ya existe |
| CONSOLIDATE_ORDER | ya existe |
| RESPOND_CONVERSATION | ya existe |
| ATTEND_HANDOFF | ya existe |
| COLLECT_PAYMENT | ya existe |
| CONVERT_TO_ORDER | lo único nuevo |
Y el feed se calcula al leer — no hay tabla de cola que mantener ni sincronizar. Añadir un tipo es añadir una consulta al conjunto que ya se ejecuta en paralelo. Pedro pidió una pantalla que ya tiene; lo que faltaba era una fila en ella.
Convertir siempre, automáticamente
Dos razones, y cualquiera basta. Una: Pedro dijo «en ocasiones» — hay clientes con logística propia en China. Dos: él describió el pago al proveedor como disparador, y ese evento no existe; usar el del cliente sería cambiar el significado del botón en silencio.
Exigir el packing list
Llega del proveedor, a veces días después. Es opcional en el modelo por una buena razón. Exigirlo convertiría un trámite ocasional en un bloqueo permanente.
Confiar en el tipo de envío guardado
Es obligatorio al crear y el asistente lo pone en SEA, meses antes de que se sepa. La conversión lo reconfirma. Un contenedor que sale por avión cuesta caro.
Adivinar el almacén de origen
Si el corredor no lo resuelve, se pregunta. Una orden en el almacén equivocado se descubre cuando la mercancía no aparece, que es el peor momento posible.
Una orden por cotización
Sería copiar ProductSale.orderId @unique sin su razón. Tres proveedores despachan en tres momentos.
Tocar el camino Orden → Flete → Contenedor
Funciona y mueve carga real. La orden convertida entra por el mismo sitio que cualquier otra y desde ahí es indistinguible. Ese es todo el truco.
| Riesgo | Arnés |
|---|---|
| Órdenes huérfanas | El cliente paga, se crea la orden, y el trato se cae porque el proveedor no puede entregar. La orden se cancela — ya hay estado para eso — pero conviene una prueba de que cancelar la orden no toca la factura ni el pago. |
| Conversión doble | Dos personas ejecutan la acción a la vez y salen dos órdenes. El centro de acciones ya resuelve esto con su mecanismo de «Tomar», que tiene un índice único parcial detrás. Hay que usarlo, no reinventarlo. |
| El almacén de origen mal resuelto | La consulta es nueva y no tiene precedente en el repo. Prueba con un corredor que tenga dos almacenes de origen y distinta priority, y otra con corredor nulo que debe pedir el dato en vez de elegir uno. |
| Órdenes sin packing list llenando la cola | Entran al centro de acciones como APPROVE_ORDER, que ya escala a prioridad alta a las 48 horas. Conviene medir si el volumen nuevo ahoga la cola antes de soltarlo. |
| La dependencia real | Mientras el pago al proveedor no exista, esto queda como acción manual. Es la mitad de lo que Pedro pidió. La otra mitad vive en el proyecto de la ganancia real, y conviene decírselo en esos términos y no como si estuviera resuelto. |
Una columna, una consulta y una fila en una pantalla que ya existe.
Es el proyecto más pequeño de los tres, y lo es porque casi todo estaba hecho: la orden solo exige dos campos, la cotización ya tiene uno, el almacén de origen ya está modelado, el packing list ya se extrae solo y el centro de acciones ya está construido. Lo que faltaba era el enlace — y decidir, en voz alta, cuál pago dispara qué.
Se añade
Order.serviceQuoteId. Nada más.
Se escribe
El resolutor de almacén de origen por corredor.
Se reusa
El extractor de packing list y el centro de acciones.
Se espera
El pago al proveedor, para el disparador automático.