From 74afe029ae9947407cf5a6bf843655efb39f7872 Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Sun, 27 Sep 2026 21:19:22 -0300 Subject: [PATCH 1/6] refactor: [TESIS-134] dispatch from a warehouse id instead of the draft origin The order detail dispatches orders that have no draft, so the payload takes the id of the origin warehouse and nothing else from the wizard. Co-Authored-By: Claude Opus 5.5 --- src/features/orders/pages/CarrierStepPage.tsx | 2 +- src/features/orders/utils/shipping.test.ts | 7 ++----- src/features/orders/utils/shipping.ts | 10 ++++++++-- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/features/orders/pages/CarrierStepPage.tsx b/src/features/orders/pages/CarrierStepPage.tsx index 9ea0c17..823c576 100644 --- a/src/features/orders/pages/CarrierStepPage.tsx +++ b/src/features/orders/pages/CarrierStepPage.tsx @@ -112,7 +112,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) => { 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, }, } From 5ff4c4af0456fa7993e22470fe6cc7b8a0d701df Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Sun, 27 Sep 2026 21:23:05 -0300 Subject: [PATCH 2/6] refactor: [TESIS-134] extract the quote states out of wizard step 3 Loading, failed, empty and the options list move to QuoteOptionsPanel, now that the order detail needs them too. Reviewing origin and destination is optional: an order that already exists has no draft to go back to. Co-Authored-By: Claude Opus 5.5 --- .../QuoteOptionsPanel.test.tsx | 66 +++++++++++++++++ .../QuoteOptionsPanel/QuoteOptionsPanel.tsx | 71 +++++++++++++++++++ .../QuoteOptionsPanel.types.ts | 18 +++++ .../components/QuoteOptionsPanel/index.ts | 2 + src/features/orders/pages/CarrierStepPage.tsx | 68 ++---------------- 5 files changed, 161 insertions(+), 64 deletions(-) create mode 100644 src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.test.tsx create mode 100644 src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.tsx create mode 100644 src/features/orders/components/QuoteOptionsPanel/QuoteOptionsPanel.types.ts create mode 100644 src/features/orders/components/QuoteOptionsPanel/index.ts 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/pages/CarrierStepPage.tsx b/src/features/orders/pages/CarrierStepPage.tsx index 823c576..886994b 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' @@ -163,7 +163,7 @@ export function CarrierStepPage() { icon={} title={carrier.options.groupLabel} > - - selectedId: number | null - disabled: boolean - onSelect: Parameters[0]['onSelect'] - onReview: () => void -} - -// Los estados de la cotización, fuera del cuerpo de la página para que éste se -// lea de un vistazo. -function QuotesContent({ quotes, selectedId, disabled, onSelect, onReview }: QuotesContentProps) { - const { options: copy } = carrier - - // 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 o volver a revisar origen y destino. - if (quotes.isError || quotes.data.length === 0) { - return ( - - - - - } - > - {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. From 058c8679e6683cccf012a38ade6fb54579093246 Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Sun, 27 Sep 2026 21:32:56 -0300 Subject: [PATCH 3/6] feat: [TESIS-134] dispatch a pending shipment from the order detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An order whose dispatch failed in wizard step 3 had nowhere else to be dispatched from once the screen was left. The detail now offers «Despachar» while its shipment is pending and without a tracking number, on orders that are not cancelled. The dialog takes the origin from the warehouse the lines came out of, and asks only when there is more than one or none is recorded. It quotes the order, lets the operator pick an option with the same list as step 3, and dispatches with the integration that dispatches, its cost and that origin. A failed dispatch says so and retries without closing; a 409 does not offer a retry. Dispatching refreshes the order, so the detail shows the shipment on its way. Co-Authored-By: Claude Opus 5.5 --- src/features/orders/api.ts | 18 ++ .../DispatchShipmentDialog.styles.ts | 30 +++ .../DispatchShipmentDialog.test.tsx | 214 +++++++++++++++++ .../DispatchShipmentDialog.tsx | 218 ++++++++++++++++++ .../DispatchShipmentDialog.types.ts | 22 ++ .../DispatchShipmentDialog/OriginOptions.tsx | 59 +++++ .../DispatchShipmentDialog/index.ts | 2 + .../ShipmentLifecycleCard.styles.ts | 6 + .../ShipmentLifecycleCard.test.tsx | 11 + .../ShipmentLifecycleCard.tsx | 4 +- .../ShipmentLifecycleCard.types.ts | 7 + src/features/orders/content.ts | 33 +++ .../orders/hooks/useDispatchShipment.test.tsx | 81 +++++++ .../orders/hooks/useDispatchShipment.ts | 31 +++ src/features/orders/hooks/useOrderQuotes.ts | 31 +++ .../orders/pages/OrderDetailPage.test.tsx | 57 +++++ src/features/orders/pages/OrderDetailPage.tsx | 48 +++- src/features/orders/queryKeys.ts | 8 +- src/features/orders/utils/dispatch.test.ts | 90 ++++++++ src/features/orders/utils/dispatch.ts | 63 +++++ 20 files changed, 1029 insertions(+), 4 deletions(-) create mode 100644 src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.styles.ts create mode 100644 src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.test.tsx create mode 100644 src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.tsx create mode 100644 src/features/orders/components/DispatchShipmentDialog/DispatchShipmentDialog.types.ts create mode 100644 src/features/orders/components/DispatchShipmentDialog/OriginOptions.tsx create mode 100644 src/features/orders/components/DispatchShipmentDialog/index.ts create mode 100644 src/features/orders/hooks/useDispatchShipment.test.tsx create mode 100644 src/features/orders/hooks/useDispatchShipment.ts create mode 100644 src/features/orders/hooks/useOrderQuotes.ts create mode 100644 src/features/orders/utils/dispatch.test.ts create mode 100644 src/features/orders/utils/dispatch.ts diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts index 95b334a..65481fc 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 @@ -411,6 +412,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/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 befe747..01fc9b7 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.', 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/OrderDetailPage.test.tsx b/src/features/orders/pages/OrderDetailPage.test.tsx index a7107b3..4788d43 100644 --- a/src/features/orders/pages/OrderDetailPage.test.tsx +++ b/src/features/orders/pages/OrderDetailPage.test.tsx @@ -13,6 +13,14 @@ vi.mock('../hooks/useOrderDetail', () => ({ useOrderShipment: vi.fn(), })) +// El flujo del despacho tiene sus propios tests: acá sólo importa cuándo se +// ofrece y con qué envío se abre. +vi.mock('../components/DispatchShipmentDialog', () => ({ + DispatchShipmentDialog: ({ shipmentId }: { shipmentId: number }) => ( +

