Skip to content

feat: [TESIS-134] dispatch a pending shipment from the order detail - #56

Merged
Sanntinat merged 7 commits into
masterfrom
TESIS-134-dispatch-pending-shipment-from-order-detail
Sep 28, 2026
Merged

Sanntinat merged 7 commits into
masterfrom
TESIS-134-dispatch-pending-shipment-from-order-detail

Conversation

@Sanntinat

@Sanntinat Sanntinat commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

🔗 Link

https://proyectofinalfrlp.atlassian.net/browse/TESIS-134

📝 Descripción

Sale de la review de TESIS-59 (#52). En el paso 3 del alta, «Confirmar orden» encadena alta, apertura del envío y despacho. Si el despacho falla, la orden ya existe y ya descontó el stock, y el borrador se vacía para que un reload no cree la misma venta dos veces. Hasta ahora, si el operador recargaba o cerraba la pestaña, ninguna otra pantalla llamaba a dispatchShipment: la orden quedaba con el envío pending y sin etiqueta, y sólo se podía despachar por API o desde el backoffice.

Tomé la alternativa A de la card: el detalle de la orden (S08) ofrece «Despachar» mientras el envío está pendiente. No hizo falta nada del backend: usa la cotización de una orden existente (POST /orders/:id/quotes, TESIS-46) y el despacho (POST /shipments/:id/dispatch, TESIS-47), que desde api#90 ya devuelve el dispatch_integration_id y guarda el costo.

Decisiones:

  • Cuándo aparece «Despachar». El envío tiene que estar pending y sin número de seguimiento, que es la misma regla que aplica ConfirmDispatch antes de pedir la etiqueta: uno devuelto a pending a mano conserva su número, y el backend lo rechaza con 409. Además no aparece en una orden cancelada. El backend no lo impide en el despacho, pero sí en el alta del envío (CreateShipment), y emitir la etiqueta de una venta que no va a salir sería pagar un despacho de más.
  • El depósito de origen sale de las líneas. El envío no lo guarda, pero cada línea sabe de qué depósito se descontó (TESIS-126). El alta manual usa uno solo, así que lo normal es que el diálogo no pregunte. Pregunta en dos casos: si las líneas salieron de más de uno (una modificación puede sumar líneas de otro) o si ninguna lo registra (las anteriores a TESIS-126 y las de webhook).
  • Un diálogo sobre ModalFrame, no una pantalla nueva. S08 no dibuja esta acción. El botón va en el encabezado de «Ciclo de vida del envío», que es donde se ve el envío pendiente, y el diálogo reúne origen, opciones y despacho. Las opciones son la misma lista del paso 3: QuoteOptionsPanel, extraída en un refactor aparte por la Regla de Dos. «Revisar origen y destino» sólo aparece en el asistente, porque una orden ya creada no tiene borrador al que volver.
  • Se despacha con la integración que despacha, con el costo elegido y ese depósito.
  • Si el despacho falla, el diálogo queda abierto con el motivo, y el botón pasa a «Reintentar el despacho». Un fallo no deja nada a medias (el backend no escribe si el courier no confirmó), así que reintentar es volver a llamar. Un 409 no se reintenta: el diálogo dice que el envío ya se despachó mientras tanto y ofrece cerrar.
  • Despachar invalida orderKeys.all, también cuando falla. Al terminar, el detalle muestra el envío despachado y el courier del listado se actualiza; ante un 409, el detalle deja de ofrecer «Despachar». La cotización cuelga de quoteKeys, como la del borrador, así que despachar no vuelve a pedir tarifas.
  • El diálogo resuelve sus datos adentro y se monta recién al abrirlo. A diferencia de los modales de producto, que son presentacionales, todo lo que pide (depósitos, cotización, despacho) es de este flujo; así el detalle no consulta depósitos ni couriers en cada visita. El envío que se despacha se fija al abrirlo, para que el refresco posterior no lo desmonte con el resultado todavía a la vista.

El paso 3, con un matiz respecto de la card. La card pide que el error remita al detalle. Lo hace, pero sólo cuando es cierto: si lo que falló es la apertura del envío, la orden queda sin envío, y el detalle tampoco lo puede despachar (ninguna pantalla abre envíos salvo el asistente). Por eso useConfirmDraftOrder ahora informa también si el envío llegó a abrirse:

Qué falló Mensaje ¿Pregunta antes de recargar o cerrar?
El despacho, con el envío ya abierto «Podés reintentarlo acá o, si salís de esta pantalla, despachar el envío desde el detalle de la orden.» No: el detalle toma la posta
La apertura del envío «Reintentalo antes de salir: la orden ya descontó el stock y todavía no tiene envío, y ninguna otra pantalla lo puede abrir.» Sí, como hasta ahora

El aviso del navegador que pidió la review de #52 queda donde el callejón sin salida sigue existiendo, y se levanta donde este PR lo resuelve.

Fuera de alcance: una orden sin envío sigue sin poder despacharse desde el detalle. Pasa con las que entran por webhook, las de los seeds y el caso raro de arriba. Resolverlo es ofrecer también «abrir el envío», que es otra decisión de producto (encendería la acción en casi todas las órdenes de canal), y no es lo que pide la card.

🛠️ Cambios realizados

  • features/orders/utils/dispatch.ts: dispatchableShipment (cuándo se ofrece), originCandidates (de qué depósito sale) y toOrderQuotePayload.
  • features/orders/components/DispatchShipmentDialog/: el diálogo, con OriginOptions para cuando hay que elegir el origen.
  • features/orders/hooks/useOrderQuotes.ts y useDispatchShipment.ts; api.ts suma quoteOrder y queryKeys.ts suma quoteKeys.order.
  • features/orders/pages/OrderDetailPage.tsx: el botón y el diálogo. ShipmentLifecycleCard suma una ranura action en el encabezado.
  • features/orders/hooks/useConfirmDraftOrder.ts + pages/CarrierStepPage.tsx + content.ts: createdShipmentId, los dos mensajes del paso 3 y el aviso del navegador sólo sin envío.
  • Refactors, en commits aparte: toDispatchPayload recibe el id del depósito en vez del origen del borrador (el detalle no tiene borrador), y los estados de la cotización salen de CarrierStepPage a QuoteOptionsPanel.
  • docs/guidelines/architecture.md: las piezas nuevas de orders.
  • Tests: reglas puras (dispatch.test.ts), el diálogo con los criterios de la card, el hook del despacho, el detalle, el panel y el paso 3 con los dos casos de falla.

🧪 Cómo probarlo (Opcional)

Precondiciones: API en master (con api#90) en localhost:3000, seeds del tenant norte, y las plantillas de Andreani apuntando a un courier simulado que falle los primeros despachos.

Caso 1: el escenario de la card

  1. Hacer un alta manual y, en el paso 3, confirmar con el courier caído: «La orden #N se creó, pero no pudimos emitir el despacho», y abajo que se puede despachar desde el detalle.
  2. Recargar: el paso 3 lleva al paso 1, porque ya no hay borrador.
  3. Ir a /orders/N: «Ciclo de vida del envío» muestra «Despachar».
  4. «Despachar» abre el diálogo con el depósito de las líneas, sin preguntar, y las opciones cotizadas.
  5. Elegir una y despachar: el detalle pasa a «Listo para despachar», con seguimiento, courier y costo, y el botón desaparece.

Caso 2: el despacho vuelve a fallar

  1. En el diálogo, despachar con el courier todavía caído: aparece «No pudimos emitir el despacho» con el motivo, y el botón pasa a «Reintentar el despacho».
  2. Reintentar cuando el courier vuelve: se despacha sin haber cerrado el diálogo.

Caso 3: dónde no aparece

  1. Una orden con el envío ya despachado, una cancelada y una sin envío no muestran «Despachar».

📸 Evidencia (Opcional)

Probado en el navegador de punta a punta contra la API de master, sobre una base propia de verificación (no la de desarrollo). Usé un courier simulado en Node que responde 503 a los dos primeros despachos de Andreani y emite la etiqueta desde el tercero.

  • Paso 3: la orden feat: [TESIS-71] semantic status components (StatusBadge, StatusSelect) #11 se creó y el despacho respondió 503. El mensaje remitió al detalle, y el navegador no preguntó antes de salir (beforeunload sin preventDefault). Al recargar, el paso 3 llevó al paso 1.
  • Detalle de feat: [TESIS-71] semantic status components (StatusBadge, StatusSelect) #11: «Despachar» en el ciclo de vida, envío «Sin cotizar», seguimiento «Pendiente de despacho».
  • Diálogo: tomó «Depósito Central» de las líneas sin preguntar. La tarifa se pidió con el CP 1900 de ese depósito y mostró Andreani $ 58.300.
  • Despacho fallido y reintento: el primer intento respondió 503 y el diálogo mostró el motivo con «Reintentar el despacho». El reintento emitió la etiqueta. En los logs del courier quedaron las tres llamadas a /ordenes: 503, 503, 200.
  • El request: {"company_integration_id": 4, "origin_warehouse_id": 1, "shipping_cost": 58300}. La 4 es la integración de despacho de Andreani; la de cotización es la 5.
  • Después: «Listo para despachar», Andreani, seguimiento AND-DEMO-3, «Etiqueta generada» en la bitácora, envío $ 58.300 y total $ 148.300. «Despachar» ya no aparece.
Antes Después
El detalle de una orden con envío pending no ofrecía ninguna acción (adjuntar captura del diálogo sobre S08 y del detalle ya despachado)

Verificación: npm run test (72 archivos, 676 tests, 0 fallas, ya con master mergeado), npm run lint, prettier --check . y npm run build limpios.

Dientes. Rompí cada regla de a una y en todos los casos se puso en rojo el test que la cubre:

Rotura Falla
Despachar con la integración que cotizó dispatches the chosen option with the integration that dispatches… (y los dos del paso 3 y toDispatchPayload)
Ofrecer «Despachar» en una orden cancelada does not offer the shipment of a cancelled order, does not offer it for a cancelled order
Reintentar después de un 409 does not offer to retry a shipment that was dispatched meanwhile
Volver a preguntar antes de salir aunque el envío exista does not ask before reloading or closing the tab, stops asking once the retry opens the shipment…

¿Afecta la arquitectura o genera un nuevo patrón?
No. Sigue ADR-002/003. QuoteOptionsPanel se extrajo por la Regla de Dos. DispatchShipmentDialog resuelve sus datos adentro, a diferencia de los modales de producto (el motivo está arriba y en architecture.md). No hace falta ADR.

Relación con otros PRs: #49 (TESIS-125) entró a master mientras abría éste. Lo mergeé: el único conflicto era la fila de orders en architecture.md, y quedó con las dos partes. No hay otros PRs abiertos en proyecto-web.

Para el backend (no bloquea): POST /shipments/:id/dispatch no rechaza el envío de una orden cancelada. El front no lo ofrece, pero la regla debería vivir también en ConfirmDispatch, igual que en CreateShipment.


🤖 Generated with Claude Code

Sanntinat and others added 6 commits September 27, 2026 21:19
…ft origin

The order detail dispatches orders that have no draft, so the payload takes
the id of the origin warehouse and nothing else from the wizard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Loading, failed, empty and the options list move to QuoteOptionsPanel, now
that the order detail needs them too. Reviewing origin and destination is
optional: an order that already exists has no draft to go back to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An order whose dispatch failed in wizard step 3 had nowhere else to be
dispatched from once the screen was left. The detail now offers «Despachar»
while its shipment is pending and without a tracking number, on orders that
are not cancelled.

The dialog takes the origin from the warehouse the lines came out of, and asks
only when there is more than one or none is recorded. It quotes the order,
lets the operator pick an option with the same list as step 3, and dispatches
with the integration that dispatches, its cost and that origin. A failed
dispatch says so and retries without closing; a 409 does not offer a retry.
Dispatching refreshes the order, so the detail shows the shipment on its way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the shipment is open and pending, a failed dispatch now says it can be
dispatched later from the order detail, and leaving the screen no longer
asks for confirmation: the detail takes over.

If the shipment could not even be opened, nothing else can open it, so the
error keeps asking to retry before leaving and the browser keeps asking
before a reload. The hook tells the page which of the two happened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…shipment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Sanntinat
Sanntinat requested a review from a team as a code owner September 28, 2026 00:56
@Sanntinat
Sanntinat requested review from TomasMartin2004 and removed request for a team September 28, 2026 00:56
…pending-shipment-from-order-detail

# Conflicts:
#	docs/guidelines/architecture.md

@TomasMartin2004 TomasMartin2004 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aprobado. Hay un conflicto que resolver antes de mergear, pero no es del código.

Verifiqué la rama en local, no de lectura: npm run lint limpio y 666 tests en verde, los números que decís.

Los dientes que declarás, probados de nuevo acá. Rompí dos reglas de a una y en las dos se puso en rojo el test que las cubre:

Rotura Falla
Saqué orderStatus === 'cancelled' de dispatchableShipment does not offer the shipment of a cancelled order + does not offer it for a cancelled order
Anulé alreadyDispatched en el diálogo does not offer to retry a shipment that was dispatched meanwhile

Lo que fui a buscar y no encontré. La opción elegida se identifica por dispatchIntegrationId, y si dos cotizaciones compartieran ese id, find devolvería la primera —la más barata, porque vienen ordenadas— y se despacharía otra tarifa de la que el operador tocó. Seguí el hilo hasta QuoteShipment#dispatchers: está indexado por quote_service_id con índice único, así que cada plantilla de cotización tiene un solo despachador y dos opciones no pueden coincidir. El id sirve como clave. Además es el mismo criterio que ya usaba el paso 3, así que tampoco lo introduce este PR.

Verifiqué también que reusar POST /orders/:id/quotes sea legítimo con un envío ya abierto: ShipmentQuotesController no mira envíos —cotiza el paquete desde la orden— así que no hay efecto raro por cotizar dos veces la misma orden.


Bloqueante para mergear, no para aprobar: conflicto con master.

docs/guidelines/architecture.md. Tu base es 5d5e881 (TESIS-59) y después entró TESIS-125 (#49), que tocó la misma fila de orders. Es el único archivo en conflicto —api.ts, content.ts y queryKeys.ts mergean solos—, así que es mecánico: quedate con las dos descripciones, la del buscador contra el search del backend y las piezas nuevas tuyas.


Dos detalles menores, ninguno bloquea.

  1. El error del despacho sobrevive a cambiar la elección. dispatch.isError queda hasta el próximo mutate, así que si el operador elige otro courier después de un fallo, sigue viendo el Alert del anterior y el botón sigue diciendo «Reintentar el despacho» para una opción que no es la que falló. Un dispatch.reset() en onSelect (y en el del origen) lo deja consistente.

  2. useOrderQuotes usa quoteKeys.all como clave de relleno cuando no hay origen. Nunca se pide, así que hoy no hace nada, pero deja una entrada con la raíz de todas las cotizaciones como clave propia. [...quoteKeys.all, 'order', orderId, 'sin-origen'] dice lo mismo sin ocupar la raíz.


Lo que más me gustó, porque es lo que hace que esto sirva y no sólo funcione:

  • El matiz sobre la card. La card pedía que el error remita al detalle; vos notaste que eso es verdad sólo si el envío llegó a abrirse, y que en el otro caso el paso 3 sigue siendo el único lugar. createdShipmentId distingue los dos y el aviso del navegador se levanta exactamente donde este PR resuelve el callejón, no antes. Eso es leer el problema y no la card.
  • La regla del origen sale de las líneas (TESIS-126) en vez de preguntar siempre, y pregunta sólo en los dos casos en que la deducción no alcanza. El flatMap(... ?? []) para descartar las que no lo registran es prolijo.
  • dispatchableShipment replica la regla del backend (pending + sin tracking) en vez de inventar una propia, y lo dice apuntando a ConfirmDispatch.
  • La evidencia con el courier simulado que falla los dos primeros despachos: los tres 503/503/200 en el log y el body del request con la integración 4 y no la 5 son justo lo que hay que mostrar para que se le crea al PR.

Tu nota para el backend es correcta y la tomo: Shipments::ConfirmDispatch no rechaza el despacho de una orden cancelada, y CreateShipment sí la excluye. El front lo tapa, pero la regla tiene que vivir del lado que la puede garantizar. Levanto la card.

Rebasá sobre master y lo mergeo.

@TomasMartin2004

Copy link
Copy Markdown
Contributor

Card levantada para lo del backend: TESIS-136 — https://proyectofinalfrlp.atlassian.net/browse/TESIS-136 (409, antes de llamar al courier, y con el control negativo de que una orden pending sigue despachando). Queda sin asignar en la épica de Logística.

@Sanntinat
Sanntinat merged commit 39fa83d into master Sep 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants