From 6bc9806cdd1d1be1a62d9448fe9d7b6ebed89634 Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Thu, 24 Sep 2026 21:44:06 -0300 Subject: [PATCH 1/6] feat: [TESIS-59] quote the draft and confirm it against the API The boundary for step 3 of the manual order wizard: quote the draft before the order exists (POST /quotes, TESIS-131), create the order, open its shipment and dispatch it with the chosen option. useConfirmDraftOrder chains the last three. They cannot be one atomic request because the dispatch calls an external courier, so a retry after a failed dispatch resumes where it stopped instead of creating the order (and deducting the stock) a second time. toQuotePayload goes away: it quoted an order that already existed, which is the flow this card replaces. Co-Authored-By: Claude Opus 5.5 --- src/features/orders/api.test.ts | 95 ++++++++++++ src/features/orders/api.ts | 57 +++++++ .../hooks/useConfirmDraftOrder.test.tsx | 140 ++++++++++++++++++ .../orders/hooks/useConfirmDraftOrder.ts | 72 +++++++++ src/features/orders/hooks/useDraftQuotes.ts | 30 ++++ src/features/orders/queryKeys.ts | 18 +++ src/features/orders/types.ts | 17 +++ src/features/orders/utils/shipping.test.ts | 77 +++++++++- src/features/orders/utils/shipping.ts | 77 ++++++++-- 9 files changed, 568 insertions(+), 15 deletions(-) create mode 100644 src/features/orders/hooks/useConfirmDraftOrder.test.tsx create mode 100644 src/features/orders/hooks/useConfirmDraftOrder.ts create mode 100644 src/features/orders/hooks/useDraftQuotes.ts diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts index bbcc39e..60f229c 100644 --- a/src/features/orders/api.test.ts +++ b/src/features/orders/api.test.ts @@ -3,11 +3,15 @@ import { client } from 'shared/api/client' import { describe, expect, it, vi } from 'vitest' import { + createOrder, + createOrderShipment, + dispatchShipment, fetchOrder, fetchOrderShipment, fetchProductStocks, fetchProvinces, fetchWarehouses, + quoteDraft, updateOrder, } from './api' @@ -241,3 +245,94 @@ describe('fetchProvinces', () => { expect(get).toHaveBeenCalledWith('/orders/provinces') }) }) + +describe('quoteDraft', () => { + const payload = { + quote: { + origin_warehouse_id: 3, + destination_zip_code: '1193', + destination_address: 'Av. Corrientes 3247', + items: [{ product_id: 12, quantity: 4 }], + }, + } + + it('quotes the draft against the endpoint that needs no order', async () => { + const post = vi.spyOn(client, 'post').mockResolvedValueOnce(respond({ data: [] })) + + await quoteDraft(payload) + + expect(post).toHaveBeenCalledWith('/quotes', payload) + }) + + // `shipping_cost` es un BigDecimal de Rails y el JSON lo manda como string. + it('turns each option into the domain, with the cost as a number', async () => { + vi.spyOn(client, 'post').mockResolvedValueOnce( + respond({ + data: [ + { + company_integration_id: 7, + dispatch_integration_id: 4, + provider_name: 'Andreani', + shipping_cost: '58300.0', + estimated_days: null, + }, + ], + }), + ) + + expect(await quoteDraft(payload)).toEqual([ + { + quoteIntegrationId: 7, + dispatchIntegrationId: 4, + providerName: 'Andreani', + shippingCost: 58300, + estimatedDays: null, + }, + ]) + }) +}) + +describe('the confirmation of a manual order', () => { + it('creates the order with the payload of the wizard', async () => { + const post = vi + .spyOn(client, 'post') + .mockResolvedValueOnce(respond({ ...ORDER, order_items: [] })) + const payload = { + order: { + customer_name: 'Global Tech', + customer_document: '30-71234567-8', + customer_address: 'Av. Corrientes 3247', + customer_city: 'CABA', + customer_province: 'Ciudad Autónoma de Buenos Aires', + customer_zip_code: '1193', + items: [{ product_id: 12, warehouse_id: 3, quantity: 4, unit_price: 120000 }], + }, + } + + const order = await createOrder(payload) + + expect(post).toHaveBeenCalledWith('/orders', payload) + expect(order.id).toBe(ORDER.id) + }) + + it('opens the shipment of the order', async () => { + const post = vi.spyOn(client, 'post').mockResolvedValueOnce(respond(SHIPMENT)) + + const shipment = await createOrderShipment(8829) + + expect(post).toHaveBeenCalledWith('/orders/8829/shipment') + expect(shipment.id).toBe(31) + }) + + it('dispatches the shipment with the chosen option', async () => { + const post = vi.spyOn(client, 'post').mockResolvedValueOnce(respond(SHIPMENT)) + const payload = { + dispatch: { company_integration_id: 4, origin_warehouse_id: 3, shipping_cost: 58300 }, + } + + const shipment = await dispatchShipment(31, payload) + + expect(post).toHaveBeenCalledWith('/shipments/31/dispatch', payload) + expect(shipment.trackingNumber).toBe('AND-9920-X8829-Z') + }) +}) diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts index 1196e96..f02cc65 100644 --- a/src/features/orders/api.ts +++ b/src/features/orders/api.ts @@ -14,8 +14,10 @@ import type { ProductStockByWarehouse, Shipment, ShipmentStatus, + ShippingQuote, UpdateOrderPayload, } from './types' +import type { CreateOrderPayload, DispatchPayload, DraftQuotePayload } from './utils/shipping' // Frontera con la API Rails. Lo que entra en snake_case se traduce acá y sale // como el dominio en camelCase. @@ -114,6 +116,16 @@ interface ApiShipment { events: ApiShipmentEvent[] } +// Una opción de la cotización. `shipping_cost` es un BigDecimal del lado de +// Rails, que el JSON serializa como string ("2500.0"): se convierte acá. +interface ApiShippingQuote { + company_integration_id: number + dispatch_integration_id: number + provider_name: string + shipping_cost: string | number + estimated_days: number | null +} + // La fila del listado de envíos: sólo se lee para saber cuántos hay y cuál es. interface ApiShipmentList { data: { id: number }[] @@ -363,3 +375,48 @@ export async function fetchProvinces(): Promise { return data.data } + +function toShippingQuote(quote: ApiShippingQuote): ShippingQuote { + return { + quoteIntegrationId: quote.company_integration_id, + dispatchIntegrationId: quote.dispatch_integration_id, + providerName: quote.provider_name, + shippingCost: Number(quote.shipping_cost), + estimatedDays: quote.estimated_days, + } +} + +/** + * Las opciones de envío para el borrador del alta manual (TESIS-131), antes de + * que la orden exista. Una lista vacía no es un error: quiere decir que ningún + * operador contestó a tiempo, y la pantalla lo muestra distinto de un fallo. + */ +export async function quoteDraft(payload: DraftQuotePayload): Promise { + const { data } = await client.post<{ data: ApiShippingQuote[] }>('/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) + + return toOrderDetail(response.data, readVersion(response.headers.etag)) +} + +/** Abre el envío de la orden, `pending` y sin courier (TESIS-105). */ +export async function createOrderShipment(orderId: number): Promise { + const { data } = await client.post(`/orders/${orderId}/shipment`) + + return toShipment(data) +} + +/** Despacha el envío con el operador elegido: pide la etiqueta (TESIS-47). */ +export async function dispatchShipment( + shipmentId: number, + payload: DispatchPayload, +): Promise { + const { data } = await client.post(`/shipments/${shipmentId}/dispatch`, payload) + + return toShipment(data) +} diff --git a/src/features/orders/hooks/useConfirmDraftOrder.test.tsx b/src/features/orders/hooks/useConfirmDraftOrder.test.tsx new file mode 100644 index 0000000..9c747a3 --- /dev/null +++ b/src/features/orders/hooks/useConfirmDraftOrder.test.tsx @@ -0,0 +1,140 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } 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 { OrderDetail, Shipment } from '../types' + +import { useConfirmDraftOrder } from './useConfirmDraftOrder' +import type { ConfirmDraftOrderInput } from './useConfirmDraftOrder' + +const INPUT: ConfirmDraftOrderInput = { + order: { + order: { + customer_name: 'Marina Rodríguez', + customer_document: '20-31298744-9', + customer_address: 'Av. Corrientes 3247', + customer_city: 'CABA', + customer_province: 'Ciudad Autónoma de Buenos Aires', + customer_zip_code: '1193', + items: [{ product_id: 12, warehouse_id: 3, quantity: 4, unit_price: 120000 }], + }, + }, + dispatch: { + dispatch: { company_integration_id: 4, origin_warehouse_id: 3, shipping_cost: 58300 }, + }, +} + +const ORDER = { id: 8829 } as OrderDetail +const SHIPMENT = { id: 31 } as Shipment + +function newClient() { + return new QueryClient({ defaultOptions: { mutations: { retry: false } } }) +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +function wrapper({ children }: { children: ReactNode }) { + return wrapperFor(newClient())({ children }) +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('useConfirmDraftOrder', () => { + it('creates the order, opens its shipment and dispatches it, in that order', async () => { + const calls: string[] = [] + vi.spyOn(api, 'createOrder').mockImplementation(() => { + calls.push('order') + return Promise.resolve(ORDER) + }) + vi.spyOn(api, 'createOrderShipment').mockImplementation((orderId) => { + calls.push(`shipment of ${orderId}`) + return Promise.resolve(SHIPMENT) + }) + vi.spyOn(api, 'dispatchShipment').mockImplementation((shipmentId) => { + calls.push(`dispatch of ${shipmentId}`) + return Promise.resolve(SHIPMENT) + }) + const { result } = renderHook(() => useConfirmDraftOrder(), { wrapper }) + + await act(() => result.current.mutateAsync(INPUT)) + + expect(calls).toEqual(['order', 'shipment of 8829', 'dispatch of 31']) + }) + + // El punto del hook: si el despacho falla, la orden ya existe y ya descontó + // el stock. Reintentar no puede crear otra. + it('retries from where it failed, without creating the order again', async () => { + const createOrder = vi.spyOn(api, 'createOrder').mockResolvedValue(ORDER) + const createShipment = vi.spyOn(api, 'createOrderShipment').mockResolvedValue(SHIPMENT) + vi.spyOn(api, 'dispatchShipment') + .mockRejectedValueOnce(new Error('El courier no contestó')) + .mockResolvedValueOnce(SHIPMENT) + const { result } = renderHook(() => useConfirmDraftOrder(), { wrapper }) + + await act(() => result.current.mutateAsync(INPUT).catch(() => undefined)) + await act(() => result.current.mutateAsync(INPUT)) + + expect(createOrder).toHaveBeenCalledTimes(1) + expect(createShipment).toHaveBeenCalledTimes(1) + }) + + it('tells the page the order exists as soon as it does', async () => { + const onOrderCreated = vi.fn() + vi.spyOn(api, 'createOrder').mockResolvedValue(ORDER) + vi.spyOn(api, 'createOrderShipment').mockRejectedValue(new Error('boom')) + const { result } = renderHook(() => useConfirmDraftOrder({ onOrderCreated }), { wrapper }) + + await act(() => result.current.mutateAsync(INPUT).catch(() => undefined)) + + expect(onOrderCreated).toHaveBeenCalledWith(8829) + await waitFor(() => expect(result.current.createdOrderId).toBe(8829)) + }) + + 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') + const { result } = renderHook(() => useConfirmDraftOrder(), { wrapper }) + + await act(() => result.current.mutateAsync(INPUT).catch(() => undefined)) + + expect(result.current.createdOrderId).toBeNull() + expect(createShipment).not.toHaveBeenCalled() + }) + + // Crear la orden refresca el listado, pero no puede volver a cotizar: sería + // una llamada de más a cada courier y, si el despacho falló, cambiarle las + // opciones al operador mientras decide si reintentar. + it('refreshes the orders without asking the carriers for a new quote', async () => { + vi.spyOn(api, 'createOrder').mockResolvedValue(ORDER) + vi.spyOn(api, 'createOrderShipment').mockResolvedValue(SHIPMENT) + vi.spyOn(api, 'dispatchShipment').mockResolvedValue(SHIPMENT) + const queryClient = newClient() + const quote = quoteKeys.draft({ + quote: { + origin_warehouse_id: 3, + destination_zip_code: '1193', + destination_address: '', + items: [], + }, + }) + queryClient.setQueryData(quote, []) + queryClient.setQueryData(orderKeys.lists(), []) + const { result } = renderHook(() => useConfirmDraftOrder(), { + wrapper: wrapperFor(queryClient), + }) + + await act(() => result.current.mutateAsync(INPUT)) + + expect(queryClient.getQueryState(orderKeys.lists())?.isInvalidated).toBe(true) + expect(queryClient.getQueryState(quote)?.isInvalidated).toBe(false) + }) +}) diff --git a/src/features/orders/hooks/useConfirmDraftOrder.ts b/src/features/orders/hooks/useConfirmDraftOrder.ts new file mode 100644 index 0000000..79b9319 --- /dev/null +++ b/src/features/orders/hooks/useConfirmDraftOrder.ts @@ -0,0 +1,72 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useRef, useState } from 'react' +import type { ApiRequestError } from 'shared/api/types' + +import { createOrder, createOrderShipment, dispatchShipment } from '../api' +import { orderKeys } from '../queryKeys' +import type { CreateOrderPayload, DispatchPayload } from '../utils/shipping' + +export interface ConfirmDraftOrderInput { + order: CreateOrderPayload + dispatch: DispatchPayload +} + +interface Progress { + orderId: number | null + shipmentId: number | null +} + +interface Options { + /** Apenas existe la orden, antes de abrir el envío. */ + onOrderCreated?: (orderId: number) => void +} + +/** + * «Confirmar orden» del paso 3 (S07): crea la orden, abre su envío y lo + * despacha con el operador elegido. Resuelve con el id de la orden. + * + * Son tres requests y no pueden ser uno atómico: el despacho llama a un courier + * externo (ADR-016 del backend). Si falla después del alta, la orden ya existe + * —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ó. + */ +export function useConfirmDraftOrder({ onOrderCreated }: Options = {}) { + const queryClient = useQueryClient() + const progress = useRef({ orderId: null, shipmentId: null }) + const [createdOrderId, setCreatedOrderId] = useState(null) + + const mutation = useMutation({ + mutationFn: async ({ order, dispatch }) => { + if (progress.current.orderId === null) { + const created = await createOrder(order) + progress.current.orderId = created.id + setCreatedOrderId(created.id) + onOrderCreated?.(created.id) + } + const orderId = progress.current.orderId + + if (progress.current.shipmentId === null) { + const shipment = await createOrderShipment(orderId) + progress.current.shipmentId = shipment.id + } + + await dispatchShipment(progress.current.shipmentId, dispatch) + + return orderId + }, + // También si falló el despacho: la orden ya existe y el stock ya se movió, + // así que el listado y el inventario quedaron viejos igual. + onSettled: () => { + if (progress.current.orderId === null) return undefined + + return Promise.all([ + queryClient.invalidateQueries({ queryKey: orderKeys.all }), + // Literal y no la factory de inventario: una feature no importa otra. + queryClient.invalidateQueries({ queryKey: ['inventory'] }), + ]) + }, + }) + + return { ...mutation, createdOrderId } +} diff --git a/src/features/orders/hooks/useDraftQuotes.ts b/src/features/orders/hooks/useDraftQuotes.ts new file mode 100644 index 0000000..9945a7c --- /dev/null +++ b/src/features/orders/hooks/useDraftQuotes.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' + +import { quoteDraft } from '../api' +import { quoteKeys } from '../queryKeys' +import type { ShippingQuote } from '../types' +import type { DraftQuotePayload } from '../utils/shipping' + +// Una tarifa caduca enseguida: pasado un minuto, volver al paso 3 cotiza de +// nuevo en vez de mostrar precios que el courier ya no sostiene. +const QUOTE_TTL_MS = 60_000 + +/** + * Las opciones de envío del borrador (paso 3, S07), pedidas apenas se entra al + * paso. `null` mientras falta algún dato del borrador: no hay nada que cotizar. + * + * Sin reintento automático: el backend ya espera a cada courier hasta su propio + * timeout, y reintentar en silencio duplicaría esa espera. Si falla, la + * pantalla ofrece reintentar. + */ +export function useDraftQuotes(payload: DraftQuotePayload | null) { + return useQuery({ + // La clave con `null` nunca se pide (`enabled`): sólo está para que el tipo + // cierre sin inventar un borrador vacío. + queryKey: payload === null ? quoteKeys.all : quoteKeys.draft(payload), + queryFn: () => quoteDraft(payload as DraftQuotePayload), + enabled: payload !== null, + staleTime: QUOTE_TTL_MS, + retry: false, + }) +} diff --git a/src/features/orders/queryKeys.ts b/src/features/orders/queryKeys.ts index d28b1ea..540fdfc 100644 --- a/src/features/orders/queryKeys.ts +++ b/src/features/orders/queryKeys.ts @@ -1,4 +1,5 @@ import type { OrderFilters, OrderStatus } from './types' +import type { DraftQuotePayload } from './utils/shipping' // Factory de query keys de la feature — nunca literales sueltos en los hooks, // así las invalidaciones no se desincronizan cuando aparezcan las mutaciones @@ -20,6 +21,23 @@ export const orderKeys = { shipment: (orderId: number) => [...orderKeys.all, 'shipment', orderId] as const, } +/** + * La cotización del alta manual (TESIS-59). + * + * 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 + * pedir a todos los couriers apenas se crea la orden —una llamada de más por + * operador y, si el despacho falló, una lista que cambia mientras el operador + * decide si reintentar—. + * + * El borrador entero entra en la clave: volver al paso 2 y cambiar el depósito + * o una cantidad es otra cotización, no la misma con datos viejos. + */ +export const quoteKeys = { + all: ['quotes'] as const, + draft: (payload: DraftQuotePayload) => [...quoteKeys.all, 'draft', payload] as const, +} + /** * El catálogo que consulta el buscador del alta manual. * diff --git a/src/features/orders/types.ts b/src/features/orders/types.ts index af8d806..9bab6f8 100644 --- a/src/features/orders/types.ts +++ b/src/features/orders/types.ts @@ -112,6 +112,23 @@ export interface ShipmentEvent { occurredAt: string } +/** + * Una opción de envío cotizada (TESIS-46, TESIS-131). La lista llega ordenada + * por precio, de la más barata a la más cara, y ya sin las opciones que no se + * podrían despachar. + */ +export interface ShippingQuote { + /** La integración que contestó la tarifa. */ + quoteIntegrationId: number + /** La que emite la etiqueta: es la que se le manda al despacho. */ + dispatchIntegrationId: number + /** El courier ("Andreani"), no el nombre de su plantilla de cotización. */ + providerName: string + shippingCost: number + /** Días de entrega que promete el courier, o null si no lo informa. */ + estimatedDays: number | null +} + /** Detalle del envío (`GET /api/v1/shipments/:id`), con su bitácora. */ export interface Shipment { id: number diff --git a/src/features/orders/utils/shipping.test.ts b/src/features/orders/utils/shipping.test.ts index 1814c28..97a4a62 100644 --- a/src/features/orders/utils/shipping.test.ts +++ b/src/features/orders/utils/shipping.test.ts @@ -1,9 +1,15 @@ import type { OrderDraftItem } from 'shared/store' import { describe, expect, it } from 'vitest' -import type { ProductStockByWarehouse } from '../types' +import type { ProductStockByWarehouse, ShippingQuote } from '../types' -import { toCreateOrderPayload, toQuotePayload, warehouseCoverage } from './shipping' +import { + toCreateOrderPayload, + toDispatchPayload, + toDraftQuotePayload, + totalWithShipping, + warehouseCoverage, +} from './shipping' function item(overrides: Partial = {}): OrderDraftItem { return { @@ -105,10 +111,69 @@ describe('toCreateOrderPayload', () => { }) }) -describe('toQuotePayload', () => { - it('only tells the quote where the package leaves from', () => { - expect(toQuotePayload({ warehouseId: 3, name: 'CD Ezeiza' })).toEqual({ - quote: { origin_warehouse_id: 3 }, +describe('toDraftQuotePayload', () => { + const origin = { warehouseId: 3, name: 'CD Ezeiza' } + const destination = { + address: ' Av. Corrientes 3247, piso 5 ', + city: 'CABA', + province: 'Ciudad Autónoma de Buenos Aires', + zipCode: '1193 ', + } + + it('tells the quote where the parcel leaves from and where it goes', () => { + expect(toDraftQuotePayload([ROUTER], origin, destination).quote).toMatchObject({ + origin_warehouse_id: 3, + destination_zip_code: '1193', + destination_address: 'Av. Corrientes 3247, piso 5', }) }) + + // El peso no viaja: lo calcula el backend con el de cada producto. + it('says what the parcel carries, not how much it weighs', () => { + expect(toDraftQuotePayload([ROUTER, SENSOR], origin, destination).quote.items).toEqual([ + { product_id: 12, quantity: 4 }, + { product_id: 13, quantity: 10 }, + ]) + }) +}) + +describe('toDispatchPayload', () => { + const quote: ShippingQuote = { + quoteIntegrationId: 7, + dispatchIntegrationId: 4, + providerName: 'Andreani', + shippingCost: 58300, + estimatedDays: 2, + } + + // 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) + }) + + it('carries the origin and the cost that was confirmed', () => { + expect(toDispatchPayload(quote, { warehouseId: 3, name: 'CD Ezeiza' }).dispatch).toMatchObject({ + origin_warehouse_id: 3, + shipping_cost: 58300, + }) + }) +}) + +describe('totalWithShipping', () => { + it('adds the chosen shipping to the products', () => { + expect(totalWithShipping(1420000, 58300)).toBe(1478300) + }) + + it('adds cents without floating point leftovers', () => { + expect(totalWithShipping(0.1, 0.2)).toBe(0.3) + }) + + // Sin opción elegida el envío no cuesta 0: el total es lo que se sabe. + it('is the products alone while no shipping was chosen', () => { + expect(totalWithShipping(1420000, null)).toBe(1420000) + }) }) diff --git a/src/features/orders/utils/shipping.ts b/src/features/orders/utils/shipping.ts index 2674918..59867e3 100644 --- a/src/features/orders/utils/shipping.ts +++ b/src/features/orders/utils/shipping.ts @@ -5,11 +5,12 @@ import type { OrderDraftOrigin, } from 'shared/store' -import type { ProductStockByWarehouse } from '../types' +import type { ProductStockByWarehouse, ShippingQuote } from '../types' -// Las reglas del paso 2 del alta manual (S06), fuera de los componentes para -// probarlas sin montar la pantalla: qué depósito cubre el borrador y cómo se -// arman los dos requests que va a hacer el paso 3 con lo que se eligió acá. +// Las reglas del envío en el alta manual, fuera de los componentes para +// probarlas sin montar las pantallas: qué depósito cubre el borrador (paso 2, +// S06) y cómo se arman los requests del paso 3 (S07): cotizar el borrador, +// crear la orden y despachar la opción elegida. /** Cómo cubre un depósito las líneas del borrador. */ export type CoverageLevel = 'full' | 'partial' | 'none' @@ -106,11 +107,69 @@ export function toCreateOrderPayload( } } +/** Lo que el paso 3 manda a `POST /api/v1/quotes` para cotizar el borrador. */ +export interface DraftQuotePayload { + quote: { + origin_warehouse_id: number + destination_zip_code: string + destination_address: string + items: { product_id: number; quantity: number }[] + } +} + +/** + * La cotización del borrador, antes de que la orden exista (TESIS-131). + * + * Viaja qué lleva el paquete y no cuánto pesa: el peso lo calcula el backend con + * el de cada producto, que es su dato. Así cotizar no crea la orden ni descuenta + * stock; eso pasa una sola vez, cuando el operador confirma. + */ +export function toDraftQuotePayload( + items: OrderDraftItem[], + origin: OrderDraftOrigin, + destination: OrderDraftDestination, +): DraftQuotePayload { + return { + quote: { + origin_warehouse_id: origin.warehouseId, + destination_zip_code: destination.zipCode.trim(), + destination_address: destination.address.trim(), + items: items.map((item) => ({ product_id: item.productId, quantity: item.quantity })), + }, + } +} + +/** Lo que el paso 3 manda a `POST /api/v1/shipments/:id/dispatch`. */ +export interface DispatchPayload { + dispatch: { + company_integration_id: number + origin_warehouse_id: number + shipping_cost: number + } +} + +/** + * El despacho de la opción elegida. La integración es la que **despacha** + * (`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. + */ +export function toDispatchPayload(quote: ShippingQuote, origin: OrderDraftOrigin): DispatchPayload { + return { + dispatch: { + company_integration_id: quote.dispatchIntegrationId, + origin_warehouse_id: origin.warehouseId, + shipping_cost: quote.shippingCost, + }, + } +} + /** - * Lo que el paso 3 manda a `POST /api/v1/orders/:id/quotes`, una vez creada la - * orden. El destino no viaja: la cotización lo lee de la orden (código postal y - * dirección), así que lo único que falta decirle es de dónde sale el paquete. + * El total final del paso 3: productos más el envío elegido. En centavos, como + * el resto de las cuentas de la feature. Sin envío elegido todavía, el total es + * lo que se sabe: los productos. */ -export function toQuotePayload(origin: OrderDraftOrigin) { - return { quote: { origin_warehouse_id: origin.warehouseId } } +export function totalWithShipping(subtotal: number, shippingCost: number | null): number { + const shippingCents = shippingCost === null ? 0 : Math.round(shippingCost * 100) + return (Math.round(subtotal * 100) + shippingCents) / 100 } From 55d08d525037de56fd77714dd13d49ad125b14fa Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Thu, 24 Sep 2026 21:46:00 -0300 Subject: [PATCH 2/6] refactor: [TESIS-59] extract the selectable card of the origin picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 lists the quoted carriers as the same kind of selectable card as the origin warehouses of step 2: primary border and container when chosen, in both modes. SelectableCard moves out of the picker now that it has a second consumer, not before (feature-structure.md §6). Co-Authored-By: Claude Opus 5.5 --- .../OriginWarehousePicker.styles.ts | 36 +--------------- .../OriginWarehousePicker.tsx | 6 +-- .../SelectableCard/SelectableCard.styles.ts | 42 +++++++++++++++++++ .../orders/components/SelectableCard/index.ts | 1 + 4 files changed, 47 insertions(+), 38 deletions(-) create mode 100644 src/features/orders/components/SelectableCard/SelectableCard.styles.ts create mode 100644 src/features/orders/components/SelectableCard/index.ts diff --git a/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.styles.ts b/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.styles.ts index 7adbe85..18ed6c1 100644 --- a/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.styles.ts +++ b/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.styles.ts @@ -1,11 +1,6 @@ -import { Box, ButtonBase } from '@mui/material' +import { Box } from '@mui/material' import { styled } from '@mui/material/styles' -// Radio de la tarjeta (px): `radius.md` del DS. No se lee de `tokens.ts` porque -// una feature no puede importar de `app/` (architecture.md §3.2); mismo -// criterio que `EditProductModal.styles.ts`. -const OPTION_RADIUS = 12 - // Tres columnas en el diseño; dos o una cuando no entran. export const OptionsGrid = styled(Box)(({ theme }) => ({ display: 'grid', @@ -15,35 +10,6 @@ export const OptionsGrid = styled(Box)(({ theme }) => ({ [theme.breakpoints.down('sm')]: { gridTemplateColumns: 'minmax(0, 1fr)' }, })) -interface OptionProps { - selected: boolean -} - -const TRANSIENT_PROPS = new Set(['selected']) - -// La tarjeta de depósito de S06. La elegida se resalta con el contenedor y el -// borde del color de acción, en los dos modos: la card pide que la opción -// elegida se distinga claramente, y el color no va solo — el ícono de check -// acompaña. -export const Option = styled(ButtonBase, { - shouldForwardProp: (prop) => !TRANSIENT_PROPS.has(prop as string), -})(({ theme, selected }) => ({ - display: 'flex', - flexDirection: 'column', - alignItems: 'stretch', - gap: theme.spacing(1.5), - padding: theme.spacing(2), - textAlign: 'left', - borderRadius: OPTION_RADIUS, - border: `1px solid ${selected ? theme.palette.primary.main : theme.palette.divider}`, - backgroundColor: selected ? theme.palette.primary.container : theme.palette.background.default, - transition: theme.transitions.create(['border-color', 'background-color'], { - duration: theme.transitions.duration.shorter, - }), - '&:hover:not(.Mui-disabled)': { borderColor: theme.palette.primary.main }, - '&.Mui-disabled': { opacity: 0.6 }, -})) - export const OptionHeader = styled(Box)(({ theme }) => ({ display: 'flex', alignItems: 'flex-start', diff --git a/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.tsx b/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.tsx index 5cb2a93..67c3113 100644 --- a/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.tsx +++ b/src/features/orders/components/OriginWarehousePicker/OriginWarehousePicker.tsx @@ -6,9 +6,9 @@ import type { StatusVariant } from 'shared/components' import { ordersCopy } from '../../content' import type { CoverageLevel, WarehouseCoverage } from '../../utils/shipping' +import { SelectableCard } from '../SelectableCard' import { - Option, OptionHeader, OptionTitle, OptionsGrid, @@ -55,7 +55,7 @@ export function OriginWarehousePicker({ const detailId = `origin-warehouse-${warehouse.id}-detail` return ( - + ) })} diff --git a/src/features/orders/components/SelectableCard/SelectableCard.styles.ts b/src/features/orders/components/SelectableCard/SelectableCard.styles.ts new file mode 100644 index 0000000..d4b4037 --- /dev/null +++ b/src/features/orders/components/SelectableCard/SelectableCard.styles.ts @@ -0,0 +1,42 @@ +import { ButtonBase } from '@mui/material' +import { styled } from '@mui/material/styles' + +// Radio de la tarjeta (px): `radius.md` del DS. No se lee de `tokens.ts` porque +// una feature no puede importar de `app/` (architecture.md §3.2); mismo +// criterio que `EditProductModal.styles.ts`. +const CARD_RADIUS = 12 + +interface SelectableCardProps { + selected: boolean +} + +const TRANSIENT_PROPS = new Set(['selected']) + +/** + * Una opción elegible con forma de tarjeta: el depósito de origen del paso 2 + * (S06) y el operador logístico del paso 3 (S07). Se extrajo cuando apareció el + * segundo consumidor (`feature-structure.md` §6). + * + * La elegida se resalta con el contenedor y el borde del color de acción, en los + * dos modos: la opción elegida se tiene que distinguir claramente, y el color no + * va solo — cada consumidor la acompaña con un ícono de check. El rol de radio y + * `aria-checked` los pone el consumidor, que es el que arma el grupo. + */ +export const SelectableCard = styled(ButtonBase, { + shouldForwardProp: (prop) => !TRANSIENT_PROPS.has(prop as string), +})(({ theme, selected }) => ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + gap: theme.spacing(1.5), + padding: theme.spacing(2), + textAlign: 'left', + borderRadius: CARD_RADIUS, + border: `1px solid ${selected ? theme.palette.primary.main : theme.palette.divider}`, + backgroundColor: selected ? theme.palette.primary.container : theme.palette.background.default, + transition: theme.transitions.create(['border-color', 'background-color'], { + duration: theme.transitions.duration.shorter, + }), + '&:hover:not(.Mui-disabled)': { borderColor: theme.palette.primary.main }, + '&.Mui-disabled': { opacity: 0.6 }, +})) diff --git a/src/features/orders/components/SelectableCard/index.ts b/src/features/orders/components/SelectableCard/index.ts new file mode 100644 index 0000000..4166269 --- /dev/null +++ b/src/features/orders/components/SelectableCard/index.ts @@ -0,0 +1 @@ +export { SelectableCard } from './SelectableCard.styles' From b48cc93168229db98a1b81af552a1454b5cf2dc6 Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Thu, 24 Sep 2026 22:12:59 -0300 Subject: [PATCH 3/6] feat: [TESIS-59] add manual order wizard step 3: carrier and confirmation /orders/new/carrier (S07) quotes the draft as soon as it opens, lists the options with their price and delivery time, adds the chosen one to the final total and confirms: create the order, open its shipment and dispatch it with the chosen carrier, then back to the listing. - The quote shows a loading state while the couriers answer, tells an empty answer (no carrier made it in time) apart from a failure, and offers both to quote again and to go back to origin and destination. - The cheapest option, which comes first, is flagged. S07 also draws "Alta confiabilidad" and "Seguimiento incluido", but the API carries neither, so they are left out. - The draft is emptied as soon as the order exists, so a reload after a failed dispatch cannot create the same sale twice. The screen keeps showing a copy of the draft taken when confirming, and the button becomes "Reintentar el despacho", which resumes from the dispatch. Co-Authored-By: Claude Opus 5.5 --- src/app/router/routes.tsx | 8 + .../OrderConfirmCard.styles.ts | 31 ++ .../OrderConfirmCard/OrderConfirmCard.tsx | 117 ++++++ .../OrderConfirmCard.types.ts | 24 ++ .../components/OrderConfirmCard/index.ts | 2 + .../QuoteOptionList/QuoteOptionList.styles.ts | 59 +++ .../QuoteOptionList/QuoteOptionList.tsx | 102 +++++ .../QuoteOptionList/QuoteOptionList.types.ts | 11 + .../components/QuoteOptionList/index.ts | 2 + src/features/orders/content.ts | 47 +++ src/features/orders/index.ts | 1 + .../orders/pages/CarrierStepPage.test.tsx | 362 ++++++++++++++++++ src/features/orders/pages/CarrierStepPage.tsx | 251 ++++++++++++ 13 files changed, 1017 insertions(+) create mode 100644 src/features/orders/components/OrderConfirmCard/OrderConfirmCard.styles.ts create mode 100644 src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx create mode 100644 src/features/orders/components/OrderConfirmCard/OrderConfirmCard.types.ts create mode 100644 src/features/orders/components/OrderConfirmCard/index.ts create mode 100644 src/features/orders/components/QuoteOptionList/QuoteOptionList.styles.ts create mode 100644 src/features/orders/components/QuoteOptionList/QuoteOptionList.tsx create mode 100644 src/features/orders/components/QuoteOptionList/QuoteOptionList.types.ts create mode 100644 src/features/orders/components/QuoteOptionList/index.ts create mode 100644 src/features/orders/pages/CarrierStepPage.test.tsx create mode 100644 src/features/orders/pages/CarrierStepPage.tsx diff --git a/src/app/router/routes.tsx b/src/app/router/routes.tsx index 9025eb3..4225bcb 100644 --- a/src/app/router/routes.tsx +++ b/src/app/router/routes.tsx @@ -27,6 +27,9 @@ const NewOrderPage = lazy(() => const ShippingStepPage = lazy(() => import('features/orders').then((m) => ({ default: m.ShippingStepPage })), ) +const CarrierStepPage = lazy(() => + import('features/orders').then((m) => ({ default: m.CarrierStepPage })), +) const OrderEditPage = lazy(() => import('features/orders').then((m) => ({ default: m.OrderEditPage })), ) @@ -106,6 +109,11 @@ export const appRoutes: AppRoute[] = [ element: , // Paso 2 del alta manual: origen y destino. Se llega desde el paso 1. }, + { + path: '/orders/new/carrier', + element: , + // Paso 3 del alta manual: cotización y confirmación. Se llega desde el paso 2. + }, { path: '/orders/edit/:orderId', element: , diff --git a/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.styles.ts b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.styles.ts new file mode 100644 index 0000000..c9ca084 --- /dev/null +++ b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.styles.ts @@ -0,0 +1,31 @@ +import { Box, Card, Typography } from '@mui/material' +import { styled } from '@mui/material/styles' +import type { ElementType } from 'react' + +// Ver `InfoPanel.styles.ts`: `component` en un `styled(Card)` necesita el tipo. +interface AsProp { + component?: ElementType +} + +export const ConfirmCardRoot = styled(Card)(({ theme }) => ({ + padding: theme.spacing(3), + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), +})) + +export const SummaryLine = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'baseline', + gap: theme.spacing(2), + '& > :last-child': { marginLeft: 'auto', textAlign: 'right' }, +})) + +// El total final de S07: el número más grande de la pantalla, en el color de +// acción y en la familia de los datos, como el subtotal del paso 1. +export const TotalValue = styled(Typography)(({ theme }) => ({ + ...theme.typography.displaySm, + fontFamily: theme.typography.dataMono.fontFamily, + color: theme.palette.primary.main, + whiteSpace: 'nowrap', +})) diff --git a/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx new file mode 100644 index 0000000..9c86943 --- /dev/null +++ b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.tsx @@ -0,0 +1,117 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' +import { Alert, Button, Divider, Stack, Typography } from '@mui/material' +import { useId } from 'react' + +import { ordersCopy } from '../../content' +import { formatMoney, formatWeight } from '../../utils/format' +import { totalWithShipping } from '../../utils/shipping' + +import { ConfirmCardRoot, SummaryLine, TotalValue } from './OrderConfirmCard.styles' +import type { OrderConfirmCardProps } from './OrderConfirmCard.types' + +const { summary: copy } = ordersCopy.carrier + +/** + * «Resumen de la orden» del paso 3 (S07): lo que se va a crear, el envío + * elegido, el total final y los dos botones del asistente. + * + * El total suma el envío apenas se elige una opción, que es lo que pide la card + * («el costo de envío se suma al total general de forma transparente»). Sin + * opción elegida el envío no cuesta 0: dice que falta elegirlo, y el total es + * lo que se sabe. + */ +export function OrderConfirmCard({ + subtotal, + shippingCost, + weight, + originName, + destinationLabel, + canConfirm, + confirming, + confirmLabel, + onConfirm, + onBack, + status, +}: OrderConfirmCardProps) { + const titleId = useId() + + return ( + + + {copy.title} + + + + + + + + + + + + + + + {copy.total} + + + + {formatMoney(totalWithShipping(subtotal, shippingCost))} + + + {copy.currency} + + + + + {status} + + + {onBack === undefined ? null : ( + + )} + + + {copy.notice} + + + ) +} + +function Line({ label, value, muted = false }: { label: string; value: string; muted?: boolean }) { + return ( + + {/* El rótulo no se parte: el que cede espacio es el valor, que puede ser + largo (el destino completo). */} + + {label} + + + {value} + + + ) +} diff --git a/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.types.ts b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.types.ts new file mode 100644 index 0000000..f546886 --- /dev/null +++ b/src/features/orders/components/OrderConfirmCard/OrderConfirmCard.types.ts @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react' + +export interface OrderConfirmCardProps { + /** Lo que suman los productos del borrador. */ + subtotal: number + /** El costo de la opción elegida; null mientras no se eligió ninguna. */ + shippingCost: number | null + /** Peso estimado del paquete, en kg. */ + weight: number + /** De dónde sale: el nombre del depósito de origen. */ + originName: string + /** A dónde va: "CABA, Ciudad Autónoma de Buenos Aires · CP 1193". */ + destinationLabel: string + /** Si «Confirmar orden» se puede apretar: hay una opción elegida y no se está confirmando. */ + canConfirm: boolean + confirming: boolean + /** El rótulo del botón principal: «Confirmar orden», o reintentar el despacho. */ + confirmLabel: string + onConfirm: () => void + /** Ausente cuando ya no se puede volver: la orden existe. */ + onBack?: () => void + /** Lo que salió mal al confirmar, arriba de los botones. */ + status?: ReactNode +} diff --git a/src/features/orders/components/OrderConfirmCard/index.ts b/src/features/orders/components/OrderConfirmCard/index.ts new file mode 100644 index 0000000..9f03f26 --- /dev/null +++ b/src/features/orders/components/OrderConfirmCard/index.ts @@ -0,0 +1,2 @@ +export { OrderConfirmCard } from './OrderConfirmCard' +export type { OrderConfirmCardProps } from './OrderConfirmCard.types' diff --git a/src/features/orders/components/QuoteOptionList/QuoteOptionList.styles.ts b/src/features/orders/components/QuoteOptionList/QuoteOptionList.styles.ts new file mode 100644 index 0000000..7d1028f --- /dev/null +++ b/src/features/orders/components/QuoteOptionList/QuoteOptionList.styles.ts @@ -0,0 +1,59 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +// Una opción por fila, como en S07: se comparan de arriba a abajo por precio. +export const OptionsColumn = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), +})) + +export const OptionRow = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + [theme.breakpoints.down('sm')]: { flexWrap: 'wrap' }, +})) + +// El recuadro del logo del diseño. No hay logos de los operadores en el +// proyecto, así que lleva el nombre en mayúsculas, que es lo que dibuja S07. +// Repite el nombre que va al lado, así que es lo primero que cede cuando la +// columna se angosta (con el resumen al costado, por debajo de `lg`). +export const CarrierMark = styled(Box)(({ theme }) => ({ + [theme.breakpoints.down('lg')]: { display: 'none' }, + width: 88, + height: 48, + flex: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: theme.spacing(0, 1), + borderRadius: 8, + backgroundColor: theme.palette.action.hover, + textAlign: 'center', +})) + +export const CarrierText = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + minWidth: 0, + flex: '1 1 auto', +})) + +export const PriceColumn = styled(Box)({ + marginLeft: 'auto', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', +}) + +// El precio de S07: grande y en la familia de los datos, como los totales del +// paso 1 (`DraftSummaryCard`). Un `span` y no un `Typography`: vive dentro del +// ` + )} + + + ) : null + + return ( + + + + + + } + title={carrier.options.groupLabel} + > + setSelectedId(quote.dispatchIntegrationId)} + onReview={() => void navigate(SHIPPING_STEP_PATH)} + /> + + + void navigate(SHIPPING_STEP_PATH) : undefined} + status={status} + /> + + + + ) +} + +interface QuotesContentProps { + quotes: ReturnType + 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 ( + + ) +} From 86dd119f70d7ece0554b14ca8a4f20d03f2817f4 Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Thu, 24 Sep 2026 22:14:01 -0300 Subject: [PATCH 4/6] docs: [TESIS-59] document the pieces of the wizard step 3 The orders row gains the third step, utils/shipping.ts lists the builders of the quote, the order and the dispatch, and the table gets the new components and hooks with the why of each: the quote key does not hang from `orders`, and the confirmation resumes instead of creating the order again. orderDraftStore says when the draft is emptied on confirm. Co-Authored-By: Claude Opus 5.5 --- docs/guidelines/architecture.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 891ef80..134d49f 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -260,7 +260,7 @@ const { slug, config, setConfig } = useTenantStore() - `authStore` — persiste **sólo** token y email (clave `'auth-store'`). `user` (id + `companyId`) se **deriva del JWT** al rehidratar, así no puede quedar desincronizado, y un token vencido o corrupto se descarta antes de arrancar. Expone `getAuthToken()` y `clearSession()` para consumidores fuera de React, como el interceptor HTTP. - `tenantStore` — `slug` (resuelto del host, no lo cambia la app) y `config` del tenant (clave `'tenant-store'`). La config persistida se restaura **sólo** si es del mismo slug, y se valida con el mismo schema que la respuesta del backend. Expone `useTenantName()` y `useTenantFeature(feature)` como selectores, y `setTenantConfig()` para escribirla desde afuera de React. - `notificationStore` — cola de notificaciones con `notify(mensaje, severidad)`, también invocable fuera de React. La renderiza `NotificationHost`, montado una vez en los providers. -- `orderDraftStore` — borrador del alta manual de una orden (cliente, líneas, depósito de origen y domicilio de entrega), compartido por los tres pasos del asistente (`/orders/new`, S05 → S07). Persiste en **`sessionStorage`** (clave `'order-draft-store'`): un reload entre pasos no pierde lo cargado, pero un borrador a medias no reaparece días después en otra pestaña. `clearDraft()` al cancelar o confirmar. +- `orderDraftStore` — borrador del alta manual de una orden (cliente, líneas, depósito de origen y domicilio de entrega), compartido por los tres pasos del asistente (`/orders/new`, S05 → S07). Persiste en **`sessionStorage`** (clave `'order-draft-store'`): un reload entre pasos no pierde lo cargado, pero un borrador a medias no reaparece días después en otra pestaña. `clearDraft()` al cancelar, y al confirmar apenas la orden existe —antes del despacho—: así un reload después de un despacho fallido no puede crear la misma venta dos veces. **Conteos sobre listados paginados** (`shared/api/count.ts`): @@ -311,7 +311,7 @@ features/[nombre]/ | `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 pasos 1 y 2 del alta manual (`/orders/new`: cliente y productos; `/orders/new/shipping`: origen y destino; el paso 3 es TESIS-59) | +| `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; `/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é:** @@ -325,8 +325,13 @@ features/[nombre]/ | `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 paso 2: qué depósito cubre el borrador entero (`warehouseCoverage`) y los dos requests que hará el paso 3 con lo elegido (`toCreateOrderPayload`, `toQuotePayload`). La orden sale de un solo depósito: el `warehouse_id` es el mismo en todas las líneas | +| `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 | | `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` | From 01286e188b235325b1940e2a6578b1adbf934865 Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Fri, 25 Sep 2026 20:41:56 -0300 Subject: [PATCH 5/6] fix: [TESIS-59] round the quoted cost to the cents the shipment keeps A courier can answer with more than two decimals. The screen rounded the binary approximation (1.005 showed as 1.00) while the shipment keeps the decimal rounded half up (1.01), so the total of step 3 and the one of the order detail could differ by a cent. Co-Authored-By: Claude Opus 5.5 --- src/features/orders/api.test.ts | 39 +++++++++++++++++++++++++++++++++ src/features/orders/api.ts | 15 ++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts index 60f229c..d4d9883 100644 --- a/src/features/orders/api.test.ts +++ b/src/features/orders/api.test.ts @@ -292,6 +292,45 @@ describe('quoteDraft', () => { }) }) +// Lo que elige el operador viaja al despacho y queda en `decimal(10,2)`: la +// pantalla tiene que mostrar lo mismo que después guarda el envío. +describe('the cost of a quote with more than two decimals', () => { + async function quotedCost(cost: string | number) { + vi.spyOn(client, 'post').mockResolvedValueOnce( + respond({ + data: [ + { + company_integration_id: 7, + dispatch_integration_id: 4, + provider_name: 'Andreani', + shipping_cost: cost, + estimated_days: null, + }, + ], + }), + ) + const [quote] = await quoteDraft({ + quote: { + origin_warehouse_id: 3, + destination_zip_code: '1193', + destination_address: 'Av. Corrientes 3247', + items: [{ product_id: 12, quantity: 1 }], + }, + }) + return quote?.shippingCost + } + + it.each([ + ['1.005', 1.01], + ['41200.555', 41200.56], + ['41200.5', 41200.5], + ['99.994', 99.99], + [2500.125, 2500.13], + ])('rounds %s to the cents the shipment keeps', async (cost, expected) => { + expect(await quotedCost(cost)).toBe(expected) + }) +}) + describe('the confirmation of a manual order', () => { it('creates the order with the payload of the wizard', async () => { const post = vi diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts index f02cc65..bad6678 100644 --- a/src/features/orders/api.ts +++ b/src/features/orders/api.ts @@ -376,12 +376,25 @@ export async function fetchProvinces(): Promise { return data.data } +/** + * El costo cotizado, redondeado a centavos como lo va a guardar el envío + * (`decimal(10,2)`, que redondea la mitad hacia arriba). Un courier puede + * contestar con más decimales, y el costo elegido viaja al despacho: sin esto + * la pantalla mostraba un total y el detalle de la orden otro. Con + * `"1.005"`, `Math.round(1.005 * 100)` da 100 —el float es 1.00499…— y el + * backend guarda 1.01. Correr la coma en el texto (`"1.005e2"` es 100.5 + * exacto) redondea el decimal que mandó el backend, no su aproximación binaria. + */ +function toCostInCents(cost: string | number): number { + return Math.round(Number(`${cost}e2`)) / 100 +} + function toShippingQuote(quote: ApiShippingQuote): ShippingQuote { return { quoteIntegrationId: quote.company_integration_id, dispatchIntegrationId: quote.dispatch_integration_id, providerName: quote.provider_name, - shippingCost: Number(quote.shipping_cost), + shippingCost: toCostInCents(quote.shipping_cost), estimatedDays: quote.estimated_days, } } From 147516e81856b033cbfb678418876af69350eb28 Mon Sep 17 00:00:00 2001 From: Santiago Natalichio Bestosini Date: Fri, 25 Sep 2026 20:46:38 -0300 Subject: [PATCH 6/6] fix: [TESIS-59] warn before leaving an order whose dispatch failed Once the order exists the draft is gone, so after a failed dispatch step 3 is the only place its shipment can be dispatched from. The error now says so, and the browser asks before reloading or closing the tab while the dispatch is pending. Co-Authored-By: Claude Opus 5.5 --- docs/guidelines/architecture.md | 2 +- src/features/orders/content.ts | 3 ++ .../orders/pages/CarrierStepPage.test.tsx | 34 +++++++++++++++++++ src/features/orders/pages/CarrierStepPage.tsx | 26 +++++++++++++- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index fd69bfa..923e95f 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -332,7 +332,7 @@ features/[nombre]/ | `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 | +| `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` | diff --git a/src/features/orders/content.ts b/src/features/orders/content.ts index 67cfbc2..befe747 100644 --- a/src/features/orders/content.ts +++ b/src/features/orders/content.ts @@ -326,6 +326,9 @@ 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.', retryDispatch: 'Reintentar el despacho', viewOrder: 'Ver la orden', }, diff --git a/src/features/orders/pages/CarrierStepPage.test.tsx b/src/features/orders/pages/CarrierStepPage.test.tsx index 0e068b2..2015677 100644 --- a/src/features/orders/pages/CarrierStepPage.test.tsx +++ b/src/features/orders/pages/CarrierStepPage.test.tsx @@ -97,6 +97,14 @@ function renderPage() { const option = (name: RegExp) => screen.getByRole('radio', { name }) const confirmButton = () => screen.getByRole('button', { name: /Confirmar orden/ }) + +// Lo que hace el navegador al recargar: si algún listener cancela el evento, +// pregunta antes de salir. +function leaveIsBlocked() { + const event = new Event('beforeunload', { cancelable: true }) + window.dispatchEvent(event) + return event.defaultPrevented +} const total = () => screen.getByLabelText('Total final') function stubConfirmation() { @@ -309,6 +317,32 @@ 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 () => { + failDispatchOnce() + renderPage() + + fireEvent.click(option(/Andreani/)) + fireEvent.click(confirmButton()) + + expect( + await screen.findByText(/si dejás esta pantalla su envío queda sin despachar/), + ).toBeInTheDocument() + }) + + it('asks 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) + }) + it('no longer offers going back to a draft that is already an order', async () => { failDispatchOnce() renderPage() diff --git a/src/features/orders/pages/CarrierStepPage.tsx b/src/features/orders/pages/CarrierStepPage.tsx index 4bc64c2..9ea0c17 100644 --- a/src/features/orders/pages/CarrierStepPage.tsx +++ b/src/features/orders/pages/CarrierStepPage.tsx @@ -1,6 +1,6 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' import { Alert, Box, Button, Stack, Typography } from '@mui/material' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Navigate, useNavigate } from 'react-router-dom' import { LoadingSpinner, PageWrapper } from 'shared/components' import { notify, useOrderDraftStore } from 'shared/store' @@ -54,6 +54,11 @@ interface ConfirmedDraft { * pantalla se recarga, lo que hay que evitar es poder crear la misma venta dos * 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. */ export function CarrierStepPage() { const navigate = useNavigate() @@ -83,6 +88,8 @@ export function CarrierStepPage() { ) const quotes = useDraftQuotes(payload) const confirm = useConfirmDraftOrder({ onOrderCreated: clearDraft }) + const dispatchPending = confirm.createdOrderId !== null && !confirm.isSuccess + useLeaveWarning(dispatchPending) // 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 @@ -125,6 +132,7 @@ export function CarrierStepPage() { {createdOrderId === null ? carrier.errors.order : carrier.errors.dispatch(orderLabel)}{' '} {confirm.error.message} + {createdOrderId === null ? null : {carrier.errors.dispatchPending}} {createdOrderId === null ? null : (