{`Despachando el envío ${shipmentId}`}

+ ), +})) + const ORDER: OrderDetail = { id: 8829, externalOrderId: 'ORD-8829-X', @@ -200,4 +208,53 @@ 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('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 540fdfc..65a45d7 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 } } +} From 6fccf533c750da5eb4b3eb516445721bf7bfcdba Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Sun, 27 Sep 2026 21:35:43 -0300 Subject: [PATCH 4/6] feat: [TESIS-134] point the step 3 dispatch error at the order detail When the shipment is open and pending, a failed dispatch now says it can be dispatched later from the order detail, and leaving the screen no longer asks for confirmation: the detail takes over. If the shipment could not even be opened, nothing else can open it, so the error keeps asking to retry before leaving and the browser keeps asking before a reload. The hook tells the page which of the two happened. Co-Authored-By: Claude Opus 5.5 --- src/features/orders/content.ts | 9 ++- .../hooks/useConfirmDraftOrder.test.tsx | 19 ++++++ .../orders/hooks/useConfirmDraftOrder.ts | 8 ++- .../orders/pages/CarrierStepPage.test.tsx | 63 ++++++++++++++++--- src/features/orders/pages/CarrierStepPage.tsx | 22 ++++--- 5 files changed, 103 insertions(+), 18 deletions(-) diff --git a/src/features/orders/content.ts b/src/features/orders/content.ts index 01fc9b7..4da82fd 100644 --- a/src/features/orders/content.ts +++ b/src/features/orders/content.ts @@ -359,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/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 886994b..bd4b501 100644 --- a/src/features/orders/pages/CarrierStepPage.tsx +++ b/src/features/orders/pages/CarrierStepPage.tsx @@ -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 @@ -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 : ( ), })) @@ -106,6 +121,7 @@ function renderAt(path: string) { beforeEach(() => { vi.mocked(useOrder).mockReset() vi.mocked(useOrderShipment).mockReset() + vi.mocked(notify).mockClear() }) describe('OrderDetailPage', () => { @@ -230,6 +246,17 @@ describe('OrderDetailPage', () => { 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 })