diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 79d7c05..800ec1d 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -321,32 +321,32 @@ features/[nombre]/ **`orders` — piezas y por qué:** -| Archivo | Rol | -| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `api.ts` | Frontera con Rails. Además de traducir el `snake_case`, resuelve el envío de una orden: `GET /shipments?order_id=` dice cuántos hay y `GET /shipments/:id` trae la bitácora, que el listado no incluye | -| `utils/shipment.ts` | Reglas del ciclo de vida: qué etapa está vigente, cuándo entró en cada una y qué badge muestra el encabezado. El estado vigente sale del envío y no del último evento, que puede llegar desordenado | -| `utils/payment.ts` | Las cuentas del detalle, en centavos para que sumar importes no deje restos de punto flotante. El subtotal es el `total_amount` persistido (TESIS-114), no la suma de las líneas recalculada | -| `components/InfoPanel/` | Panel lateral de pares rótulo-valor, compartido por los datos del cliente y los del envío. Se extrajo al aparecer el **segundo** consumidor, no antes — `feature-structure.md` §6 | -| `components/ShipmentStateMessage.tsx` | Lo que muestran los paneles del envío cuando no hay uno que dibujar (sin envío, envío duplicado, cargando, error). Lo comparten el ciclo de vida y los datos del envío, así los dos dicen lo mismo | -| `utils/draft.ts` | Reglas del paso 1 del alta manual: el filtro del buscador (SKU o nombre, en memoria), qué línea es válida, los totales del borrador y cuándo se habilita «Siguiente» (`canProceed`). Puras, para probarlas sin montar la pantalla | -| `components/ProductPicker/` | Buscador + cantidad + precio unitario para sumar un SKU al borrador. `GET /products` no busca, así que trae una página (100, el máximo) y filtra del lado del cliente. No hay precio de lista en `products`: el precio se carga a mano | -| `utils/shipping.ts` | Reglas del envío en el alta: qué depósito cubre el borrador entero (`warehouseCoverage`, paso 2) y los requests del paso 3: cotizar el borrador (`toDraftQuotePayload`), crear la orden (`toCreateOrderPayload`), despachar la opción elegida (`toDispatchPayload`) y el total con envío (`totalWithShipping`). La orden sale de un solo depósito: el `warehouse_id` es el mismo en todas las líneas | -| `components/OriginWarehousePicker/` | Los depósitos como tarjetas elegibles (grupo de radios), cada una con su nivel de stock para el borrador. Un depósito que no cubre la orden entera queda deshabilitado, con el motivo en su descripción accesible. El stock por depósito sale de `GET /products/:id`, uno por línea: el listado del catálogo no trae el desglose | -| `components/SelectableCard/` | La tarjeta elegible de los grupos de radios del alta: el depósito de origen (paso 2) y el operador logístico (paso 3). Se extrajo cuando apareció el segundo | -| `components/QuoteOptionList/` | Las opciones cotizadas como tarjetas elegibles, una por fila: courier, plazo y tarifa. Marca la más económica, que llega primera; no inventa los otros badges de S07, porque la API no los informa | -| `components/OrderConfirmCard/` | «Resumen de la orden» del paso 3: productos, envío elegido, total final y los botones del asistente. Sin opción elegida el envío dice que falta elegirla, no que cuesta 0 | -| `hooks/useDraftQuotes.ts` | La cotización del borrador (`POST /quotes`, TESIS-131), sin crear la orden. Su clave (`quoteKeys`) no cuelga de `orders`: confirmar invalida las órdenes y no tiene que volver a cotizar | -| `hooks/useConfirmDraftOrder.ts` | «Confirmar orden»: alta, envío y despacho encadenados. No son atómicos —el despacho llama a un courier externo—, así que un reintento retoma desde donde falló en vez de crear la orden otra vez. Si el despacho falla con el envío ya abierto, el error remite al detalle de la orden, que lo puede despachar (TESIS-134). Si ni siquiera se abrió el envío, el paso 3 es el único lugar que lo abre: la pantalla lo dice y el navegador pregunta antes de recargar o cerrar la pestaña | -| `utils/dispatch.ts` | Reglas del despacho desde el detalle (TESIS-134): cuándo se ofrece «Despachar» —envío `pending` sin número de seguimiento, en una orden que no está cancelada, la misma regla que aplica el backend antes de pedir la etiqueta— y de qué depósito sale. El envío no guarda su origen: sale del depósito de las líneas, y sólo se pregunta si son varios o ninguna lo registra | -| `components/DispatchShipmentDialog/` | El despacho de un envío que quedó sin despachar, sobre `ModalFrame`: resuelve el origen, cotiza la orden (`POST /orders/:id/quotes`), deja elegir con la lista del paso 3 y despacha con la integración que despacha. Un despacho que falla se reintenta sin cerrar el diálogo; un 409 no se reintenta. Resuelve sus datos adentro, a diferencia de los modales de producto: todo lo que pide es de este flujo, y se monta recién al abrirlo | -| `components/QuoteOptionsPanel/` | La cotización con sus estados —cargando, error, vacía, opciones—. La comparten el paso 3 y el despacho desde el detalle; «Revisar origen y destino» sólo aparece donde se pueden cambiar | -| `hooks/useOrderQuotes.ts` · `hooks/useDispatchShipment.ts` | La cotización de una orden que ya existe —su clave cuelga de `quoteKeys`, como la del borrador, para que despachar no vuelva a cotizar— y el despacho, que invalida la orden también cuando falla: un 409 quiere decir que otro lo despachó y el detalle tiene que dejar de ofrecerlo | -| `components/DestinationFieldsCard/` | Domicilio de entrega: calle, ciudad, provincia y código postal (TESIS-128). La provincia se elige de `GET /orders/provinces`, no de una lista escrita en el front: tiene que coincidir con la que valida el backend | -| `utils/edit.ts` | Reglas de la modificación: qué líneas quedan fijas (las anteriores a TESIS-126 no saben de qué depósito salieron), cuáles piden más stock del libre —con la misma cuenta neta que el backend, que devuelve antes de descontar—, si las líneas cambiaron (sin cambios, el `PUT` no manda `items` y no toca stock) y el body del `PUT` | -| `components/OrderEditForm/` | El formulario de S09. Dos formularios de React Hook Form (datos de la orden y domicilio) y las líneas en estado local, guardados juntos en un `PUT` con `If-Match`. La versión que viaja en `If-Match` es la que se leyó al montar, congelada: un refetch en segundo plano no la puede reemplazar sin que el guardado deje de detectar el cambio de otro operador. El formulario no se remonta cuando cambia la versión (guardar la cambia, y remontar descartaría el callback que lleva al detalle); sólo cuando el operador recarga después de un 412. La regla de cuándo una orden no se edita (cancelada, o con el envío ya salido) es la de `Orders::UpdateOrder` | -| `components/EditLinesTable/` | Las líneas en edición, con cantidad `−` / `+` y quitar como columna propia: el menú de acciones de `DataTable` no se apaga por fila, y una línea fija no se puede quitar | -| `components/FormSection/` | Tarjeta de sección con ícono y título de los formularios del alta. Se extrajo cuando el paso 2 la necesitó dos veces | -| `components/DraftItemsTable/` | Las líneas cargadas, sobre `DataTable` compacta, con la cantidad editable en la fila. El buscador va en su barra, como «Agregar SKU» en S05 | +| Archivo | Rol | +| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api.ts` | Frontera con Rails. Además de traducir el `snake_case`, resuelve el envío de una orden: `GET /shipments?order_id=` dice cuántos hay y `GET /shipments/:id` trae la bitácora, que el listado no incluye | +| `utils/shipment.ts` | Reglas del ciclo de vida: qué etapa está vigente, cuándo entró en cada una y qué badge muestra el encabezado. El estado vigente sale del envío y no del último evento, que puede llegar desordenado | +| `utils/payment.ts` | Las cuentas del detalle, en centavos para que sumar importes no deje restos de punto flotante. El subtotal es el `total_amount` persistido (TESIS-114), no la suma de las líneas recalculada | +| `components/InfoPanel/` | Panel lateral de pares rótulo-valor, compartido por los datos del cliente y los del envío. Se extrajo al aparecer el **segundo** consumidor, no antes — `feature-structure.md` §6 | +| `components/ShipmentStateMessage.tsx` | Lo que muestran los paneles del envío cuando no hay uno que dibujar (sin envío, envío duplicado, cargando, error). Lo comparten el ciclo de vida y los datos del envío, así los dos dicen lo mismo | +| `utils/draft.ts` | Reglas del paso 1 del alta manual: el filtro del buscador (SKU o nombre, en memoria), qué línea es válida, los totales del borrador y cuándo se habilita «Siguiente» (`canProceed`). Puras, para probarlas sin montar la pantalla | +| `components/ProductPicker/` | Buscador + cantidad + precio unitario para sumar un SKU al borrador. `GET /products` no busca, así que trae una página (100, el máximo) y filtra del lado del cliente. No hay precio de lista en `products`: el precio se carga a mano | +| `utils/shipping.ts` | Reglas del envío en el alta: qué depósito cubre el borrador entero (`warehouseCoverage`, paso 2) y los requests del paso 3: cotizar el borrador (`toDraftQuotePayload`), crear la orden (`toCreateOrderPayload`), despachar la opción elegida (`toDispatchPayload`) y el total con envío (`totalWithShipping`). La orden sale de un solo depósito: el `warehouse_id` es el mismo en todas las líneas | +| `components/OriginWarehousePicker/` | Los depósitos como tarjetas elegibles (grupo de radios), cada una con su nivel de stock para el borrador. Un depósito que no cubre la orden entera queda deshabilitado, con el motivo en su descripción accesible. El stock por depósito sale de `GET /products/:id`, uno por línea: el listado del catálogo no trae el desglose | +| `components/SelectableCard/` | La tarjeta elegible de los grupos de radios del alta: el depósito de origen (paso 2) y el operador logístico (paso 3). Se extrajo cuando apareció el segundo | +| `components/QuoteOptionList/` | Las opciones cotizadas como tarjetas elegibles, una por fila: courier, plazo y tarifa. Marca la más económica, que llega primera; no inventa los otros badges de S07, porque la API no los informa | +| `components/OrderConfirmCard/` | «Resumen de la orden» del paso 3: productos, envío elegido, total final y los botones del asistente. Sin opción elegida el envío dice que falta elegirla, no que cuesta 0 | +| `hooks/useDraftQuotes.ts` | La cotización del borrador (`POST /quotes`, TESIS-131), sin crear la orden. Su clave (`quoteKeys`) no cuelga de `orders`: confirmar invalida las órdenes y no tiene que volver a cotizar | +| `hooks/useConfirmDraftOrder.ts` | «Confirmar orden»: alta, envío y despacho encadenados. No son atómicos —el despacho llama a un courier externo—, así que un reintento retoma desde donde falló en vez de crear la orden otra vez. Con la orden ya creada, falle donde falle, el error remite al detalle de la orden: desde TESIS-141 ése abre el envío que no llegó a abrirse y despacha el que quedó `pending` (TESIS-134). Por eso hay un solo mensaje y ningún aviso al salir de la pantalla — antes el paso 3 era la única salida, y ya no lo es | +| `utils/dispatch.ts` | Reglas de las dos acciones del envío en el detalle, fuera de los componentes para probarlas sin montar la pantalla. `canOpenShipment` (TESIS-141): se ofrece «Crear envío» cuando el envío resuelve en «no hay» y la orden no está cancelada —misma exclusión que `Shipments::CreateShipment`—, y nunca mientras la consulta carga o falla, porque ofrecerlo sobre un envío que no se pudo leer invita a un 409. `dispatchableShipment` (TESIS-134): se ofrece «Despachar» con el envío `pending` sin número de seguimiento. Y de qué depósito sale: el envío no guarda su origen, sale del de las líneas, y sólo se pregunta si son varios o ninguna lo registra | +| `components/DispatchShipmentDialog/` | El despacho de un envío que quedó sin despachar, sobre `ModalFrame`: resuelve el origen, cotiza la orden (`POST /orders/:id/quotes`), deja elegir con la lista del paso 3 y despacha con la integración que despacha. Un despacho que falla se reintenta sin cerrar el diálogo; un 409 no se reintenta. Resuelve sus datos adentro, a diferencia de los modales de producto: todo lo que pide es de este flujo, y se monta recién al abrirlo | +| `components/QuoteOptionsPanel/` | La cotización con sus estados —cargando, error, vacía, opciones—. La comparten el paso 3 y el despacho desde el detalle; «Revisar origen y destino» sólo aparece donde se pueden cambiar | +| `hooks/useOrderQuotes.ts` · `hooks/useDispatchShipment.ts` · `hooks/useCreateOrderShipment.ts` | La cotización de una orden que ya existe —su clave cuelga de `quoteKeys`, como la del borrador, para que despachar no vuelva a cotizar—, el despacho y el alta del envío. Los dos últimos invalidan la orden también cuando fallan: un 409 quiere decir que el hecho ya ocurrió (otro lo despachó, o el envío ya existía) y el detalle tiene que reflejarlo en vez de insistir | +| `components/DestinationFieldsCard/` | Domicilio de entrega: calle, ciudad, provincia y código postal (TESIS-128). La provincia se elige de `GET /orders/provinces`, no de una lista escrita en el front: tiene que coincidir con la que valida el backend | +| `utils/edit.ts` | Reglas de la modificación: qué líneas quedan fijas (las anteriores a TESIS-126 no saben de qué depósito salieron), cuáles piden más stock del libre —con la misma cuenta neta que el backend, que devuelve antes de descontar—, si las líneas cambiaron (sin cambios, el `PUT` no manda `items` y no toca stock) y el body del `PUT` | +| `components/OrderEditForm/` | El formulario de S09. Dos formularios de React Hook Form (datos de la orden y domicilio) y las líneas en estado local, guardados juntos en un `PUT` con `If-Match`. La versión que viaja en `If-Match` es la que se leyó al montar, congelada: un refetch en segundo plano no la puede reemplazar sin que el guardado deje de detectar el cambio de otro operador. El formulario no se remonta cuando cambia la versión (guardar la cambia, y remontar descartaría el callback que lleva al detalle); sólo cuando el operador recarga después de un 412. La regla de cuándo una orden no se edita (cancelada, o con el envío ya salido) es la de `Orders::UpdateOrder` | +| `components/EditLinesTable/` | Las líneas en edición, con cantidad `−` / `+` y quitar como columna propia: el menú de acciones de `DataTable` no se apaga por fila, y una línea fija no se puede quitar | +| `components/FormSection/` | Tarjeta de sección con ícono y título de los formularios del alta. Se extrajo cuando el paso 2 la necesitó dos veces | +| `components/DraftItemsTable/` | Las líneas cargadas, sobre `DataTable` compacta, con la cantidad editable en la fila. El buscador va en su barra, como «Agregar SKU» en S05 | **`reports` — piezas y por qué:** diff --git a/src/features/orders/content.ts b/src/features/orders/content.ts index 2f3f408..bf22b2a 100644 --- a/src/features/orders/content.ts +++ b/src/features/orders/content.ts @@ -78,6 +78,15 @@ export const ordersCopy = { error: 'No pudimos cargar el envío de la orden.', retry: 'Reintentar', }, + /** Abrir el envío de una orden que todavía no lo tiene (TESIS-141). */ + openShipment: { + action: 'Crear envío', + creating: 'Creando…', + created: 'Envío creado. Ya se puede despachar.', + /** 409: lo abrió el asistente del alta, u otra pestaña. */ + duplicated: 'La orden ya tenía un envío. Lo acabamos de traer.', + error: 'No pudimos crear el envío de la orden.', + }, /** Despachar desde el detalle un envío que quedó sin despachar (TESIS-134). */ dispatch: { action: 'Despachar', @@ -359,12 +368,15 @@ export const ordersCopy = { /** La orden ya existe: lo que falló es el envío o el despacho. */ dispatch: (orderLabel: string) => `La orden ${orderLabel} se creó, pero no pudimos emitir el despacho.`, - /** El envío quedó `pending`: el detalle de la orden lo puede despachar (TESIS-134). */ + /** + * Un solo mensaje para las dos fallas que dejan la orden creada: que no + * se haya abierto el envío y que no se haya podido despachar. Desde + * TESIS-141 las dos tienen la misma salida, porque el detalle de la orden + * abre el envío que falta (TESIS-141) y despacha el que quedó `pending` + * (TESIS-134). Antes eran dos textos porque sin envío no había a dónde ir. + */ dispatchLater: - 'Podés reintentarlo acá o, si salís de esta pantalla, despachar el envío desde el detalle de la orden.', - /** Ni siquiera se abrió el envío: ninguna otra pantalla lo abre, ésta es la única salida. */ - shipmentMissing: - 'Reintentalo antes de salir: la orden ya descontó el stock y todavía no tiene envío, y ninguna otra pantalla lo puede abrir.', + 'Podés reintentarlo acá o, si salís de esta pantalla, terminar el envío desde el detalle de la orden.', retryDispatch: 'Reintentar el despacho', viewOrder: 'Ver la orden', }, diff --git a/src/features/orders/hooks/useConfirmDraftOrder.test.tsx b/src/features/orders/hooks/useConfirmDraftOrder.test.tsx index 2a966f6..547bf9b 100644 --- a/src/features/orders/hooks/useConfirmDraftOrder.test.tsx +++ b/src/features/orders/hooks/useConfirmDraftOrder.test.tsx @@ -99,10 +99,10 @@ describe('useConfirmDraftOrder', () => { await waitFor(() => expect(result.current.createdOrderId).toBe(8829)) }) - // Con el envío abierto, el detalle de la orden lo puede despachar (TESIS-134); - // sin él, no. - it('tells the page whether the shipment was opened before the dispatch failed', async () => { - vi.spyOn(api, 'createOrder').mockResolvedValue(ORDER) + // El envío que no llegó a abrirse se reintenta; la orden, que sí se creó, no + // se vuelve a crear: sería una segunda venta. + it('retries the shipment it could not open without creating the order twice', async () => { + const createOrder = vi.spyOn(api, 'createOrder').mockResolvedValue(ORDER) const createShipment = vi .spyOn(api, 'createOrderShipment') .mockRejectedValueOnce(new Error('boom')) @@ -111,11 +111,11 @@ describe('useConfirmDraftOrder', () => { const { result } = renderHook(() => useConfirmDraftOrder(), { wrapper }) await act(() => result.current.mutateAsync(INPUT).catch(() => undefined)) - expect(result.current.createdShipmentId).toBeNull() - await act(() => result.current.mutateAsync(INPUT).catch(() => undefined)) - await waitFor(() => expect(result.current.createdShipmentId).toBe(31)) + expect(createShipment).toHaveBeenCalledTimes(2) + expect(createOrder).toHaveBeenCalledTimes(1) + await waitFor(() => expect(result.current.createdOrderId).toBe(8829)) }) it('leaves no created order behind when the order itself is rejected', async () => { diff --git a/src/features/orders/hooks/useConfirmDraftOrder.ts b/src/features/orders/hooks/useConfirmDraftOrder.ts index fbe9551..3a55161 100644 --- a/src/features/orders/hooks/useConfirmDraftOrder.ts +++ b/src/features/orders/hooks/useConfirmDraftOrder.ts @@ -31,15 +31,15 @@ interface Options { * volver a crearla sería una segunda venta. `createdOrderId` le dice a la * pantalla si eso pasó. * - * `createdShipmentId` le dice si el envío también llegó a abrirse: con envío - * `pending`, lo que falte se puede despachar desde el detalle de la orden - * (TESIS-134); sin envío, este paso sigue siendo el único lugar que lo abre. + * No hace falta distinguir hasta dónde llegó: con la orden creada, el detalle + * termina el envío de las dos maneras —lo abre si no llegó a abrirse (TESIS-141) + * y lo despacha si quedó `pending` (TESIS-134)—. `progress` sí lleva la cuenta, + * pero puertas adentro, para que el reintento no repita lo que ya salió bien. */ export function useConfirmDraftOrder({ onOrderCreated }: Options = {}) { const queryClient = useQueryClient() const progress = useRef({ orderId: null, shipmentId: null }) const [createdOrderId, setCreatedOrderId] = useState(null) - const [createdShipmentId, setCreatedShipmentId] = useState(null) const mutation = useMutation({ mutationFn: async ({ order, dispatch }) => { @@ -52,9 +52,7 @@ export function useConfirmDraftOrder({ onOrderCreated }: Options = {}) { const orderId = progress.current.orderId if (progress.current.shipmentId === null) { - const shipment = await createOrderShipment(orderId) - progress.current.shipmentId = shipment.id - setCreatedShipmentId(shipment.id) + progress.current.shipmentId = (await createOrderShipment(orderId)).id } await dispatchShipment(progress.current.shipmentId, dispatch) @@ -74,5 +72,5 @@ export function useConfirmDraftOrder({ onOrderCreated }: Options = {}) { }, }) - return { ...mutation, createdOrderId, createdShipmentId } + return { ...mutation, createdOrderId } } diff --git a/src/features/orders/hooks/useCreateOrderShipment.test.tsx b/src/features/orders/hooks/useCreateOrderShipment.test.tsx new file mode 100644 index 0000000..2e28296 --- /dev/null +++ b/src/features/orders/hooks/useCreateOrderShipment.test.tsx @@ -0,0 +1,73 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import * as api from '../api' +import { orderKeys } from '../queryKeys' +import type { Shipment } from '../types' + +import { useCreateOrderShipment } from './useCreateOrderShipment' + +const SHIPMENT = { id: 31, status: 'pending' } as Shipment + +// El detalle de la orden y su envío, ya en caché. El envío resuelto en «no +// hay», que es el estado desde el que se ofrece abrirlo. +function seededClient() { + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + queryClient.setQueryData(orderKeys.detail(8829), {}) + queryClient.setQueryData(orderKeys.shipment(8829), { kind: 'none' }) + return queryClient +} + +function renderFor(queryClient: QueryClient) { + return renderHook(() => useCreateOrderShipment(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }) +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('useCreateOrderShipment', () => { + it('opens the shipment of the order', async () => { + const create = vi.spyOn(api, 'createOrderShipment').mockResolvedValue(SHIPMENT) + const { result } = renderFor(seededClient()) + + await act(() => result.current.mutateAsync(8829)) + + expect(create).toHaveBeenCalledWith(8829) + }) + + // Criterio de la card: al terminar, la tarjeta muestra el envío `pending` y + // con él aparece «Despachar». + it('refreshes the order and its shipment', async () => { + vi.spyOn(api, 'createOrderShipment').mockResolvedValue(SHIPMENT) + const queryClient = seededClient() + const { result } = renderFor(queryClient) + + await act(() => result.current.mutateAsync(8829)) + + expect(queryClient.getQueryState(orderKeys.detail(8829))?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(orderKeys.shipment(8829))?.isInvalidated).toBe(true) + }) + + // Un 409 es que el envío ya existe —lo abrió el asistente del alta, u otra + // pestaña—: lo que corresponde es traer el que está, no insistir. + it('refreshes the shipment also when the order already had one', async () => { + vi.spyOn(api, 'createOrderShipment').mockRejectedValue( + Object.assign(new Error('Order already has a shipment'), { status: 409 }), + ) + const queryClient = seededClient() + const { result } = renderFor(queryClient) + + await act(async () => { + await result.current.mutateAsync(8829).catch(() => undefined) + }) + + expect(queryClient.getQueryState(orderKeys.shipment(8829))?.isInvalidated).toBe(true) + }) +}) diff --git a/src/features/orders/hooks/useCreateOrderShipment.ts b/src/features/orders/hooks/useCreateOrderShipment.ts new file mode 100644 index 0000000..77b34c0 --- /dev/null +++ b/src/features/orders/hooks/useCreateOrderShipment.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { ApiRequestError } from 'shared/api/types' + +import { createOrderShipment } from '../api' +import { orderKeys } from '../queryKeys' +import type { Shipment } from '../types' + +/** + * Abre el envío de una orden desde su detalle (TESIS-141): `POST + * /orders/:id/shipment` (TESIS-105). Nace `pending` y sin courier; elegirlo es + * la otra acción, la de despachar (TESIS-134). + * + * `onSettled` y no `onSuccess`: un 409 quiere decir que el envío ya existe + * —otra pestaña, o el asistente del alta— y lo que corresponde ahí es traer el + * que ya está, no insistir. El detalle se refresca en los dos casos. + */ +export function useCreateOrderShipment() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (orderId) => createOrderShipment(orderId), + onSettled: () => queryClient.invalidateQueries({ queryKey: orderKeys.all }), + }) +} diff --git a/src/features/orders/pages/CarrierStepPage.test.tsx b/src/features/orders/pages/CarrierStepPage.test.tsx index bbc6c1f..91365f1 100644 --- a/src/features/orders/pages/CarrierStepPage.test.tsx +++ b/src/features/orders/pages/CarrierStepPage.test.tsx @@ -98,13 +98,6 @@ function renderPage() { const option = (name: RegExp) => screen.getByRole('radio', { name }) const confirmButton = () => screen.getByRole('button', { name: /Confirmar orden/ }) -// Lo que hace el navegador al recargar: si algún listener cancela el evento, -// pregunta antes de salir. -function leaveIsBlocked() { - const event = new Event('beforeunload', { cancelable: true }) - window.dispatchEvent(event) - return event.defaultPrevented -} const total = () => screen.getByLabelText('Total final') function stubConfirmation() { @@ -327,21 +320,10 @@ describe('CarrierStepPage', () => { fireEvent.click(confirmButton()) expect( - await screen.findByText(/despachar el envío desde el detalle de la orden/), + await screen.findByText(/terminar el envío desde el detalle de la orden/), ).toBeInTheDocument() }) - it('does not ask before reloading or closing the tab', async () => { - failDispatchOnce() - renderPage() - - fireEvent.click(option(/Andreani/)) - fireEvent.click(confirmButton()) - await screen.findByText(/se creó, pero/) - - expect(leaveIsBlocked()).toBe(false) - }) - it('no longer offers going back to a draft that is already an order', async () => { failDispatchOnce() renderPage() @@ -366,8 +348,10 @@ describe('CarrierStepPage', () => { }) }) - // Si falla la apertura del envío, la orden existe y no tiene envío: el - // detalle no lo puede despachar, y ninguna otra pantalla lo abre. + // Si falla la apertura del envío, la orden existe y todavía no tiene envío. + // Desde TESIS-141 el detalle de la orden lo abre, así que la salida es la + // misma que cuando el envío existe y lo que falló fue el despacho: por eso no + // hay un mensaje aparte ni un aviso al salir de la pantalla. describe('when the shipment could not even be opened', () => { function failShipmentOnce() { const calls = stubConfirmation() @@ -377,7 +361,7 @@ describe('CarrierStepPage', () => { return calls } - it('says no other screen can open it', async () => { + it('points at the order detail, the same way a failed dispatch does', async () => { failShipmentOnce() renderPage() @@ -385,23 +369,13 @@ describe('CarrierStepPage', () => { fireEvent.click(confirmButton()) expect( - await screen.findByText(/todavía no tiene envío, y ninguna otra pantalla lo puede abrir/), + await screen.findByText(/terminar el envío desde el detalle de la orden/), ).toBeInTheDocument() }) - it('asks before reloading or closing the tab', async () => { - failShipmentOnce() - renderPage() - expect(leaveIsBlocked()).toBe(false) - - fireEvent.click(option(/Andreani/)) - fireEvent.click(confirmButton()) - await screen.findByText(/se creó, pero/) - - expect(leaveIsBlocked()).toBe(true) - }) - - it('stops asking once the retry opens the shipment, even if the dispatch fails', async () => { + // El envío que no llegó a abrirse se reintenta; la orden, que sí se creó, no + // se vuelve a crear. + it('opens the shipment on the retry, without creating the order again', async () => { const calls = failShipmentOnce() calls.dispatch.mockReset() calls.dispatch.mockRejectedValue(new Error('El courier no contestó.')) @@ -411,8 +385,9 @@ describe('CarrierStepPage', () => { fireEvent.click(confirmButton()) fireEvent.click(await screen.findByRole('button', { name: 'Reintentar el despacho' })) - await screen.findByText(/despachar el envío desde el detalle de la orden/) - expect(leaveIsBlocked()).toBe(false) + await screen.findByText(/terminar el envío desde el detalle de la orden/) + expect(calls.createShipment).toHaveBeenCalledTimes(2) + expect(calls.createOrder).toHaveBeenCalledTimes(1) }) }) diff --git a/src/features/orders/pages/CarrierStepPage.tsx b/src/features/orders/pages/CarrierStepPage.tsx index bd4b501..f6acb7d 100644 --- a/src/features/orders/pages/CarrierStepPage.tsx +++ b/src/features/orders/pages/CarrierStepPage.tsx @@ -1,6 +1,6 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' import { Alert, Box, Button, Stack } from '@mui/material' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { Navigate, useNavigate } from 'react-router-dom' import { PageWrapper } from 'shared/components' import { notify, useOrderDraftStore } from 'shared/store' @@ -89,9 +89,6 @@ export function CarrierStepPage() { ) const quotes = useDraftQuotes(payload) const confirm = useConfirmDraftOrder({ onOrderCreated: clearDraft }) - const shipmentMissing = - confirm.createdOrderId !== null && confirm.createdShipmentId === null && !confirm.isSuccess - useLeaveWarning(shipmentMissing) // Sin cliente o sin líneas no hay orden que cotizar; sin origen o destino hay // que volver al paso 2. Se llegó por URL, recargando, o se canceló el @@ -134,13 +131,10 @@ export function CarrierStepPage() { {createdOrderId === null ? carrier.errors.order : carrier.errors.dispatch(orderLabel)}{' '} {confirm.error.message} - {createdOrderId === null ? null : ( - - {confirm.createdShipmentId === null - ? carrier.errors.shipmentMissing - : carrier.errors.dispatchLater} - - )} + {/* Un solo mensaje: con la orden ya creada, el detalle termina el + envío tanto si no llegó a abrirse (TESIS-141) como si quedó + `pending` sin despachar (TESIS-134). */} + {createdOrderId === null ? null : {carrier.errors.dispatchLater}} {createdOrderId === null ? null : ( + ) + } + + if (!canOpen) return undefined + + return ( + + ) +} + function NotFound() { return ( @@ -149,7 +194,10 @@ function NotFound() { * propósito: si el envío no se puede leer, la orden se muestra igual. * * Un envío que quedó sin despachar —el despacho del alta falló y la pantalla se - * cerró— se despacha desde acá (TESIS-134). + * cerró— se despacha desde acá (TESIS-134). Y una orden que todavía no tiene + * envío lo abre desde acá (TESIS-141): hasta entonces el único lugar que lo + * abría era el paso 3 del alta manual, así que las órdenes que entran por + * webhook no tenían forma de llegar al circuito logístico. */ export function OrderDetailPage() { const { orderId } = useParams() @@ -166,6 +214,7 @@ export function OrderDetailPage() { // refresca el detalle, y el diálogo no tiene que desmontarse con el resultado // todavía a la vista porque el envío dejó de estar pendiente. const [dispatchingId, setDispatchingId] = useState(null) + const openShipment = useCreateOrderShipment() if (id === undefined || order.error?.status === NOT_FOUND_STATUS) return @@ -183,7 +232,24 @@ export function OrderDetailPage() { const payment = paymentSummary(order.data, resolved?.shippingCost ?? null) const status = headerStatus(order.data.status, shipment.data) const dispatchable = dispatchableShipment(order.data.status, shipmentView) + const canOpen = canOpenShipment(order.data.status, shipmentView) const orderLabel = formatOrderId(order.data.externalOrderId, order.data.id) + // El id suelto y no `order.data.id` dentro del callback: el angostado de los + // returns de arriba no alcanza adentro de una función, que TypeScript no sabe + // cuándo se llama. + const loadedOrderId = order.data.id + + // Un 409 no es un fallo que haya que reintentar: el envío ya existe (lo abrió + // el asistente, u otra pestaña) y la invalidación de `onSettled` ya lo trae. + function createShipment() { + openShipment.mutate(loadedOrderId, { + onSuccess: () => notify(detail.openShipment.created, 'success'), + onError: (failure) => + failure.status === CONFLICT_STATUS + ? notify(detail.openShipment.duplicated) + : notify(detail.openShipment.error, 'error'), + }) + } return ( @@ -203,18 +269,13 @@ export function OrderDetailPage() { } - onClick={() => setDispatchingId(dispatchable.id)} - > - {detail.dispatch.action} - - ) - } + action={shipmentAction({ + dispatchable, + canOpen, + creating: openShipment.isPending, + onDispatch: setDispatchingId, + onOpen: createShipment, + })} /> diff --git a/src/features/orders/utils/dispatch.test.ts b/src/features/orders/utils/dispatch.test.ts index ee50247..731c0b3 100644 --- a/src/features/orders/utils/dispatch.test.ts +++ b/src/features/orders/utils/dispatch.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest' import type { OrderLine, OriginWarehouse, Shipment } from '../types' -import { dispatchableShipment, originCandidates, toOrderQuotePayload } from './dispatch' +import { + canOpenShipment, + dispatchableShipment, + originCandidates, + toOrderQuotePayload, +} from './dispatch' const PENDING: Shipment = { id: 31, @@ -61,6 +66,35 @@ describe('dispatchableShipment', () => { }) }) +describe('canOpenShipment', () => { + // El caso que motiva la card: una orden de webhook nace sin envío y hasta + // TESIS-141 no había forma de abrirlo desde la app. + it('lets an order with no shipment open one', () => { + expect(canOpenShipment('paid', { kind: 'none' })).toBe(true) + }) + + it('also lets a pending order open it, like the backend does', () => { + expect(canOpenShipment('pending', { kind: 'none' })).toBe(true) + }) + + // Misma exclusión que Shipments::CreateShipment::NON_SHIPPABLE_STATUSES. + it('refuses a cancelled order', () => { + expect(canOpenShipment('cancelled', { kind: 'none' })).toBe(false) + }) + + it('refuses an order that already has one', () => { + expect(canOpenShipment('paid', { kind: 'single', shipment: PENDING })).toBe(false) + }) + + // Que el envío no exista tiene que ser un hecho: sobre una consulta que + // todavía no resolvió o que falló, ofrecerlo invitaría a un 409. + it('refuses while the shipment is unknown', () => { + expect(canOpenShipment('paid', { kind: 'loading' })).toBe(false) + expect(canOpenShipment('paid', { kind: 'error', onRetry: () => undefined })).toBe(false) + expect(canOpenShipment('paid', { kind: 'duplicated', count: 2 })).toBe(false) + }) +}) + describe('originCandidates', () => { it('takes the warehouse the lines came out of', () => { expect(originCandidates([line(1, 2), line(2, 2)], WAREHOUSES)).toEqual([PACHECO]) diff --git a/src/features/orders/utils/dispatch.ts b/src/features/orders/utils/dispatch.ts index c1badc7..0340a99 100644 --- a/src/features/orders/utils/dispatch.ts +++ b/src/features/orders/utils/dispatch.ts @@ -26,6 +26,23 @@ export function dispatchableShipment( return shipment.status === 'pending' && shipment.trackingNumber === null ? shipment : null } +/** + * Si la orden puede abrir su envío desde el detalle (TESIS-141). + * + * El envío lo abría un solo lugar: el paso 3 del alta manual. Una orden que + * entra por webhook nace sin envío, y sin esta acción no llegaba nunca al + * circuito logístico desde la app: el detalle decía que no tenía envío y no + * ofrecía nada. + * + * Es la misma regla que aplica el backend (`Shipments::CreateShipment`): se + * excluyen las canceladas y nada más. Que el envío no exista tiene que ser un + * hecho y no una duda, así que mientras la consulta carga o falla no se ofrece: + * ofrecerlo sobre un envío que no se pudo leer invitaría a un 409. + */ +export function canOpenShipment(orderStatus: OrderStatus, view: ShipmentView): boolean { + return orderStatus !== 'cancelled' && view.kind === 'none' +} + /** * Los depósitos desde los que puede salir el envío. *