feat: [TESIS-134] dispatch a pending shipment from the order detail - #56
Conversation
…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>
…pending-shipment-from-order-detail # Conflicts: # docs/guidelines/architecture.md
TomasMartin2004
left a comment
There was a problem hiding this comment.
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.
-
El error del despacho sobrevive a cambiar la elección.
dispatch.isErrorqueda hasta el próximomutate, 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ó. Undispatch.reset()enonSelect(y en el del origen) lo deja consistente. -
useOrderQuotesusaquoteKeys.allcomo 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.
createdShipmentIddistingue 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. dispatchableShipmentreplica la regla del backend (pending+ sin tracking) en vez de inventar una propia, y lo dice apuntando aConfirmDispatch.- 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.
|
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 |
🔗 Link
📝 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íopendingy 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 eldispatch_integration_idy guarda el costo.Decisiones:
pendingy sin número de seguimiento, que es la misma regla que aplicaConfirmDispatchantes de pedir la etiqueta: uno devuelto apendinga 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.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.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 dequoteKeys, como la del borrador, así que despachar no vuelve a pedir tarifas.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
useConfirmDraftOrderahora informa también si el envío llegó a abrirse: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) ytoOrderQuotePayload.features/orders/components/DispatchShipmentDialog/: el diálogo, conOriginOptionspara cuando hay que elegir el origen.features/orders/hooks/useOrderQuotes.tsyuseDispatchShipment.ts;api.tssumaquoteOrderyqueryKeys.tssumaquoteKeys.order.features/orders/pages/OrderDetailPage.tsx: el botón y el diálogo.ShipmentLifecycleCardsuma una ranuraactionen 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.toDispatchPayloadrecibe el id del depósito en vez del origen del borrador (el detalle no tiene borrador), y los estados de la cotización salen deCarrierStepPageaQuoteOptionsPanel.docs/guidelines/architecture.md: las piezas nuevas deorders.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) enlocalhost:3000, seeds del tenantnorte, y las plantillas de Andreani apuntando a un courier simulado que falle los primeros despachos.Caso 1: el escenario de la card
/orders/N: «Ciclo de vida del envío» muestra «Despachar».Caso 2: el despacho vuelve a fallar
Caso 3: dónde no aparece
📸 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.beforeunloadsinpreventDefault). Al recargar, el paso 3 llevó al paso 1./ordenes: 503, 503, 200.{"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.AND-DEMO-3, «Etiqueta generada» en la bitácora, envío $ 58.300 y total $ 148.300. «Despachar» ya no aparece.pendingno ofrecía ninguna acciónVerificación:
npm run test(72 archivos, 676 tests, 0 fallas, ya conmastermergeado),npm run lint,prettier --check .ynpm run buildlimpios.Dientes. Rompí cada regla de a una y en todos los casos se puso en rojo el test que la cubre:
dispatches the chosen option with the integration that dispatches…(y los dos del paso 3 ytoDispatchPayload)does not offer the shipment of a cancelled order,does not offer it for a cancelled orderdoes not offer to retry a shipment that was dispatched meanwhiledoes 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.
QuoteOptionsPanelse extrajo por la Regla de Dos.DispatchShipmentDialogresuelve sus datos adentro, a diferencia de los modales de producto (el motivo está arriba y enarchitecture.md). No hace falta ADR.Relación con otros PRs: #49 (TESIS-125) entró a
mastermientras abría éste. Lo mergeé: el único conflicto era la fila deordersenarchitecture.md, y quedó con las dos partes. No hay otros PRs abiertos enproyecto-web.Para el backend (no bloquea):
POST /shipments/:id/dispatchno rechaza el envío de una orden cancelada. El front no lo ofrece, pero la regla debería vivir también enConfirmDispatch, igual que enCreateShipment.🤖 Generated with Claude Code