Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,13 +363,16 @@ features/[nombre]/

**`inventory` — piezas y por qué:**

| Archivo | Rol |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `components/EditProductModal/` | Modal de edición: datos básicos, medidas y asignación de stock. **Presentacional** — recibe `product` y `warehouses` ya resueltos y devuelve en `onSubmit` el cuerpo de `PUT /api/v1/products/:id`. Quien lo monta decide de dónde salen los datos |
| `components/CreateProductModal/` | Modal de alta, mismo contrato: devuelve el cuerpo de `POST /api/v1/products`. Valida depósitos repetidos en el schema y resalta el campo `sku` cuando la API rechaza el alta por SKU duplicado |
| `api.ts` | Frontera con Rails y único lugar que conoce el `snake_case`. También documenta que el index envuelve en `{ data, meta }` y usa `ProductListSerializer` (sin `stocks`), mientras `show`/`update` devuelven el objeto pelado |
| `utils/dimensions.ts` | `products.dimensions` es un único `string` en la API y el diseño lo edita en tres ejes. Define el formato canónico (`"45x30x30"`, cm) y es el único lugar que lo conoce |
| `utils/payload.ts` | Traduce el formulario al cuerpo de la API. Funciones puras, aparte del componente, porque concentran las reglas no obvias (ver abajo) y así se pueden verificar solas |
| Archivo | Rol |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `components/EditProductModal/` | Modal de edición: datos básicos, medidas y asignación de stock. **Presentacional** — recibe `product` y `warehouses` ya resueltos y devuelve en `onSubmit` el cuerpo de `PUT /api/v1/products/:id`. Quien lo monta decide de dónde salen los datos |
| `components/CreateProductModal/` | Modal de alta, mismo contrato: devuelve el cuerpo de `POST /api/v1/products`. Valida depósitos repetidos en el schema y resalta el campo `sku` cuando la API rechaza el alta por SKU duplicado |
| `components/TransferStockModal/` | Modal de transferencia entre depósitos. **Presentacional**, mismo contrato que los de producto: devuelve el cuerpo de `POST /api/v1/stock-transfers`. El origen sólo ofrece depósitos con unidades y el tope de cantidad sale del origen elegido (el schema se arma con esas cantidades) |
| `components/ProductTransfers/` | Las transferencias en vuelo del detalle (S12, sin maqueta), con su alta, recepción y cancelación. Monta el modal y un `ConfirmDialog`. Recibir y cancelar invalidan el dominio también al fallar: un 409 es una transferencia que otro ya liquidó |
| `utils/stock.ts` | Une las filas de `stocks` con el entrante por depósito (`distributionPositions`). No calcula estados: el `stock_status` de cada fila lo manda el backend |
| `api.ts` | Frontera con Rails y único lugar que conoce el `snake_case`. También documenta que el index envuelve en `{ data, meta }` y usa `ProductListSerializer` (sin `stocks`), mientras `show`/`update` devuelven el objeto pelado |
| `utils/dimensions.ts` | `products.dimensions` es un único `string` en la API y el diseño lo edita en tres ejes. Define el formato canónico (`"45x30x30"`, cm) y es el único lugar que lo conoce |
| `utils/payload.ts` | Traduce el formulario al cuerpo de la API. Funciones puras, aparte del componente, porque concentran las reglas no obvias (ver abajo) y así se pueden verificar solas |

> El diálogo en sí se tematizó en `app/theme/components/dialog.ts` (`MuiDialog` en el nivel 3 de la escala de elevación + `MuiBackdrop` con scrim y blur), no dentro del modal: es un componente base de MUI y sus estilos van al tema — ver `component-structure.md` §3.1.

Expand Down
58 changes: 58 additions & 0 deletions src/features/inventory/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import { client } from 'shared/api/client'

