Skip to content
Merged
11 changes: 8 additions & 3 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ const { slug, config, setConfig } = useTenantStore()
- `authStore` — persiste **sólo** el token (clave `'auth-store'`); un token vencido o corrupto se descarta antes de arrancar. `user` es la identidad que devuelve `GET /me` (ver ADR-002) y no se persiste. El logout vacía también lo del usuario que se va: la cache de React Query y el borrador de la orden. `followSessionAcrossTabs()`, registrado en `main.tsx`, mantiene de acuerdo a las pestañas: si otra cierra sesión o entra con otra cuenta, ésta relee la sesión guardada y vacía lo del usuario anterior. 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, al confirmar y al cerrar sesión (el logout lo vacía junto con la cache de React Query).
- `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, al cerrar sesión (el logout lo vacía junto con la cache de React Query) 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`):

Expand Down Expand Up @@ -314,7 +314,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é:**
Expand All @@ -328,8 +328,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. 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` |
Expand Down
8 changes: 8 additions & 0 deletions src/app/router/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
)
Expand Down Expand Up @@ -106,6 +109,11 @@ export const appRoutes: AppRoute[] = [
element: <ShippingStepPage />,
// Paso 2 del alta manual: origen y destino. Se llega desde el paso 1.
},
{
path: '/orders/new/carrier',
element: <CarrierStepPage />,
// Paso 3 del alta manual: cotización y confirmación. Se llega desde el paso 2.
},
{
path: '/orders/edit/:orderId',
element: <OrderEditPage />,
Expand Down
134 changes: 134 additions & 0 deletions src/features/orders/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -241,3 +245,133 @@ 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,
},
])
})
})

// 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
.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')
})
})
Loading
Loading