diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 3774f4c..02c5c52 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -308,39 +308,43 @@ features/[nombre]/ **Features existentes:** -| Feature | Contenido | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dashboard` | Panel de operación (`/dashboard`) y la sección de Integraciones (`/integrations`, detrás del feature flag `integrations` del tenant). Comparten `useInfraHealth` e `IntegrationNodeList`. El panel suma la tarjeta de alertas de inventario, la carga por depósito y la tabla de órdenes recientes (`useInventoryAlerts`, `WarehouseLoadCard`, `useRecentOrders`, `RecentOrdersTable`) | -| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | -| `home` | Landing de la app (`/`) | -| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | -| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; `/orders/new/carrier`: cotización y confirmación) | -| `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) | +| Feature | Contenido | +| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dashboard` | Panel de operación (`/dashboard`) y la sección de Integraciones (`/integrations`, detrás del feature flag `integrations` del tenant). Comparten `useInfraHealth` e `IntegrationNodeList`. El panel suma la tarjeta de alertas de inventario, la carga por depósito y la tabla de órdenes recientes (`useInventoryAlerts`, `WarehouseLoadCard`, `useRecentOrders`, `RecentOrdersTable`) | +| `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | +| `home` | Landing de la app (`/`) | +| `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | +| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío, desde el que se despacha un envío que quedó pendiente (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los tres pasos del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; `/orders/new/carrier`: cotización y confirmación) | +| `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) | **`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. Mientras el despacho no sale, el paso 3 es el único lugar desde el que se puede despachar esa orden: la pantalla lo dice y el navegador pregunta antes de recargar o cerrar la pestaña | -| `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. 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 | **`reports` — piezas y por qué:** diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts index 4d34eb4..1fa26bc 100644 --- a/src/features/orders/api.ts +++ b/src/features/orders/api.ts @@ -17,6 +17,7 @@ import type { ShippingQuote, UpdateOrderPayload, } from './types' +import type { OrderQuotePayload } from './utils/dispatch' import type { CreateOrderPayload, DispatchPayload, DraftQuotePayload } from './utils/shipping' // Frontera con la API Rails. Lo que entra en snake_case se traduce acá y sale @@ -418,6 +419,23 @@ export async function quoteDraft(payload: DraftQuotePayload): Promise { + const { data } = await client.post<{ data: ApiShippingQuote[] }>( + `/orders/${orderId}/quotes`, + payload, + ) + + return data.data.map(toShippingQuote) +} + /** El alta de la orden (TESIS-42). Es lo que descuenta el stock. */ export async function createOrder(payload: CreateOrderPayload): Promise { const response = await client.post('/orders', payload) diff --git a/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.styles.ts b/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.styles.ts new file mode 100644 index 0000000..e518131 --- /dev/null +++ b/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.styles.ts @@ -0,0 +1,30 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +// Cada bloque del cuerpo: el rótulo arriba y su contenido abajo. El espacio +// entre bloques lo pone `ModalBody`. +export const DialogSection = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), +})) + +export const OriginColumn = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), +})) + +export const OriginRow = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + width: '100%', +})) + +export const OriginText = styled(Box)({ + display: 'flex', + flexDirection: 'column', + flex: 1, + minWidth: 0, +}) diff --git a/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.test.tsx b/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.test.tsx new file mode 100644 index 0000000..bb34437 --- /dev/null +++ b/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.test.tsx @@ -0,0 +1,214 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { renderWithTheme } from '../../../../test/renderWithTheme' +import * as api from '../../api' +import type { OrderLine, OriginWarehouse, Shipment, ShippingQuote } from '../../types' + +import { DispatchShipmentDialog } from './DispatchShipmentDialog' + +const EZEIZA: OriginWarehouse = { + id: 1, + name: 'CD Ezeiza', + address: 'Ruta 205 km 32', + zipCode: '1804', +} +const PACHECO: OriginWarehouse = { + id: 2, + name: 'CD Pacheco', + address: 'Ruta 197 1450', + zipCode: '1617', +} + +function line(id: number, warehouseId: number | null): OrderLine { + return { + id, + productId: 10 + id, + sku: `SKU-${id}`, + productName: `Producto ${id}`, + quantity: 2, + unitPrice: 1000, + warehouseId, + } +} + +// Ordenadas por precio, como las devuelve la cotización. +const QUOTES: ShippingQuote[] = [ + { + quoteIntegrationId: 8, + dispatchIntegrationId: 5, + providerName: 'Correo Argentino', + shippingCost: 41200, + estimatedDays: 5, + }, + { + quoteIntegrationId: 7, + dispatchIntegrationId: 4, + providerName: 'Andreani', + shippingCost: 58300, + estimatedDays: 2, + }, +] + +const SHIPMENT = { id: 31 } as Shipment + +function stubApi() { + return { + warehouses: vi.spyOn(api, 'fetchWarehouses').mockResolvedValue([EZEIZA, PACHECO]), + quote: vi.spyOn(api, 'quoteOrder').mockResolvedValue(QUOTES), + dispatch: vi.spyOn(api, 'dispatchShipment').mockResolvedValue(SHIPMENT), + } +} + +function renderDialog(lines: OrderLine[] = [line(1, 1), line(2, 1)]) { + const onDispatched = vi.fn() + const onClose = vi.fn() + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + + renderWithTheme( + + + , + ) + + return { onDispatched, onClose } +} + +const option = (name: RegExp) => screen.findByRole('radio', { name }) +const dispatchButton = () => screen.getByRole('button', { name: 'Despachar' }) + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('DispatchShipmentDialog', () => { + it('quotes the order from the warehouse its lines came out of, without asking', async () => { + const calls = stubApi() + renderDialog() + + await option(/Andreani/) + + expect(calls.quote).toHaveBeenCalledWith(8829, { quote: { origin_warehouse_id: 1 } }) + expect(screen.getByText('CD Ezeiza')).toBeInTheDocument() + expect(screen.queryByRole('radio', { name: /CD Pacheco/ })).not.toBeInTheDocument() + }) + + it('asks for the origin when the lines came out of more than one warehouse', async () => { + const calls = stubApi() + renderDialog([line(1, 1), line(2, 2)]) + + fireEvent.click(await option(/CD Pacheco/)) + await option(/Andreani/) + + expect(calls.quote).toHaveBeenCalledTimes(1) + expect(calls.quote).toHaveBeenCalledWith(8829, { quote: { origin_warehouse_id: 2 } }) + }) + + it('does not quote before the origin is known', async () => { + const calls = stubApi() + renderDialog([line(1, null)]) + + await option(/CD Ezeiza/) + + expect(calls.quote).not.toHaveBeenCalled() + expect(screen.getByText(/Elegí el depósito de origen/)).toBeInTheDocument() + }) + + it('does not dispatch before an option is chosen', async () => { + stubApi() + renderDialog() + + await option(/Andreani/) + + expect(dispatchButton()).toBeDisabled() + }) + + // Criterio de la card: despacha con la integración que despacha, no con la + // que cotiza, con su costo y el depósito de origen. + it('dispatches the chosen option with the integration that dispatches, its cost and the origin', async () => { + const calls = stubApi() + const { onDispatched } = renderDialog() + + fireEvent.click(await option(/Andreani/)) + fireEvent.click(dispatchButton()) + + await waitFor(() => expect(onDispatched).toHaveBeenCalledWith('Andreani')) + expect(calls.dispatch).toHaveBeenCalledWith(31, { + dispatch: { company_integration_id: 4, origin_warehouse_id: 1, shipping_cost: 58300 }, + }) + }) + + // Criterio de la card: si el despacho vuelve a fallar, el error lo dice y se + // puede reintentar sin salir del detalle. + describe('when the dispatch fails again', () => { + function failOnce() { + const calls = stubApi() + calls.dispatch.mockReset() + calls.dispatch.mockRejectedValueOnce(new Error('El courier no contestó.')) + calls.dispatch.mockResolvedValueOnce(SHIPMENT) + return calls + } + + it('says so, with what the api answered', async () => { + failOnce() + renderDialog() + + fireEvent.click(await option(/Andreani/)) + fireEvent.click(dispatchButton()) + + expect( + await screen.findByText('No pudimos emitir el despacho. El courier no contestó.'), + ).toBeInTheDocument() + }) + + it('retries without leaving the dialog', async () => { + const calls = failOnce() + const { onDispatched, onClose } = renderDialog() + + fireEvent.click(await option(/Andreani/)) + fireEvent.click(dispatchButton()) + fireEvent.click(await screen.findByRole('button', { name: 'Reintentar el despacho' })) + + await waitFor(() => expect(onDispatched).toHaveBeenCalledWith('Andreani')) + expect(calls.dispatch).toHaveBeenCalledTimes(2) + expect(onClose).not.toHaveBeenCalled() + }) + }) + + it('does not offer to retry a shipment that was dispatched meanwhile', async () => { + const calls = stubApi() + calls.dispatch.mockRejectedValue( + Object.assign(new Error('Shipment already dispatched'), { status: 409 }), + ) + renderDialog() + + fireEvent.click(await option(/Andreani/)) + fireEvent.click(dispatchButton()) + + expect(await screen.findByText(/El envío ya se despachó mientras tanto/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Reintentar el despacho' })).not.toBeInTheDocument() + expect(calls.dispatch).toHaveBeenCalledTimes(1) + }) + + it('offers to look for the warehouses again when they could not be loaded', async () => { + const calls = stubApi() + calls.warehouses.mockRejectedValueOnce(new Error('boom')) + renderDialog() + + fireEvent.click(await screen.findByRole('button', { name: 'Reintentar' })) + + await option(/Andreani/) + expect(calls.warehouses).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.tsx b/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.tsx new file mode 100644 index 0000000..2c52941 --- /dev/null +++ b/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.tsx @@ -0,0 +1,218 @@ +import { Alert, Button, Stack, Typography } from '@mui/material' +import { useState } from 'react' +import { + LoadingSpinner, + ModalBody, + ModalFooter, + ModalFooterActions, + ModalFrame, +} from 'shared/components' + +import { ordersCopy } from '../../content' +import { useDispatchShipment } from '../../hooks/useDispatchShipment' +import { useOrderQuotes } from '../../hooks/useOrderQuotes' +import { useOriginWarehouses } from '../../hooks/useOriginWarehouses' +import type { OriginWarehouse } from '../../types' +import { originCandidates } from '../../utils/dispatch' +import { toDispatchPayload } from '../../utils/shipping' +import { QuoteOptionsPanel } from '../QuoteOptionsPanel' + +import { DialogSection } from './DispatchShipmentDialog.styles' +import type { DispatchShipmentDialogProps } from './DispatchShipmentDialog.types' +import { OriginOptions } from './OriginOptions' + +const { dispatch: copy } = ordersCopy.detail + +const CONFLICT_STATUS = 409 + +/** + * Despachar desde el detalle el envío de una orden que quedó sin despachar + * (TESIS-134). Es la salida del camino que dejaba abierto el paso 3 del alta: + * la orden se creó, el despacho falló y la pantalla se cerró. + * + * Cotiza la orden (`POST /orders/:id/quotes`) desde el depósito del que + * salieron sus líneas, deja elegir la opción con la misma lista del paso 3 y + * despacha con la integración que despacha, su costo y ese depósito. Un + * despacho que falla se reintenta sin cerrar el diálogo. + * + * Resuelve sus datos adentro, a diferencia de los modales de producto: el + * detalle sólo sabe que hay un envío pendiente, y todo lo que hace falta para + * despacharlo —depósitos, cotización, despacho— es de este flujo. + */ +export function DispatchShipmentDialog({ + open, + orderId, + orderLabel, + shipmentId, + lines, + onClose, + onDispatched, +}: DispatchShipmentDialogProps) { + const [pickedOriginId, setPickedOriginId] = useState(null) + const [selectedId, setSelectedId] = useState(null) + + const warehouses = useOriginWarehouses() + const candidates = warehouses.data === undefined ? [] : originCandidates(lines, warehouses.data) + // Con un solo candidato el origen está decidido y no se pregunta. + const origin = + candidates.length === 1 + ? candidates[0] + : (candidates.find((warehouse) => warehouse.id === pickedOriginId) ?? null) + + const quotes = useOrderQuotes(orderId, origin?.id ?? null) + const dispatch = useDispatchShipment() + + // La opción elegida sólo cuenta si sigue entre las cotizadas: otro origen o + // volver a cotizar pueden traer otra lista. + const chosen = quotes.data?.find((quote) => quote.dispatchIntegrationId === selectedId) ?? null + const alreadyDispatched = dispatch.error?.status === CONFLICT_STATUS + + function submit() { + if (origin === null || chosen === null) return + + dispatch.mutate( + { shipmentId, payload: toDispatchPayload(chosen, origin.id) }, + { onSuccess: () => onDispatched(chosen.providerName) }, + ) + } + + let confirmLabel = copy.confirm + if (dispatch.isPending) confirmLabel = copy.confirming + else if (dispatch.isError) confirmLabel = copy.retry + + return ( + + + + + {copy.origin.title} + + setPickedOriginId(warehouse.id)} + /> + + + + + {copy.options.title} + + {origin === null ? ( + + {copy.options.waitingOrigin} + + ) : ( + setSelectedId(quote.dispatchIntegrationId)} + /> + )} + + + {dispatch.isError ? ( + + {alreadyDispatched + ? copy.errors.alreadyDispatched + : `${copy.errors.failed} ${dispatch.error.message}`} + + ) : null} + + + + + + {/* Un 409 no se reintenta: el envío ya tiene su etiqueta. */} + {alreadyDispatched ? null : ( + + )} + + + + ) +} + +interface OriginContentProps { + warehouses: ReturnType + candidates: OriginWarehouse[] + origin: OriginWarehouse | null + disabled: boolean + onSelect: (warehouse: OriginWarehouse) => void +} + +// Los estados del origen, fuera del cuerpo del diálogo para que éste se lea de +// un vistazo. Stateless y usado sólo acá: no justifica archivo propio. +function OriginContent({ warehouses, candidates, origin, disabled, onSelect }: OriginContentProps) { + if (warehouses.isPending) { + return ( + + + + {copy.origin.loading} + + + ) + } + + if (warehouses.isError) { + return ( + void warehouses.refetch()}> + {copy.origin.retry} + + } + > + {copy.origin.error} + + ) + } + + if (candidates.length === 1 && origin !== null) { + return ( + + + {origin.name} + + + {origin.address} + + + ) + } + + return ( + + + {copy.origin.choose} + + + + ) +} diff --git a/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.types.ts b/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.types.ts new file mode 100644 index 0000000..6db863f --- /dev/null +++ b/src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.types.ts @@ -0,0 +1,22 @@ +import type { OrderLine, OriginWarehouse } from '../../types' + +export interface DispatchShipmentDialogProps { + open: boolean + orderId: number + /** "#8829", como lo muestra el encabezado del detalle. */ + orderLabel: string + /** El envío `pending` que se despacha. */ + shipmentId: number + /** De las líneas sale el depósito de origen: el envío no lo guarda. */ + lines: OrderLine[] + onClose: () => void + /** Después de un despacho exitoso, con el operador que lo emitió. */ + onDispatched: (carrier: string) => void +} + +export interface OriginOptionsProps { + warehouses: OriginWarehouse[] + selectedId: number | null + onSelect: (warehouse: OriginWarehouse) => void + disabled?: boolean +} diff --git a/src/features/orders/components/DispatchShipmentDialog/OriginOptions.tsx b/src/features/orders/components/DispatchShipmentDialog/OriginOptions.tsx new file mode 100644 index 0000000..1007265 --- /dev/null +++ b/src/features/orders/components/DispatchShipmentDialog/OriginOptions.tsx @@ -0,0 +1,59 @@ +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked' +import { Typography } from '@mui/material' + +import { ordersCopy } from '../../content' +import { SelectableCard } from '../SelectableCard' + +import { OriginColumn, OriginRow, OriginText } from './DispatchShipmentDialog.styles' +import type { OriginOptionsProps } from './DispatchShipmentDialog.types' + +const { origin: copy } = ordersCopy.detail.dispatch + +/** + * Los depósitos candidatos como tarjetas elegibles, cuando el origen no se + * puede deducir de las líneas. No es el selector del paso 2 + * (`OriginWarehousePicker`): aquél mide si cada depósito cubre el borrador, y + * acá el stock de la orden ya se descontó. + */ +export function OriginOptions({ + warehouses, + selectedId, + onSelect, + disabled = false, +}: OriginOptionsProps) { + return ( + + {warehouses.map((warehouse) => { + const selected = warehouse.id === selectedId + + return ( + onSelect(warehouse)} + > + + + + {warehouse.name} + + + {warehouse.address} + + + {selected ? ( + + ) : ( + + )} + + + ) + })} + + ) +} diff --git a/src/features/orders/components/DispatchShipmentDialog/index.ts b/src/features/orders/components/DispatchShipmentDialog/index.ts new file mode 100644 index 0000000..b7de01e --- /dev/null +++ b/src/features/orders/components/DispatchShipmentDialog/index.ts @@ -0,0 +1,2 @@ +export { DispatchShipmentDialog } from './DispatchShipmentDialog' +export type { DispatchShipmentDialogProps } from './DispatchShipmentDialog.types' diff --git a/src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.test.tsx b/src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.test.tsx new file mode 100644 index 0000000..fa64d27 --- /dev/null +++ b/src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.test.tsx @@ -0,0 +1,66 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { renderWithTheme } from '../../../../test/renderWithTheme' +import type { ShippingQuote } from '../../types' + +import { QuoteOptionsPanel } from './QuoteOptionsPanel' +import type { QuoteOptionsPanelProps } from './QuoteOptionsPanel.types' + +const QUOTE: ShippingQuote = { + quoteIntegrationId: 7, + dispatchIntegrationId: 4, + providerName: 'Andreani', + shippingCost: 58300, + estimatedDays: 2, +} + +function quotes(state: Partial = {}) { + return { + data: [QUOTE], + isPending: false, + isError: false, + refetch: vi.fn(), + ...state, + } as QuoteOptionsPanelProps['quotes'] +} + +function renderPanel(props: Partial = {}) { + return renderWithTheme( + , + ) +} + +describe('QuoteOptionsPanel', () => { + it('lists the quoted options to choose from', () => { + const onSelect = vi.fn() + renderPanel({ onSelect }) + + fireEvent.click(screen.getByRole('radio', { name: /Andreani/ })) + + expect(onSelect).toHaveBeenCalledWith(QUOTE) + }) + + it('offers to quote again when no carrier answered', () => { + const empty = quotes({ data: [] }) + renderPanel({ quotes: empty }) + + fireEvent.click(screen.getByRole('button', { name: 'Volver a cotizar' })) + + expect(empty.refetch).toHaveBeenCalled() + }) + + it('offers to review origin and destination only where they can be changed', () => { + const onReview = vi.fn() + const { unmount } = renderPanel({ quotes: quotes({ isError: true }), onReview }) + + fireEvent.click(screen.getByRole('button', { name: 'Revisar origen y destino' })) + expect(onReview).toHaveBeenCalled() + unmount() + + renderPanel({ quotes: quotes({ isError: true }) }) + expect( + screen.queryByRole('button', { name: 'Revisar origen y destino' }), + ).not.toBeInTheDocument() + }) +}) diff --git a/src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.tsx b/src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.tsx new file mode 100644 index 0000000..195edf4 --- /dev/null +++ b/src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.tsx @@ -0,0 +1,71 @@ +import { Alert, Button, Stack, Typography } from '@mui/material' +import { LoadingSpinner } from 'shared/components' + +import { ordersCopy } from '../../content' +import { QuoteOptionList } from '../QuoteOptionList' + +import type { QuoteOptionsPanelProps } from './QuoteOptionsPanel.types' + +const { options: copy } = ordersCopy.carrier + +/** + * La cotización con sus estados: mientras consulta, si falló, si ningún + * operador contestó y, cuando hay opciones, la lista para elegir. La usan el + * paso 3 del alta (S07) y el despacho desde el detalle de la orden; se extrajo + * cuando apareció el segundo (`feature-structure.md` §6). + */ +export function QuoteOptionsPanel({ + quotes, + selectedId, + onSelect, + disabled = false, + onReview, +}: QuoteOptionsPanelProps) { + // Consultar a los operadores puede tardar lo que tarda el más lento: la + // pantalla lo dice en vez de quedarse en blanco. + if (quotes.isPending) { + return ( + + + + {copy.loading} + + + ) + } + + // Un fallo y una lista vacía son dos cosas distintas: el primero es nuestro o + // de la red, la segunda es que ningún operador contestó a tiempo. Las dos + // dejan reintentar y, donde se puede, volver a revisar origen y destino. + if (quotes.isError || quotes.data === undefined || quotes.data.length === 0) { + return ( + + {onReview === undefined ? null : ( + + )} + + + } + > + {quotes.isError ? copy.error : copy.empty} + + ) + } + + return ( + + ) +} diff --git a/src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.types.ts b/src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.types.ts new file mode 100644 index 0000000..9c270d5 --- /dev/null +++ b/src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.types.ts @@ -0,0 +1,18 @@ +import type { UseQueryResult } from '@tanstack/react-query' + +import type { ShippingQuote } from '../../types' + +export interface QuoteOptionsPanelProps { + /** La consulta de la cotización, con sus estados: cargando, error, vacía o con opciones. */ + quotes: Pick, 'data' | 'isPending' | 'isError' | 'refetch'> + /** La opción elegida, por la integración que la despacha; null si todavía no hay. */ + selectedId: number | null + onSelect: (quote: ShippingQuote) => void + /** Mientras se confirma no se cambia de operador. */ + disabled?: boolean + /** + * Volver a revisar el origen y el destino. Sólo lo ofrece quien puede + * cambiarlos: el asistente sí, el detalle de una orden ya creada no. + */ + onReview?: () => void +} diff --git a/src/features/orders/components/QuoteOptionsPanel/index.ts b/src/features/orders/components/QuoteOptionsPanel/index.ts new file mode 100644 index 0000000..f9bd6a8 --- /dev/null +++ b/src/features/orders/components/QuoteOptionsPanel/index.ts @@ -0,0 +1,2 @@ +export { QuoteOptionsPanel } from './QuoteOptionsPanel' +export type { QuoteOptionsPanelProps } from './QuoteOptionsPanel.types' diff --git a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.styles.ts b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.styles.ts index 282fbc4..0c336f0 100644 --- a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.styles.ts +++ b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.styles.ts @@ -20,6 +20,12 @@ export const CardHeading = styled(Box)(({ theme }) => ({ color: theme.vars.palette.primary.main, })) +// La acción va contra el borde derecho del encabezado, como las de los +// encabezados de página. +export const CardAction = styled(Box)({ + marginLeft: 'auto', +}) + // Cuatro etapas en fila; al angostarse bajan a dos columnas. export const StageGrid = styled(Box)(({ theme }) => ({ display: 'grid', diff --git a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx index a3887f0..f3f2995 100644 --- a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx +++ b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.test.tsx @@ -125,4 +125,15 @@ describe('ShipmentLifecycleCard', () => { expect(onRetry).toHaveBeenCalledTimes(1) }) + + it('places the action the page gives it next to the title', () => { + renderWithTheme( + Despachar} + />, + ) + + expect(screen.getByRole('button', { name: 'Despachar' })).toBeInTheDocument() + }) }) diff --git a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.tsx b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.tsx index b8a2748..ac1ce22 100644 --- a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.tsx +++ b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.tsx @@ -22,6 +22,7 @@ import type { LifecycleStage } from '../../utils/shipment' import { ShipmentStateMessage } from '../ShipmentStateMessage' import { + CardAction, CardHeading, LifecycleCard, StageDot, @@ -80,7 +81,7 @@ function feedEntries(shipment: Shipment): StatusFeedEntry[] { * «Ciclo de vida del envío» de S08: las cuatro etapas con su avance, y debajo * la bitácora completa de `ShipmentEvents` con los dos estados de cada evento. */ -export function ShipmentLifecycleCard({ shipment }: ShipmentLifecycleCardProps) { +export function ShipmentLifecycleCard({ shipment, action }: ShipmentLifecycleCardProps) { return ( @@ -88,6 +89,7 @@ export function ShipmentLifecycleCard({ shipment }: ShipmentLifecycleCardProps) {lifecycleCopy.title} + {action === undefined ? null : {action}} {shipment.kind === 'single' ? ( diff --git a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.types.ts b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.types.ts index 9b40cc1..2b72922 100644 --- a/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.types.ts +++ b/src/features/orders/components/ShipmentLifecycleCard/ShipmentLifecycleCard.types.ts @@ -1,5 +1,12 @@ +import type { ReactNode } from 'react' + import type { ShipmentView } from '../../types' export interface ShipmentLifecycleCardProps { shipment: ShipmentView + /** + * Acción sobre el envío, a la derecha del título: «Despachar» cuando quedó + * pendiente (TESIS-134). La decide la página; la tarjeta sólo la ubica. + */ + action?: ReactNode } diff --git a/src/features/orders/content.ts b/src/features/orders/content.ts index 29ac638..2f3f408 100644 --- a/src/features/orders/content.ts +++ b/src/features/orders/content.ts @@ -78,6 +78,39 @@ export const ordersCopy = { error: 'No pudimos cargar el envío de la orden.', retry: 'Reintentar', }, + /** Despachar desde el detalle un envío que quedó sin despachar (TESIS-134). */ + dispatch: { + action: 'Despachar', + title: 'Despachar el envío', + /** "Orden #8829". */ + subtitle: (orderLabel: string) => `Orden ${orderLabel}`, + close: 'Cerrar', + origin: { + title: 'Depósito de origen', + /** Hay más de un candidato: las líneas salieron de varios, o no lo registran. */ + choose: 'Elegí desde qué depósito sale el envío.', + loading: 'Buscando los depósitos…', + error: 'No pudimos cargar los depósitos.', + retry: 'Reintentar', + }, + options: { + title: 'Operador logístico', + /** Sin origen no hay qué cotizar. */ + waitingOrigin: 'Elegí el depósito de origen para ver las opciones de envío.', + }, + cancel: 'Cancelar', + confirm: 'Despachar', + confirming: 'Despachando…', + retry: 'Reintentar el despacho', + errors: { + failed: 'No pudimos emitir el despacho.', + /** 409: otro operador lo despachó mientras tanto. */ + alreadyDispatched: + 'El envío ya se despachó mientras tanto. Cerrá para ver cómo quedó en el detalle.', + }, + /** "Envío despachado con Andreani." */ + dispatched: (carrier: string) => `Envío despachado con ${carrier}.`, + }, notFound: 'No encontramos la orden que buscabas.', backToOrders: 'Volver a órdenes', error: 'No pudimos cargar la orden.', @@ -326,9 +359,12 @@ 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.`, - /** Hasta que el detalle de la orden permita despachar, ésta es la única salida. */ - dispatchPending: - 'Reintentalo antes de salir: la orden ya descontó el stock, y si dejás esta pantalla su envío queda sin despachar.', + /** El envío quedó `pending`: el detalle de la orden lo puede despachar (TESIS-134). */ + 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.', 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 9c747a3..2a966f6 100644 --- a/src/features/orders/hooks/useConfirmDraftOrder.test.tsx +++ b/src/features/orders/hooks/useConfirmDraftOrder.test.tsx @@ -99,6 +99,25 @@ 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) + const createShipment = vi + .spyOn(api, 'createOrderShipment') + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(SHIPMENT) + vi.spyOn(api, 'dispatchShipment').mockRejectedValue(new Error('El courier no contestó.')) + 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) + }) + it('leaves no created order behind when the order itself is rejected', async () => { vi.spyOn(api, 'createOrder').mockRejectedValue(new Error('Stock insuficiente')) const createShipment = vi.spyOn(api, 'createOrderShipment') diff --git a/src/features/orders/hooks/useConfirmDraftOrder.ts b/src/features/orders/hooks/useConfirmDraftOrder.ts index 79b9319..fbe9551 100644 --- a/src/features/orders/hooks/useConfirmDraftOrder.ts +++ b/src/features/orders/hooks/useConfirmDraftOrder.ts @@ -30,11 +30,16 @@ interface Options { * —y ya descontó el stock—, así que un reintento **retoma desde donde quedó**: * 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. */ 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 }) => { @@ -49,6 +54,7 @@ export function useConfirmDraftOrder({ onOrderCreated }: Options = {}) { if (progress.current.shipmentId === null) { const shipment = await createOrderShipment(orderId) progress.current.shipmentId = shipment.id + setCreatedShipmentId(shipment.id) } await dispatchShipment(progress.current.shipmentId, dispatch) @@ -68,5 +74,5 @@ export function useConfirmDraftOrder({ onOrderCreated }: Options = {}) { }, }) - return { ...mutation, createdOrderId } + return { ...mutation, createdOrderId, createdShipmentId } } diff --git a/src/features/orders/hooks/useDispatchShipment.test.tsx b/src/features/orders/hooks/useDispatchShipment.test.tsx new file mode 100644 index 0000000..3e9d966 --- /dev/null +++ b/src/features/orders/hooks/useDispatchShipment.test.tsx @@ -0,0 +1,81 @@ +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, quoteKeys } from '../queryKeys' +import type { Shipment } from '../types' + +import { useDispatchShipment } from './useDispatchShipment' +import type { DispatchShipmentInput } from './useDispatchShipment' + +const INPUT: DispatchShipmentInput = { + shipmentId: 31, + payload: { + dispatch: { company_integration_id: 4, origin_warehouse_id: 1, shipping_cost: 58300 }, + }, +} + +const SHIPMENT = { id: 31 } as Shipment + +// El detalle, su envío y la cotización de la orden, ya en caché. +function seededClient() { + const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }) + queryClient.setQueryData(orderKeys.detail(8829), {}) + queryClient.setQueryData(orderKeys.shipment(8829), { kind: 'none' }) + queryClient.setQueryData(quoteKeys.order(8829, 1), []) + return queryClient +} + +function renderFor(queryClient: QueryClient) { + return renderHook(() => useDispatchShipment(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }) +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('useDispatchShipment', () => { + it('dispatches the shipment with what the operator chose', async () => { + const dispatch = vi.spyOn(api, 'dispatchShipment').mockResolvedValue(SHIPMENT) + const { result } = renderFor(seededClient()) + + await act(() => result.current.mutateAsync(INPUT)) + + expect(dispatch).toHaveBeenCalledWith(31, INPUT.payload) + }) + + // Criterio de la card: al terminar, el detalle muestra el envío despachado. + it('refreshes the order and its shipment, without asking the carriers for a new quote', async () => { + vi.spyOn(api, 'dispatchShipment').mockResolvedValue(SHIPMENT) + const queryClient = seededClient() + const { result } = renderFor(queryClient) + + await act(() => result.current.mutateAsync(INPUT)) + + expect(queryClient.getQueryState(orderKeys.detail(8829))?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(orderKeys.shipment(8829))?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(quoteKeys.order(8829, 1))?.isInvalidated).toBe(false) + }) + + // Un 409 es que otro lo despachó mientras tanto: el detalle tiene que dejar de + // ofrecerlo. + it('refreshes the shipment also when the dispatch fails', async () => { + vi.spyOn(api, 'dispatchShipment').mockRejectedValue( + Object.assign(new Error('Shipment already dispatched'), { status: 409 }), + ) + const queryClient = seededClient() + const { result } = renderFor(queryClient) + + await act(async () => { + await result.current.mutateAsync(INPUT).catch(() => undefined) + }) + + expect(queryClient.getQueryState(orderKeys.shipment(8829))?.isInvalidated).toBe(true) + }) +}) diff --git a/src/features/orders/hooks/useDispatchShipment.ts b/src/features/orders/hooks/useDispatchShipment.ts new file mode 100644 index 0000000..054fa96 --- /dev/null +++ b/src/features/orders/hooks/useDispatchShipment.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import type { ApiRequestError } from 'shared/api/types' + +import { dispatchShipment } from '../api' +import { orderKeys } from '../queryKeys' +import type { Shipment } from '../types' +import type { DispatchPayload } from '../utils/shipping' + +export interface DispatchShipmentInput { + shipmentId: number + payload: DispatchPayload +} + +/** + * Despacha el envío de una orden desde su detalle (TESIS-134): pide la etiqueta + * con el operador elegido (`POST /shipments/:id/dispatch`, TESIS-47). + * + * Un fallo no deja nada a medias —el backend no escribe si el courier no + * confirmó—, así que reintentar es volver a llamar. + */ +export function useDispatchShipment() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ shipmentId, payload }) => dispatchShipment(shipmentId, payload), + // También si falló: un 409 quiere decir que otro lo despachó mientras tanto, + // y el detalle tiene que dejar de ofrecerlo. Invalidar el dominio entero + // refresca además el courier que muestra el listado. + onSettled: () => queryClient.invalidateQueries({ queryKey: orderKeys.all }), + }) +} diff --git a/src/features/orders/hooks/useOrderQuotes.ts b/src/features/orders/hooks/useOrderQuotes.ts new file mode 100644 index 0000000..fa8fb99 --- /dev/null +++ b/src/features/orders/hooks/useOrderQuotes.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query' + +import { quoteOrder } from '../api' +import { quoteKeys } from '../queryKeys' +import type { ShippingQuote } from '../types' +import { toOrderQuotePayload } from '../utils/dispatch' + +// Mismo plazo que la cotización del borrador: pasado un minuto, la tarifa ya no +// es la que el courier sostiene. +const QUOTE_TTL_MS = 60_000 + +/** + * Las opciones de envío de una orden que ya existe, para despacharla desde su + * detalle (TESIS-134). `null` mientras no se sabe de qué depósito sale: sin + * origen no hay nada que cotizar. + * + * Sin reintento automático, como en el paso 3: el backend ya espera a cada + * courier hasta su timeout, y la pantalla ofrece volver a cotizar. + */ +export function useOrderQuotes(orderId: number, originWarehouseId: number | null) { + return useQuery({ + // La clave con `null` nunca se pide (`enabled`): sólo está para que el tipo + // cierre sin inventar un depósito. + queryKey: + originWarehouseId === null ? quoteKeys.all : quoteKeys.order(orderId, originWarehouseId), + queryFn: () => quoteOrder(orderId, toOrderQuotePayload(originWarehouseId ?? 0)), + enabled: originWarehouseId !== null, + staleTime: QUOTE_TTL_MS, + retry: false, + }) +} diff --git a/src/features/orders/pages/CarrierStepPage.test.tsx b/src/features/orders/pages/CarrierStepPage.test.tsx index 2015677..bbc6c1f 100644 --- a/src/features/orders/pages/CarrierStepPage.test.tsx +++ b/src/features/orders/pages/CarrierStepPage.test.tsx @@ -317,9 +317,9 @@ describe('CarrierStepPage', () => { expect(calls.dispatch).toHaveBeenCalledTimes(2) }) - // Recargar deja la orden sin despacho y sin pantalla desde la que - // despacharla (lo encontró la review de TESIS-59). - it('says what happens if the screen is left', async () => { + // Con el envío abierto y `pending`, el detalle lo puede despachar + // (TESIS-134): salir ya no deja la orden sin salida. + it('points to the order detail, where the shipment can be dispatched later', async () => { failDispatchOnce() renderPage() @@ -327,20 +327,19 @@ describe('CarrierStepPage', () => { fireEvent.click(confirmButton()) expect( - await screen.findByText(/si dejás esta pantalla su envío queda sin despachar/), + await screen.findByText(/despachar el envío desde el detalle de la orden/), ).toBeInTheDocument() }) - it('asks before reloading or closing the tab', async () => { + it('does not ask before reloading or closing the tab', async () => { failDispatchOnce() renderPage() - expect(leaveIsBlocked()).toBe(false) fireEvent.click(option(/Andreani/)) fireEvent.click(confirmButton()) await screen.findByText(/se creó, pero/) - expect(leaveIsBlocked()).toBe(true) + expect(leaveIsBlocked()).toBe(false) }) it('no longer offers going back to a draft that is already an order', async () => { @@ -367,6 +366,56 @@ 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. + describe('when the shipment could not even be opened', () => { + function failShipmentOnce() { + const calls = stubConfirmation() + calls.createShipment.mockReset() + calls.createShipment.mockRejectedValueOnce(new Error('No se pudo abrir el envío.')) + calls.createShipment.mockResolvedValueOnce(SHIPMENT) + return calls + } + + it('says no other screen can open it', async () => { + failShipmentOnce() + renderPage() + + fireEvent.click(option(/Andreani/)) + fireEvent.click(confirmButton()) + + expect( + await screen.findByText(/todavía no tiene envío, y ninguna otra pantalla lo puede abrir/), + ).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 () => { + const calls = failShipmentOnce() + calls.dispatch.mockReset() + calls.dispatch.mockRejectedValue(new Error('El courier no contestó.')) + renderPage() + + fireEvent.click(option(/Andreani/)) + 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) + }) + }) + describe('reached without a complete draft', () => { it('sends the operator back to step 1 without a customer or lines', () => { useOrderDraftStore.getState().clearDraft() diff --git a/src/features/orders/pages/CarrierStepPage.tsx b/src/features/orders/pages/CarrierStepPage.tsx index 9ea0c17..bd4b501 100644 --- a/src/features/orders/pages/CarrierStepPage.tsx +++ b/src/features/orders/pages/CarrierStepPage.tsx @@ -1,8 +1,8 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' -import { Alert, Box, Button, Stack, Typography } from '@mui/material' +import { Alert, Box, Button, Stack } from '@mui/material' import { useEffect, useMemo, useState } from 'react' import { Navigate, useNavigate } from 'react-router-dom' -import { LoadingSpinner, PageWrapper } from 'shared/components' +import { PageWrapper } from 'shared/components' import { notify, useOrderDraftStore } from 'shared/store' import type { OrderDraftCustomer, @@ -14,7 +14,7 @@ import type { import { FormSection } from '../components/FormSection' import { OrderConfirmCard } from '../components/OrderConfirmCard' import { OrderWizardHeader } from '../components/OrderWizardHeader' -import { QuoteOptionList } from '../components/QuoteOptionList' +import { QuoteOptionsPanel } from '../components/QuoteOptionsPanel' import { ordersCopy } from '../content' import { useConfirmDraftOrder } from '../hooks/useConfirmDraftOrder' import { useDraftQuotes } from '../hooks/useDraftQuotes' @@ -55,10 +55,11 @@ interface ConfirmedDraft { * veces. Por eso lo que se muestra durante y después de confirmar sale de una * copia del borrador tomada al apretar el botón, no del store. * - * La contracara: una vez creada la orden, esta pantalla es el único lugar desde - * el que se puede despachar su envío (el detalle todavía no lo ofrece). Mientras - * el despacho no salga, el navegador pregunta antes de recargar o cerrar la - * pestaña, y el error lo dice. + * La contracara: si el despacho falla, la orden ya existe. Con su envío abierto + * y `pending`, el detalle de la orden lo puede despachar (TESIS-134), y el error + * remite ahí. Si ni siquiera se abrió el envío, esta pantalla es el único lugar + * que lo abre: el error lo dice y el navegador pregunta antes de recargar o + * cerrar la pestaña. */ export function CarrierStepPage() { const navigate = useNavigate() @@ -88,8 +89,9 @@ export function CarrierStepPage() { ) const quotes = useDraftQuotes(payload) const confirm = useConfirmDraftOrder({ onOrderCreated: clearDraft }) - const dispatchPending = confirm.createdOrderId !== null && !confirm.isSuccess - useLeaveWarning(dispatchPending) + 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 @@ -112,7 +114,7 @@ export function CarrierStepPage() { confirm.mutate( { order: toCreateOrderPayload(draft.customer, draft.items, draft.origin, draft.destination), - dispatch: toDispatchPayload(chosen, draft.origin), + dispatch: toDispatchPayload(chosen, draft.origin.warehouseId), }, { onSuccess: (orderId) => { @@ -132,7 +134,13 @@ export function CarrierStepPage() { {createdOrderId === null ? carrier.errors.order : carrier.errors.dispatch(orderLabel)}{' '} {confirm.error.message} - {createdOrderId === null ? null : {carrier.errors.dispatchPending}} + {createdOrderId === null ? null : ( + + {confirm.createdShipmentId === null + ? carrier.errors.shipmentMissing + : carrier.errors.dispatchLater} + + )} {createdOrderId === null ? null : ( - - - } - > - {quotes.isError ? copy.error : copy.empty} - - ) - } - - return ( - - ) -} - /** * Pide confirmación al recargar o cerrar la pestaña mientras `active`. Es lo que * el navegador permite: el texto del diálogo es el suyo, no uno propio. diff --git a/src/features/orders/pages/OrderDetailPage.test.tsx b/src/features/orders/pages/OrderDetailPage.test.tsx index a7107b3..5830a00 100644 --- a/src/features/orders/pages/OrderDetailPage.test.tsx +++ b/src/features/orders/pages/OrderDetailPage.test.tsx @@ -1,5 +1,7 @@ import { fireEvent, screen, within } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { notify } from 'shared/store' +import type * as sharedStore from 'shared/store' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithTheme } from '../../../test/renderWithTheme' @@ -13,6 +15,27 @@ vi.mock('../hooks/useOrderDetail', () => ({ useOrderShipment: vi.fn(), })) +vi.mock('shared/store', async (importOriginal) => ({ + ...(await importOriginal()), + notify: vi.fn(), +})) + +// El flujo del despacho tiene sus propios tests: acá sólo importa cuándo se +// ofrece, con qué envío se abre y qué hace la página cuando termina. +vi.mock('../components/DispatchShipmentDialog', () => ({ + DispatchShipmentDialog: ({ + shipmentId, + onDispatched, + }: { + shipmentId: number + onDispatched: (carrier: string) => void + }) => ( + + ), +})) + const ORDER: OrderDetail = { id: 8829, externalOrderId: 'ORD-8829-X', @@ -98,6 +121,7 @@ function renderAt(path: string) { beforeEach(() => { vi.mocked(useOrder).mockReset() vi.mocked(useOrderShipment).mockReset() + vi.mocked(notify).mockClear() }) describe('OrderDetailPage', () => { @@ -200,4 +224,64 @@ describe('OrderDetailPage', () => { expect(screen.getByText('Sin asignar')).toBeInTheDocument() expect(screen.getByText('Sin cotizar')).toBeInTheDocument() }) + + // Criterio de la card: una orden con envío `pending` muestra «Despachar»; + // una con el envío ya despachado, no. + describe('dispatching from the detail', () => { + const PENDING: Shipment = { + ...SHIPMENT, + status: 'pending', + trackingNumber: null, + shippingCost: null, + courier: null, + } + const dispatchButton = () => screen.queryByRole('button', { name: 'Despachar' }) + + it('offers to dispatch a shipment that was left pending', () => { + mockQueries({ data: ORDER }, { data: { kind: 'single', shipment: PENDING } }) + + renderAt('/orders/8829') + + fireEvent.click(screen.getByRole('button', { name: 'Despachar' })) + expect(screen.getByText('Despachando el envío 31')).toBeInTheDocument() + }) + + it('announces the dispatch and closes the dialog when it is done', () => { + mockQueries({ data: ORDER }, { data: { kind: 'single', shipment: PENDING } }) + renderAt('/orders/8829') + + fireEvent.click(screen.getByRole('button', { name: 'Despachar' })) + fireEvent.click(screen.getByRole('button', { name: 'Despachando el envío 31' })) + + expect(notify).toHaveBeenCalledWith('Envío despachado con Andreani.', 'success') + expect(screen.queryByText('Despachando el envío 31')).not.toBeInTheDocument() + }) + + it('does not offer it once the shipment was dispatched', () => { + mockQueries({ data: ORDER }) + + renderAt('/orders/8829') + + expect(dispatchButton()).not.toBeInTheDocument() + }) + + it('does not offer it for a cancelled order', () => { + mockQueries( + { data: { ...ORDER, status: 'cancelled' } }, + { data: { kind: 'single', shipment: PENDING } }, + ) + + renderAt('/orders/8829') + + expect(dispatchButton()).not.toBeInTheDocument() + }) + + it('does not offer it while the order has no shipment', () => { + mockQueries({ data: ORDER }, { data: { kind: 'none' } }) + + renderAt('/orders/8829') + + expect(dispatchButton()).not.toBeInTheDocument() + }) + }) }) diff --git a/src/features/orders/pages/OrderDetailPage.tsx b/src/features/orders/pages/OrderDetailPage.tsx index e25e0de..334b181 100644 --- a/src/features/orders/pages/OrderDetailPage.tsx +++ b/src/features/orders/pages/OrderDetailPage.tsx @@ -4,9 +4,12 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined' import PersonOutlineIcon from '@mui/icons-material/PersonOutline' import { Box, Button, Stack, Typography } from '@mui/material' +import { useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components' +import { notify } from 'shared/store' +import { DispatchShipmentDialog } from '../components/DispatchShipmentDialog' import { InfoPanel } from '../components/InfoPanel' import type { InfoField } from '../components/InfoPanel' import { OrderDetailHeader } from '../components/OrderDetailHeader' @@ -19,6 +22,7 @@ import { TrackingNumberField } from '../components/TrackingNumberField' import { formatCount, ordersCopy } from '../content' import { useOrder, useOrderShipment } from '../hooks/useOrderDetail' import type { OrderDetail, Shipment, ShipmentView } from '../types' +import { dispatchableShipment } from '../utils/dispatch' import { formatMoney, formatOrderId, formatShortDate } from '../utils/format' import { paymentSummary, totalUnits } from '../utils/payment' import { deliveredAt, headerStatus } from '../utils/shipment' @@ -143,6 +147,9 @@ function NotFound() { * Los datos salen de dos lugares: la orden con sus líneas de `GET /orders/:id` * y el envío con su bitácora de `GET /shipments`. Son queries separadas a * 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). */ export function OrderDetailPage() { const { orderId } = useParams() @@ -155,6 +162,10 @@ export function OrderDetailPage() { const order = useOrder(id) const shipment = useOrderShipment(id) + // El envío que se está despachando, fijado al abrir el diálogo: despachar + // 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) if (id === undefined || order.error?.status === NOT_FOUND_STATUS) return @@ -171,12 +182,14 @@ export function OrderDetailPage() { const resolved = resolvedShipment(shipmentView) const payment = paymentSummary(order.data, resolved?.shippingCost ?? null) const status = headerStatus(order.data.status, shipment.data) + const dispatchable = dispatchableShipment(order.data.status, shipmentView) + const orderLabel = formatOrderId(order.data.externalOrderId, order.data.id) return ( - + } + onClick={() => setDispatchingId(dispatchable.id)} + > + {detail.dispatch.action} + + ) + } + /> @@ -217,6 +244,23 @@ export function OrderDetailPage() { + + {/* Se monta al abrirlo: los depósitos y la cotización se piden recién + cuando el operador decide despachar, no en cada visita al detalle. */} + {dispatchingId === null ? null : ( + setDispatchingId(null)} + onDispatched={(carrier) => { + setDispatchingId(null) + notify(detail.dispatch.dispatched(carrier), 'success') + }} + /> + )} ) } diff --git a/src/features/orders/queryKeys.ts b/src/features/orders/queryKeys.ts index fa266e7..cfc2470 100644 --- a/src/features/orders/queryKeys.ts +++ b/src/features/orders/queryKeys.ts @@ -22,7 +22,7 @@ export const orderKeys = { } /** - * La cotización del alta manual (TESIS-59). + * La cotización del alta manual (TESIS-59) y la del despacho desde el detalle. * * Tiene su propia raíz y no cuelga de `orders` a propósito: confirmar la orden * invalida `orderKeys.all`, y si la cotización colgara de ahí se volvería a @@ -36,6 +36,12 @@ export const orderKeys = { export const quoteKeys = { all: ['quotes'] as const, draft: (payload: DraftQuotePayload) => [...quoteKeys.all, 'draft', payload] as const, + // La de una orden que ya existe (TESIS-134), por el mismo motivo: despachar + // invalida la orden, y no tiene que volver a cotizar mientras el operador + // decide si reintenta. El origen entra en la clave porque cambiarlo es otra + // cotización. + order: (orderId: number, originWarehouseId: number) => + [...quoteKeys.all, 'order', orderId, originWarehouseId] as const, } /** diff --git a/src/features/orders/utils/dispatch.test.ts b/src/features/orders/utils/dispatch.test.ts new file mode 100644 index 0000000..ee50247 --- /dev/null +++ b/src/features/orders/utils/dispatch.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' + +import type { OrderLine, OriginWarehouse, Shipment } from '../types' + +import { dispatchableShipment, originCandidates, toOrderQuotePayload } from './dispatch' + +const PENDING: Shipment = { + id: 31, + orderId: 8829, + status: 'pending', + trackingNumber: null, + shippingCost: null, + courier: null, + events: [], +} + +function line(id: number, warehouseId: number | null): OrderLine { + return { + id, + productId: 10 + id, + sku: `SKU-${id}`, + productName: `Producto ${id}`, + quantity: 1, + unitPrice: 1000, + warehouseId, + } +} + +const EZEIZA: OriginWarehouse = { id: 1, name: 'CD Ezeiza', address: 'Ruta 205', zipCode: '1804' } +const PACHECO: OriginWarehouse = { id: 2, name: 'CD Pacheco', address: 'Ruta 197', zipCode: '1617' } +const WAREHOUSES = [EZEIZA, PACHECO] + +describe('dispatchableShipment', () => { + it('offers a pending shipment that has no tracking number yet', () => { + expect(dispatchableShipment('pending', { kind: 'single', shipment: PENDING })).toBe(PENDING) + }) + + it('does not offer a shipment that already left', () => { + const dispatched = { ...PENDING, status: 'ready_to_ship' as const, trackingNumber: 'AND-1' } + + expect(dispatchableShipment('paid', { kind: 'single', shipment: dispatched })).toBeNull() + }) + + // Un envío devuelto a `pending` a mano conserva su número: el backend lo + // rechaza con 409, así que la pantalla no lo ofrece. + it('does not offer a pending shipment that kept its tracking number', () => { + const reverted = { ...PENDING, trackingNumber: 'AND-1' } + + expect(dispatchableShipment('paid', { kind: 'single', shipment: reverted })).toBeNull() + }) + + it('does not offer the shipment of a cancelled order', () => { + expect(dispatchableShipment('cancelled', { kind: 'single', shipment: PENDING })).toBeNull() + }) + + it('does not offer anything while there is no single shipment to show', () => { + expect(dispatchableShipment('pending', { kind: 'none' })).toBeNull() + expect(dispatchableShipment('pending', { kind: 'duplicated', count: 2 })).toBeNull() + expect(dispatchableShipment('pending', { kind: 'loading' })).toBeNull() + expect(dispatchableShipment('pending', { kind: 'error', onRetry: () => undefined })).toBeNull() + }) +}) + +describe('originCandidates', () => { + it('takes the warehouse the lines came out of', () => { + expect(originCandidates([line(1, 2), line(2, 2)], WAREHOUSES)).toEqual([PACHECO]) + }) + + it('leaves the choice to the operator when the lines came out of more than one', () => { + expect(originCandidates([line(1, 1), line(2, 2)], WAREHOUSES)).toEqual(WAREHOUSES) + }) + + it('offers every warehouse when no line knows where it came from', () => { + expect(originCandidates([line(1, null)], WAREHOUSES)).toEqual(WAREHOUSES) + }) + + it('ignores a line that does not know its warehouse when another one does', () => { + expect(originCandidates([line(1, null), line(2, 1)], WAREHOUSES)).toEqual([EZEIZA]) + }) + + it('does not deduce the origin from a warehouse the company no longer has', () => { + expect(originCandidates([line(1, 99)], WAREHOUSES)).toEqual(WAREHOUSES) + }) +}) + +describe('toOrderQuotePayload', () => { + it('sends only the origin: the order already knows the destination and the parcel', () => { + expect(toOrderQuotePayload(2)).toEqual({ quote: { origin_warehouse_id: 2 } }) + }) +}) diff --git a/src/features/orders/utils/dispatch.ts b/src/features/orders/utils/dispatch.ts new file mode 100644 index 0000000..c1badc7 --- /dev/null +++ b/src/features/orders/utils/dispatch.ts @@ -0,0 +1,63 @@ +import type { OrderLine, OrderStatus, OriginWarehouse, Shipment, ShipmentView } from '../types' + +// Las reglas del despacho desde el detalle de la orden (TESIS-134), fuera de los +// componentes para probarlas sin montar la pantalla: cuándo se ofrece, de qué +// depósito sale y qué se cotiza. + +/** + * El envío que se puede despachar desde el detalle, o null si no hay ninguno. + * + * Es la misma regla que aplica el backend antes de pedir la etiqueta + * (`Shipments::ConfirmDispatch#validate_status!`): el envío está `pending` y + * todavía no tiene número de seguimiento. Un envío que ya salió no se vuelve a + * despachar, y uno que se devolvió a `pending` a mano conserva su número. + * + * Una orden cancelada tampoco lo ofrece, aunque el backend no lo rechace: el + * alta del envío sí las excluye (`Shipments::CreateShipment`), y emitir la + * etiqueta de una venta que no va a salir sería pagar un despacho de más. + */ +export function dispatchableShipment( + orderStatus: OrderStatus, + view: ShipmentView, +): Shipment | null { + if (orderStatus === 'cancelled' || view.kind !== 'single') return null + + const { shipment } = view + return shipment.status === 'pending' && shipment.trackingNumber === null ? shipment : null +} + +/** + * Los depósitos desde los que puede salir el envío. + * + * El envío no guarda su origen, pero cada línea sabe de qué depósito se + * descontó (TESIS-126), y el despacho tiene que salir de ahí. El alta manual + * usa uno solo, así que lo normal es un único candidato y la pantalla no + * pregunta. Si las líneas salieron de más de uno —una modificación puede sumar + * líneas de otro—, elige el operador. + * + * Sin ninguna línea que lo sepa (las anteriores a TESIS-126 y las que entran por + * webhook), no hay de dónde deducirlo y se ofrecen todos. Tampoco se deduce de + * un depósito que ya no está entre los de la empresa. + */ +export function originCandidates( + lines: OrderLine[], + warehouses: OriginWarehouse[], +): OriginWarehouse[] { + const fromLines = new Set(lines.flatMap((line) => line.warehouseId ?? [])) + const known = warehouses.filter((warehouse) => fromLines.has(warehouse.id)) + + return known.length === 0 ? warehouses : known +} + +/** Lo que manda el detalle a `POST /api/v1/orders/:id/quotes` (TESIS-46). */ +export interface OrderQuotePayload { + quote: { origin_warehouse_id: number } +} + +/** + * La cotización de una orden que ya existe. Sólo viaja el origen: el destino y + * lo que lleva el paquete el backend los lee de la orden. + */ +export function toOrderQuotePayload(originWarehouseId: number): OrderQuotePayload { + return { quote: { origin_warehouse_id: originWarehouseId } } +} diff --git a/src/features/orders/utils/shipping.test.ts b/src/features/orders/utils/shipping.test.ts index 97a4a62..037e4a2 100644 --- a/src/features/orders/utils/shipping.test.ts +++ b/src/features/orders/utils/shipping.test.ts @@ -149,14 +149,11 @@ describe('toDispatchPayload', () => { // La cotización la contesta una plantilla y la etiqueta la emite otra: el // despacho rechaza la integración de la cotización. it('dispatches with the integration that dispatches, not with the one that quoted', () => { - expect( - toDispatchPayload(quote, { warehouseId: 3, name: 'CD Ezeiza' }).dispatch - .company_integration_id, - ).toBe(4) + expect(toDispatchPayload(quote, 3).dispatch.company_integration_id).toBe(4) }) it('carries the origin and the cost that was confirmed', () => { - expect(toDispatchPayload(quote, { warehouseId: 3, name: 'CD Ezeiza' }).dispatch).toMatchObject({ + expect(toDispatchPayload(quote, 3).dispatch).toMatchObject({ origin_warehouse_id: 3, shipping_cost: 58300, }) diff --git a/src/features/orders/utils/shipping.ts b/src/features/orders/utils/shipping.ts index 59867e3..10aef78 100644 --- a/src/features/orders/utils/shipping.ts +++ b/src/features/orders/utils/shipping.ts @@ -153,12 +153,18 @@ export interface DispatchPayload { * (`dispatchIntegrationId`), no la que contestó la tarifa: son dos plantillas * del mismo courier, y el despacho rechaza la de cotización. El costo viaja * para que quede en el envío, que es de donde lo lee el detalle de la orden. + * + * Recibe el id del depósito y no el origen del borrador: despachan tanto el + * paso 3 como el detalle de una orden que ya existe, que no tiene borrador. */ -export function toDispatchPayload(quote: ShippingQuote, origin: OrderDraftOrigin): DispatchPayload { +export function toDispatchPayload( + quote: ShippingQuote, + originWarehouseId: number, +): DispatchPayload { return { dispatch: { company_integration_id: quote.dispatchIntegrationId, - origin_warehouse_id: origin.warehouseId, + origin_warehouse_id: originWarehouseId, shipping_cost: quote.shippingCost, }, }