import type {
CreateProductPayload,
CreateTransferPayload,
Product,
ProductFilters,
ProductPage,
ProductSummary,
StockStatus,
StockTransfer,
TransferOutcome,
TransferStatus,
UpdateProductPayload,
Warehouse,
} from './types'
Expand Down Expand Up @@ -223,3 +227,57 @@ export async function updateProduct(
export async function deleteProduct(id: number): Promise<void> {
await client.delete(`/products/${id}`)
}

interface ApiStockTransfer {
id: number
quantity: number
status: TransferStatus
dispatched_at: string
origin_warehouse: { id: number; name: string }
destination_warehouse: { id: number; name: string }
}

function toTransfer(transfer: ApiStockTransfer): StockTransfer {
return {
id: transfer.id,
quantity: transfer.quantity,
status: transfer.status,
dispatchedAt: transfer.dispatched_at,
origin: transfer.origin_warehouse,
destination: transfer.destination_warehouse,
}
}

/**
* Transferencias de un producto que todavía están en vuelo.
*
* `per_page` al techo de la API (100): la tarjeta del detalle no pagina, y un
* producto con más de cien transferencias abiertas a la vez no es un caso del
* negocio. Si pasara, `meta.total` lo diría; acá no se muestra.
*/
export async function fetchTransfers(productId: number): Promise<StockTransfer[]> {
const { data } = await client.get<ApiList<ApiStockTransfer>>('/stock-transfers', {
params: { product_id: productId, status: 'in_transit', per_page: 100 },
})

return data.data.map(toTransfer)
}

/**
* Despacha una transferencia: el backend descuenta el origen en la misma
* operación. Responde **422** si el origen no tiene esas unidades (alguien las
* movió mientras el modal estaba abierto) y **409** si otra operación tiene
* tomado el stock del producto en ese momento.
*/
export async function createTransfer(payload: CreateTransferPayload): Promise<StockTransfer> {
const { data } = await client.post<ApiStockTransfer>('/stock-transfers', payload)

return toTransfer(data)
}

/** Recibe o cancela. Responde **409** si la transferencia ya se liquidó. */
export async function settleTransfer(id: number, outcome: TransferOutcome): Promise<StockTransfer> {
const { data } = await client.post<ApiStockTransfer>(`/stock-transfers/${id}/${outcome}`)

return toTransfer(data)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Box, Card, List, ListItem } from '@mui/material'
import { styled } from '@mui/material/styles'

export const TransfersCard = styled(Card)({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})

// Mismo alto y separador que la cabecera de la distribución por depósito.
export const CardHeader = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: theme.spacing(2),
padding: theme.spacing(1, 2),
minHeight: 48,
borderBottom: `1px solid ${theme.vars.palette.divider}`,
}))

export const HeaderText = styled(Box)({
display: 'flex',
flexDirection: 'column',
flex: '1 1 240px',
minWidth: 0,
})

export const TransferList = styled(List)({
padding: 0,
})

export const TransferRow = styled(ListItem)(({ theme }) => ({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: theme.spacing(2),
padding: theme.spacing(1.5, 2),
borderBottom: `1px solid ${theme.vars.palette.divider}`,
'&:last-of-type': { borderBottom: 'none' },
}))

export const TransferText = styled(Box)({
display: 'flex',
flexDirection: 'column',
flex: '1 1 220px',
minWidth: 0,
})

export const TransferActions = styled(Box)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
marginLeft: 'auto',
}))

