diff --git a/src/features/inventory/api.ts b/src/features/inventory/api.ts index e714041..f6230c8 100644 --- a/src/features/inventory/api.ts +++ b/src/features/inventory/api.ts @@ -38,6 +38,7 @@ interface ApiStock { quantity: number warehouse_id: number warehouse: ApiWarehouse + stock_status: StockStatus } interface ApiProductSummary { @@ -57,8 +58,13 @@ interface ApiProduct { sku: string name: string description: string | null + category: string | null weight: number dimensions: string | null + total_stock: number + stock_status: StockStatus + in_transit_quantity: number + in_transit_by_warehouse: { warehouse_id: number; name: string; quantity: number }[] updated_at: string stocks: ApiStock[] } @@ -84,14 +90,24 @@ function toProduct(product: ApiProduct, version: string | null = null): Product sku: product.sku, name: product.name, description: product.description, + category: (product.category ?? null) as Product['category'], weight: product.weight, dimensions: product.dimensions, + totalStock: product.total_stock, + stockStatus: product.stock_status, + inTransitQuantity: product.in_transit_quantity, + inTransitByWarehouse: (product.in_transit_by_warehouse ?? []).map((transit) => ({ + warehouseId: transit.warehouse_id, + name: transit.name, + quantity: transit.quantity, + })), updatedAt: product.updated_at, version, stocks: (product.stocks ?? []).map((stock) => ({ warehouseId: stock.warehouse_id, quantity: stock.quantity, warehouse: toWarehouse(stock.warehouse), + stockStatus: stock.stock_status, })), } } diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx index 0a19cc5..01fc1fe 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx +++ b/src/features/inventory/components/EditProductModal/EditProductModal.test.tsx @@ -14,11 +14,17 @@ function product(overrides: Partial = {}): Product { description: 'Rollo de 305 metros', weight: 12.4, dimensions: '45x30x20', + category: null, + totalStock: 10, + stockStatus: 'low', + inTransitQuantity: 0, + inTransitByWarehouse: [], stocks: [ { warehouseId: 1, quantity: 10, warehouse: { id: 1, name: 'CD Ezeiza', address: 'Autopista Riccheri km 33' }, + stockStatus: 'low', }, ], updatedAt: '2026-08-30T12:00:00.000Z', diff --git a/src/features/inventory/content.ts b/src/features/inventory/content.ts index e6e5399..17eff29 100644 --- a/src/features/inventory/content.ts +++ b/src/features/inventory/content.ts @@ -99,7 +99,7 @@ export const inventoryCopy = { `${length} × ${width} × ${height} cm`, /** Marca de "sin dato" del DS, la misma que usa el dashboard. */ unknown: '—', - pendingBackend: 'Categoría, empaque y norma técnica todavía no existen en la API.', + pendingBackend: 'Empaque y norma técnica todavía no existen en la API.', }, master: { title: 'Stock maestro', @@ -114,9 +114,10 @@ export const inventoryCopy = { }, // Cubre las dos piezas del diseño que la API no puede alimentar: la barra // de capacidad ("71 % del umbral máximo de 6.000 unidades"), que necesita - // un techo por producto, y el desglose por estado de reserva. + // un techo por producto, y el desglose por reserva. Y aclara que el en + // tránsito, a diferencia del diseño, no es una porción del total. pending: - 'El porcentaje de capacidad y el desglose por reserva esperan datos que la API todavía no expone.', + 'El en tránsito no forma parte del total. La capacidad y las reservas esperan datos que la API todavía no expone.', edit: 'Editar stock', }, distribution: { @@ -131,16 +132,14 @@ export const inventoryCopy = { onHand: 'En depósito', status: 'Estado', }, + // Cuenta también los depósitos que sólo esperan unidades en camino. footer: (count: number) => - count === 1 ? '1 depósito con stock asignado' : `${count} depósitos con stock asignado`, + count === 1 + ? '1 depósito con stock o en camino' + : `${count} depósitos con stock o en camino`, empty: 'Este producto no tiene stock asignado en ningún depósito.', - pending: 'Comprometido y en tránsito esperan que la API los modele.', - }, - status: { - available: 'Disponible', - low: 'Stock bajo', - critical: 'Crítico', - out: 'Sin stock', + pending: + 'En tránsito son las unidades que vienen hacia cada depósito. Comprometido espera que la API modele las reservas.', }, notFound: 'No encontramos el producto que buscabas.', backToCatalog: 'Volver al catálogo', diff --git a/src/features/inventory/pages/ProductDetailPage.test.tsx b/src/features/inventory/pages/ProductDetailPage.test.tsx index 4ece89f..da828d7 100644 --- a/src/features/inventory/pages/ProductDetailPage.test.tsx +++ b/src/features/inventory/pages/ProductDetailPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -21,13 +21,67 @@ const PRODUCT: Product = { sku: 'NOR-007', name: 'Cable UTP Cat6 100m', description: null, + category: null, weight: 4.5, dimensions: null, stocks: [], + totalStock: 0, + stockStatus: 'out_of_stock', + inTransitQuantity: 0, + inTransitByWarehouse: [], updatedAt: '2026-09-01T10:00:00Z', version: 'W/"1"', } +// NOR-003 de los seeds: 100 en Central + 30 en Satélite = 130. El catálogo lo +// muestra «Disponible» (130 > 100); el detalle lo mostraba «Crítico» porque +// calculaba el badge con umbrales propios (≤ 200). +const SEEDED_MOUSE: Product = { + ...PRODUCT, + sku: 'NOR-003', + name: 'Mouse Inalámbrico Logitech', + category: 'Electronics', + stocks: [ + { + warehouseId: 1, + quantity: 100, + warehouse: { id: 1, name: 'Depósito Central', address: 'Av. 7 N° 1234' }, + stockStatus: 'low', + }, + { + warehouseId: 2, + quantity: 30, + warehouse: { id: 2, name: 'Depósito Satélite Norte', address: 'Calle 25 N° 456' }, + stockStatus: 'low', + }, + ], + totalStock: 130, + stockStatus: 'available', + inTransitQuantity: 12, + inTransitByWarehouse: [ + { warehouseId: 2, name: 'Depósito Satélite Norte', quantity: 5 }, + { warehouseId: 3, name: 'Depósito Sur', quantity: 7 }, + ], +} + +function showProduct(product: Product) { + vi.mocked(useProduct).mockReturnValue({ + data: product, + isPending: false, + isError: false, + isFetching: false, + refetch: vi.fn(), + } as never) +} + +/** La fila de la tabla de distribución que nombra a un depósito. */ +function distributionRow(warehouse: string) { + const cell = screen.getByText(warehouse) + const row = cell.closest('tr') + if (row === null) throw new Error(`no row for ${warehouse}`) + return within(row) +} + const editForm = () => screen.queryByText(`Editar producto: ${PRODUCT.name}`) // Lo que quedó en `history.state`, que es donde vive `location.state`. Es lo que @@ -104,4 +158,69 @@ describe('ProductDetailPage', () => { await waitFor(() => expect(editForm()).not.toBeInTheDocument()) }) + + describe('the data that comes from the backend', () => { + it('shows the badge the catalog shows for the same product', () => { + showProduct(SEEDED_MOUSE) + renderDetail() + + expect(screen.getAllByText('Disponible').length).toBeGreaterThan(0) + expect(screen.queryByText('Crítico')).not.toBeInTheDocument() + }) + + it('shows the status the backend sent for each warehouse', () => { + showProduct(SEEDED_MOUSE) + renderDetail() + + expect(distributionRow('Depósito Central').getByText('Stock bajo')).toBeInTheDocument() + expect(distributionRow('Depósito Satélite Norte').getByText('Stock bajo')).toBeInTheDocument() + }) + + it('shows the category of the product', () => { + showProduct(SEEDED_MOUSE) + renderDetail() + + expect(screen.getByText('Electronics')).toBeInTheDocument() + }) + + it('shows the no-data mark for a product with no category', () => { + renderDetail() + + expect(screen.getByText('Categoría').parentElement).toHaveTextContent('—') + }) + + it('takes the master total from the API instead of adding the rows', () => { + showProduct({ ...SEEDED_MOUSE, totalStock: 131 }) + renderDetail() + + expect(screen.getByText('131')).toBeInTheDocument() + }) + + // El en tránsito queda fuera del total: 130 sigue siendo 130. + it('shows the units in flight apart from the total', () => { + showProduct(SEEDED_MOUSE) + renderDetail() + + expect( + screen.getByText('En tránsito', { selector: 'p, span' }).parentElement?.parentElement, + ).toHaveTextContent('12') + expect(screen.getByText('130')).toBeInTheDocument() + }) + + it('puts the incoming units on the row of their destination', () => { + showProduct(SEEDED_MOUSE) + renderDetail() + + expect(distributionRow('Depósito Satélite Norte').getByText('5')).toBeInTheDocument() + }) + + // Depósito Sur no tiene fila en `stocks`: la nace al recibir la transferencia. + it('lists a warehouse that only waits for units in flight', () => { + showProduct(SEEDED_MOUSE) + renderDetail() + + expect(distributionRow('Depósito Sur').getByText('7')).toBeInTheDocument() + expect(distributionRow('Depósito Sur').getByText('Sin stock')).toBeInTheDocument() + }) + }) }) diff --git a/src/features/inventory/pages/ProductDetailPage.tsx b/src/features/inventory/pages/ProductDetailPage.tsx index 1e058cc..84b16a5 100644 --- a/src/features/inventory/pages/ProductDetailPage.tsx +++ b/src/features/inventory/pages/ProductDetailPage.tsx @@ -22,10 +22,11 @@ import type { Product, UpdateProductPayload } from '../types' import { describeConflict } from '../utils/conflict' import { parseDimensions } from '../utils/dimensions' import { formatSpecTimestamp, formatUnits, formatWeight } from '../utils/format' -import { STOCK_LEVEL_STATUS, sortByQuantityDesc, stockLevel, totalOnHand } from '../utils/stock' +import { distributionPositions } from '../utils/stock' +import { stockLabel, stockRowTone, stockVariant } from '../utils/stockStatus' const { detail, page } = inventoryCopy -const { specs: specsCopy, master: masterCopy, distribution: distributionCopy, status } = detail +const { specs: specsCopy, master: masterCopy, distribution: distributionCopy } = detail // Ruta del catálogo. Las rutas se registran en `app/router/routes.tsx`, capa que // una feature no puede importar (ver architecture.md §3.2), así que el destino @@ -50,11 +51,13 @@ function buildSpecs(product: Product): ProductSpec[] { const hasDimensions = product.dimensions !== null && product.dimensions !== '' return [ - // `products` no tiene columna de categoría (las cuatro de Product::CATEGORIES - // viven en el backend pero no llegan en el serializer), ni empaque, ni norma - // técnica. Se muestran los rótulos del diseño sin valor, en vez de omitir las - // celdas: así la ficha dice qué falta en lugar de disimularlo. - { id: 'category', label: specsCopy.fields.category, value: specsCopy.unknown, unknown: true }, + // La categoría es opcional: los productos anteriores a TESIS-102 no tienen. + { + id: 'category', + label: specsCopy.fields.category, + value: product.category ?? specsCopy.unknown, + unknown: product.category === null, + }, { id: 'weight', label: specsCopy.fields.weight, @@ -74,6 +77,9 @@ function buildSpecs(product: Product): ProductSpec[] { mono: true, unknown: !hasDimensions, }, + // Ni empaque ni norma técnica existen en el modelo. Se muestran los rótulos + // del diseño sin valor, en vez de omitir las celdas: así la ficha dice qué + // falta en lugar de disimularlo. { id: 'packaging', label: specsCopy.fields.packaging, value: specsCopy.unknown, unknown: true }, ] } @@ -94,13 +100,14 @@ function buildSecondarySpecs(product: Product): ProductSpec[] { } /** - * Desglose por estado de reserva. + * Desglose del stock maestro. * - * Las tres cubetas van sin dato: la API devuelve un solo número por depósito - * (las unidades en depósito) y no modela reservas ni mercadería en tránsito. - * Ver el encabezado de `utils/stock.ts`. + * Comprometido y disponible para prometer van sin dato: la API no modela + * reservas. El en tránsito sí existe, pero **no es una porción del total**: + * son unidades que salieron de un depósito y no llegaron a otro, y el backend + * las deja fuera de `total_stock`. Ver el encabezado de `utils/stock.ts`. */ -function buildBuckets(): StockBucket[] { +function buildBuckets(product: Product): StockBucket[] { return [ { id: 'committed', @@ -112,9 +119,8 @@ function buildBuckets(): StockBucket[] { { id: 'inTransit', label: masterCopy.buckets.inTransit, - icon: , - value: specsCopy.unknown, - unknown: true, + icon: , + value: formatUnits(product.inTransitQuantity), }, { id: 'availableToPromise', @@ -127,23 +133,22 @@ function buildBuckets(): StockBucket[] { ] } -/** Filas de la tabla, de mayor a menor cantidad como en el diseño. */ +/** + * Filas de la tabla, de mayor a menor cantidad como en el diseño. El estado de + * cada fila lo manda el backend; el resalte sigue el criterio del catálogo. + */ function buildRows(product: Product): WarehouseDistributionRow[] { - return sortByQuantityDesc(product.stocks).map((stock) => { - const level = stockLevel(stock.quantity) - - return { - id: stock.warehouseId, - name: stock.warehouse.name, - location: stock.warehouse.address, - committed: specsCopy.unknown, - inTransit: specsCopy.unknown, - onHand: formatUnits(stock.quantity), - statusLabel: status[level], - statusVariant: STOCK_LEVEL_STATUS[level], - critical: level === 'critical' || level === 'out', - } - }) + return distributionPositions(product).map((position) => ({ + id: position.warehouseId, + name: position.name, + location: position.location ?? specsCopy.unknown, + committed: specsCopy.unknown, + inTransit: formatUnits(position.incoming), + onHand: formatUnits(position.quantity), + statusLabel: stockLabel(position.stockStatus), + statusVariant: stockVariant(position.stockStatus), + critical: stockRowTone(position.stockStatus) === 'critical', + })) } /** @@ -247,17 +252,14 @@ export function ProductDetailPage() { ) } - const total = totalOnHand(product.data.stocks) - const level = stockLevel(total) - return ( setEditing(true)} /> @@ -270,9 +272,9 @@ export function ProductDetailPage() { setEditing(true)} /> diff --git a/src/features/inventory/types.ts b/src/features/inventory/types.ts index f7a12b6..ae0a96a 100644 --- a/src/features/inventory/types.ts +++ b/src/features/inventory/types.ts @@ -73,6 +73,22 @@ export interface ProductStock { warehouseId: number quantity: number warehouse: Warehouse + /** + * Disponibilidad de este depósito. La calcula el backend con la misma regla + * que la del producto (`Product.stock_status_for`), aplicada a `quantity`. + */ + stockStatus: StockStatus +} + +/** + * Unidades en vuelo **hacia** un depósito: salieron de otro y todavía no + * llegaron. El saliente no aparece porque el backend ya lo descontó del + * depósito de origen al despachar. + */ +export interface IncomingTransit { + warehouseId: number + name: string + quantity: number } /** @@ -88,7 +104,21 @@ export interface Product { description: string | null weight: number dimensions: string | null + /** Opcional: los productos anteriores a TESIS-102 no tienen ninguna. */ + category: ProductCategory | null stocks: ProductStock[] + /** Unidades en depósito sumando todos los depósitos, calculado por la API. */ + totalStock: number + /** Disponibilidad del producto: la misma que muestra el catálogo. */ + stockStatus: StockStatus + /** Unidades en vuelo entre depósitos. **No** están incluidas en `totalStock`. */ + inTransitQuantity: number + /** + * El mismo en tránsito, por depósito de destino. Puede nombrar depósitos que + * todavía no tienen fila en `stocks`: la fila nace cuando la transferencia + * se recibe. La suma de `quantity` es `inTransitQuantity`. + */ + inTransitByWarehouse: IncomingTransit[] updatedAt: string /** * Versión del agregado que devolvió la API en el header `ETag` (TESIS-101). diff --git a/src/features/inventory/utils/conflict.test.ts b/src/features/inventory/utils/conflict.test.ts index bde062a..4b485d9 100644 --- a/src/features/inventory/utils/conflict.test.ts +++ b/src/features/inventory/utils/conflict.test.ts @@ -18,6 +18,7 @@ function stock(warehouseId: number, quantity: number, warehouseName: string): Pr warehouseId, quantity, warehouse: { id: warehouseId, name: warehouseName, address: 'Calle 1' }, + stockStatus: 'low', } } @@ -29,6 +30,11 @@ function product(overrides: Partial = {}): Product { description: 'Rollo de 305 metros', weight: 12.4, dimensions: '45x30x20', + category: null, + totalStock: 10, + stockStatus: 'low', + inTransitQuantity: 0, + inTransitByWarehouse: [], stocks: [stock(1, 10, 'CD Ezeiza')], updatedAt: '2026-08-30T12:00:00.000Z', version: '"abc"', diff --git a/src/features/inventory/utils/payload.test.ts b/src/features/inventory/utils/payload.test.ts index efa363a..46fc47c 100644 --- a/src/features/inventory/utils/payload.test.ts +++ b/src/features/inventory/utils/payload.test.ts @@ -46,11 +46,17 @@ function product(overrides: Partial = {}): Product { description: 'Rollo de 305 metros', weight: 12.4, dimensions: '45x30x20', + category: null, + totalStock: 10, + stockStatus: 'low', + inTransitQuantity: 0, + inTransitByWarehouse: [], stocks: [ { warehouseId: 1, quantity: 10, warehouse: { id: 1, name: 'CD Ezeiza', address: 'Autopista Riccheri km 33' }, + stockStatus: 'low', }, ], updatedAt: '2026-08-30T12:00:00.000Z', @@ -120,6 +126,7 @@ describe('buildUpdatePayload', () => { warehouseId: 2, quantity: 40, warehouse: { id: 2, name: 'CD Córdoba', address: 'Ruta 9 km 695' }, + stockStatus: 'low', }, ], }) diff --git a/src/features/inventory/utils/stock.test.ts b/src/features/inventory/utils/stock.test.ts index e00eb48..5e64a59 100644 --- a/src/features/inventory/utils/stock.test.ts +++ b/src/features/inventory/utils/stock.test.ts @@ -1,84 +1,111 @@ import { describe, expect, it } from 'vitest' -import type { ProductStock } from '../types' +import type { Product, ProductStock } from '../types' -import { - CRITICAL_STOCK_UNITS, - LOW_STOCK_UNITS, - sortByQuantityDesc, - stockLevel, - totalOnHand, -} from './stock' +import { distributionPositions, sortByQuantityDesc } from './stock' function stock(overrides: Partial = {}): ProductStock { return { warehouseId: 1, quantity: 10, warehouse: { id: 1, name: 'CD Ezeiza', address: 'Autopista Riccheri km 33' }, + stockStatus: 'low', ...overrides, } } -describe('stockLevel', () => { - it('is out at zero', () => { - expect(stockLevel(0)).toBe('out') - }) +function product(overrides: Partial = {}): Product { + return { + id: 5, + sku: 'CAB-6-305', + name: 'Cable UTP Cat6', + description: null, + category: null, + weight: 12.4, + dimensions: null, + stocks: [], + totalStock: 0, + stockStatus: 'out_of_stock', + inTransitQuantity: 0, + inTransitByWarehouse: [], + updatedAt: '2026-08-30T12:00:00.000Z', + version: null, + ...overrides, + } +} - it('is out for a negative quantity', () => { - expect(stockLevel(-5)).toBe('out') - }) +const central = stock({ + warehouseId: 1, + quantity: 100, + warehouse: { id: 1, name: 'Central', address: 'Av. 7' }, +}) +const satelite = stock({ + warehouseId: 2, + quantity: 30, + warehouse: { id: 2, name: 'Satélite', address: 'Calle 25' }, +}) - it('is critical just above zero', () => { - expect(stockLevel(1)).toBe('critical') - }) +describe('sortByQuantityDesc', () => { + it('orders the positions from the largest to the smallest quantity', () => { + const stocks = [stock({ quantity: 10 }), stock({ quantity: 40 }), stock({ quantity: 25 })] - // El límite que separa "crítico" de "bajo": la cantidad exacta del umbral - // todavía cuenta como crítico, no como el nivel siguiente. - it('is critical at the critical threshold', () => { - expect(stockLevel(CRITICAL_STOCK_UNITS)).toBe('critical') + expect(sortByQuantityDesc(stocks).map((position) => position.quantity)).toEqual([40, 25, 10]) }) - it('is low just above the critical threshold', () => { - expect(stockLevel(CRITICAL_STOCK_UNITS + 1)).toBe('low') - }) + // El array llega desde la caché de React Query: ordenarlo en el lugar + // reordenaría los datos de cualquier otro consumidor. + it('does not mutate the array it receives', () => { + const stocks = [stock({ quantity: 10 }), stock({ quantity: 40 })] - it('is low at the low threshold', () => { - expect(stockLevel(LOW_STOCK_UNITS)).toBe('low') - }) + sortByQuantityDesc(stocks) - it('is available just above the low threshold', () => { - expect(stockLevel(LOW_STOCK_UNITS + 1)).toBe('available') + expect(stocks.map((position) => position.quantity)).toEqual([10, 40]) }) }) -describe('totalOnHand', () => { - it('is zero for an empty list', () => { - expect(totalOnHand([])).toBe(0) - }) - - it('sums the quantity of every position', () => { - const stocks = [stock({ quantity: 10 }), stock({ warehouseId: 2, quantity: 25 })] +describe('distributionPositions', () => { + it('keeps the status the backend sent for each warehouse', () => { + const positions = distributionPositions( + product({ stocks: [stock({ quantity: 500, stockStatus: 'available' })] }), + ) - expect(totalOnHand(stocks)).toBe(35) + expect(positions[0]?.stockStatus).toBe('available') }) -}) - -describe('sortByQuantityDesc', () => { - it('orders positions from highest to lowest quantity', () => { - const stocks = [stock({ quantity: 10 }), stock({ quantity: 40 }), stock({ quantity: 25 })] - expect(sortByQuantityDesc(stocks).map((position) => position.quantity)).toEqual([40, 25, 10]) + it('adds the incoming units to the warehouse they travel to', () => { + const positions = distributionPositions( + product({ + stocks: [satelite, central], + inTransitByWarehouse: [{ warehouseId: 2, name: 'Satélite', quantity: 7 }], + }), + ) + + expect(positions.map((position) => [position.name, position.incoming])).toEqual([ + ['Central', 0], + ['Satélite', 7], + ]) }) - // `sort` muta el array que recibe, y este llega desde la caché de React - // Query: mutarlo ahí rompería a cualquier otro consumidor de esa misma - // referencia sin que el bug se vea en esta pantalla. - it('does not mutate the array it receives', () => { - const stocks = [stock({ quantity: 10 }), stock({ quantity: 40 })] - const original = [...stocks] - - sortByQuantityDesc(stocks) + // La fila de stock del destino nace recién cuando se recibe la transferencia. + it('lists a warehouse that only receives units, after the stocked ones', () => { + const positions = distributionPositions( + product({ + stocks: [central], + inTransitByWarehouse: [{ warehouseId: 9, name: 'Depósito Sur', quantity: 4 }], + }), + ) + + expect(positions[1]).toEqual({ + warehouseId: 9, + name: 'Depósito Sur', + location: null, + quantity: 0, + incoming: 4, + stockStatus: 'out_of_stock', + }) + }) - expect(stocks).toEqual(original) + it('answers no rows for a product with no stock and nothing in flight', () => { + expect(distributionPositions(product())).toEqual([]) }) }) diff --git a/src/features/inventory/utils/stock.ts b/src/features/inventory/utils/stock.ts index 96ef756..e0c8ecc 100644 --- a/src/features/inventory/utils/stock.ts +++ b/src/features/inventory/utils/stock.ts @@ -1,6 +1,4 @@ -import type { StatusVariant } from 'shared/components' - -import type { ProductStock } from '../types' +import type { Product, ProductStock, StockStatus } from '../types' // Derivaciones de stock del detalle de producto. Funciones puras y aparte del // componente porque concentran las reglas que NO se ven en el diseño — mismo @@ -8,55 +6,32 @@ import type { ProductStock } from '../types' // // ⚠️ Qué expone hoy la API y qué no // -// `GET /api/v1/products/:id` devuelve un único número por depósito -// (`stocks[].quantity`): las unidades **en depósito**. No hay reservas, ni -// mercadería en tránsito, ni umbral máximo por producto. +// `GET /api/v1/products/:id` devuelve, por depósito, las unidades **en +// depósito** (`stocks[].quantity`) con su estado ya calculado, y aparte las +// unidades **entrantes** de cada depósito (`in_transit_by_warehouse`). No hay +// reservas ni umbral máximo por producto. // // El diseño de S12 muestra el total repartido en tres cubetas —comprometido, // en tránsito y disponible para prometer— que suman el on hand (4.280 = -// 1.120 + 450 + 2.710). Ninguna de las tres se puede calcular con lo que hay: -// dar comprometido = 0 diría "no hay nada reservado" cuando en realidad el -// modelo no lo registra, que es la misma clase de error que usar `updated_at` -// como marca de sincronización. Por eso el desglose se muestra sin dato. - -/** - * Umbrales de nivel de stock, en unidades. - * - * **Provisorios.** La regla de negocio real es un punto de reposición por - * producto (o por producto y depósito) que el backend todavía no modela; hasta - * que exista, estos dos números son la única forma de que el badge de estado - * diga algo. Los valores salen de las cantidades del propio diseño, donde 420 - * es "Stock bajo" y 200 "Crítico". - * - * Mismo patrón que `SYNC_STALE_THRESHOLD_MS` en el dashboard: constante con - * nombre y comentario, para que el día que llegue el dato real haya un solo - * lugar que tocar. - */ -export const LOW_STOCK_UNITS = 500 -export const CRITICAL_STOCK_UNITS = 200 - -/** Nivel de disponibilidad de una posición de stock. */ -export type StockLevel = 'available' | 'low' | 'critical' | 'out' - -/** Tono semántico del badge por nivel. El color nunca va solo: siempre con texto. */ -export const STOCK_LEVEL_STATUS: Record = { - available: 'success', - low: 'warning', - critical: 'error', - out: 'error', -} - -/** Unidades en depósito sumando todas las posiciones del producto. */ -export function totalOnHand(stocks: ProductStock[]): number { - return stocks.reduce((total, stock) => total + stock.quantity, 0) -} +// 1.120 + 450 + 2.710). Con el modelo actual eso no se cumple: el en tránsito +// está FUERA del total (salió del origen y no llegó al destino), y comprometido +// y disponible para prometer no se pueden calcular. Dar comprometido = 0 diría +// "no hay nada reservado" cuando en realidad el modelo no lo registra. Por eso +// esas dos se muestran sin dato. +// +// El estado de disponibilidad NO se calcula acá: lo manda el backend, que es +// donde vive el umbral (ver `utils/stockStatus.ts`). -/** Nivel de una cantidad contra los umbrales provisorios de arriba. */ -export function stockLevel(quantity: number): StockLevel { - if (quantity <= 0) return 'out' - if (quantity <= CRITICAL_STOCK_UNITS) return 'critical' - if (quantity <= LOW_STOCK_UNITS) return 'low' - return 'available' +/** Una fila de la distribución por depósito, antes de formatear. */ +export interface DistributionPosition { + warehouseId: number + name: string + /** `null` si el depósito sólo aparece por unidades entrantes (sin fila de stock). */ + location: string | null + quantity: number + /** Unidades en vuelo hacia este depósito. */ + incoming: number + stockStatus: StockStatus } /** @@ -68,3 +43,43 @@ export function stockLevel(quantity: number): StockLevel { export function sortByQuantityDesc(stocks: ProductStock[]): ProductStock[] { return [...stocks].sort((a, b) => b.quantity - a.quantity) } + +/** + * Filas de la distribución: cada depósito con stock, con sus entrantes, y al + * final los que sólo reciben unidades. + * + * Un depósito que espera una transferencia puede no tener fila en `stocks`: la + * API la crea recién cuando la transferencia se recibe. Se lo muestra igual, + * con cero en depósito, porque esconderlo haría desaparecer de la tabla las + * unidades que viajan hacia él. Su estado es `out_of_stock`, que es lo que el + * backend responde para cero unidades: no es una regla nueva, es el único + * valor posible sin fila. + */ +export function distributionPositions(product: Product): DistributionPosition[] { + const incoming = new Map( + product.inTransitByWarehouse.map((transit) => [transit.warehouseId, transit]), + ) + + const stocked = sortByQuantityDesc(product.stocks).map((stock) => ({ + warehouseId: stock.warehouseId, + name: stock.warehouse.name, + location: stock.warehouse.address, + quantity: stock.quantity, + incoming: incoming.get(stock.warehouseId)?.quantity ?? 0, + stockStatus: stock.stockStatus, + })) + + const stockedIds = new Set(product.stocks.map((stock) => stock.warehouseId)) + const onlyIncoming = product.inTransitByWarehouse + .filter((transit) => !stockedIds.has(transit.warehouseId)) + .map((transit) => ({ + warehouseId: transit.warehouseId, + name: transit.name, + location: null, + quantity: 0, + incoming: transit.quantity, + stockStatus: 'out_of_stock' as const, + })) + + return [...stocked, ...onlyIncoming] +}