export const EmptyState = styled(Box)(({ theme }) => ({
padding: theme.spacing(3, 2),
}))
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { fireEvent, screen, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { renderWithTheme } from '../../../../test/renderWithTheme'
import type * as inventoryHooks from '../../hooks/useInventory'
import { useCreateTransfer, useProductTransfers, useSettleTransfer } from '../../hooks/useInventory'
import type { Product, StockTransfer, Warehouse } from '../../types'

import { ProductTransfers } from './ProductTransfers'

vi.mock('../../hooks/useInventory', async (importOriginal) => ({
...(await importOriginal<typeof inventoryHooks>()),
useProductTransfers: vi.fn(),
useCreateTransfer: vi.fn(),
useSettleTransfer: vi.fn(),
}))

const CENTRAL: Warehouse = { id: 1, name: 'Central', address: 'Av. 7' }
const SUR: Warehouse = { id: 3, name: 'Sur', address: 'Av. Colón' }

const WAREHOUSES: Warehouse[] = [CENTRAL, SUR]

const PRODUCT: Product = {
id: 7,
sku: 'NOR-003',
name: 'Mouse',
description: null,
category: null,
weight: 0.1,
dimensions: null,
stocks: [{ warehouseId: 1, quantity: 100, warehouse: CENTRAL, stockStatus: 'low' }],
totalStock: 100,
stockStatus: 'low',
inTransitQuantity: 4,
inTransitByWarehouse: [{ warehouseId: 3, name: 'Sur', quantity: 4 }],
updatedAt: '2026-09-01T10:00:00Z',
version: null,
}

const TRANSFER: StockTransfer = {
id: 55,
quantity: 4,
status: 'in_transit',
dispatchedAt: '2026-09-30T10:00:00Z',
origin: { id: 1, name: 'Central' },
destination: { id: 3, name: 'Sur' },
}

const settle = vi.fn()

function listTransfers(data: StockTransfer[]) {
vi.mocked(useProductTransfers).mockReturnValue({
data,
isPending: false,
isError: false,
} as never)
}

beforeEach(() => {
settle.mockReset()
listTransfers([TRANSFER])
vi.mocked(useCreateTransfer).mockReturnValue({
mutate: vi.fn(),
reset: vi.fn(),
isPending: false,
isError: false,
error: null,
} as never)
vi.mocked(useSettleTransfer).mockReturnValue({ mutate: settle, isPending: false } as never)
})

const renderSection = (product: Product = PRODUCT, warehouses: Warehouse[] = WAREHOUSES) =>
renderWithTheme(<ProductTransfers product={product} warehouses={warehouses} />)

const transferRow = () =>
within(screen.getByRole('list', { name: 'Transferencias en curso' })).getByRole('listitem')

describe('ProductTransfers', () => {
it('lists the transfers in flight with their route and units', () => {
renderSection()

expect(transferRow()).toHaveTextContent('Central → Sur')
expect(transferRow()).toHaveTextContent('4 unidades')
})

it('says so when nothing is in flight', () => {
listTransfers([])
renderSection()

expect(screen.getByText('No hay transferencias en curso para este producto.')).toBeVisible()
})

it('asks before receiving and then receives', () => {
renderSection()

fireEvent.click(within(transferRow()).getByRole('button', { name: 'Recibir' }))
expect(screen.getByText('Se van a sumar 4 unidades al stock de Sur.')).toBeInTheDocument()
fireEvent.click(
within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Recibir' }),
)

expect(settle).toHaveBeenCalledWith({ id: 55, outcome: 'receive' }, expect.anything())
})

it('asks before cancelling, as a destructive action', () => {
renderSection()

fireEvent.click(within(transferRow()).getByRole('button', { name: 'Cancelar' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancelar transferencia' }))

expect(settle).toHaveBeenCalledWith({ id: 55, outcome: 'cancel' }, expect.anything())
})

it('opens the transfer form', () => {
renderSection()

fireEvent.click(screen.getByRole('button', { name: 'Transferir stock' }))

expect(screen.getByRole('combobox', { name: 'Depósito de origen' })).toBeInTheDocument()
})

it('turns the transfer off when there is no other warehouse', () => {
renderSection(PRODUCT, [CENTRAL])

expect(screen.getByRole('button', { name: 'Transferir stock' })).toBeDisabled()
})

it('turns the transfer off when no warehouse holds units', () => {
renderSection({ ...PRODUCT, stocks: [] })

expect(screen.getByRole('button', { name: 'Transferir stock' })).toBeDisabled()
})
})
Loading