From 729a7292fdb2f56cac65770d527a9c75334aa172 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 2 Oct 2026 00:51:50 -0300 Subject: [PATCH] feat: [TESIS-999002] take the product detail status and units in flight from the API The detail computed its badge with its own thresholds (500/200) and four levels, so NOR-003 read "available" in the catalog and "critical" in the detail. The header and every warehouse row now show the stock_status the API sends, through the same stockStatus mapping the catalog uses, and the front keeps no stock threshold of its own. It also shows what the API already sent and the screen discarded: the category, the API total, the units in flight apart from the total, and the incoming units of each warehouse, including warehouses that only wait for a transfer and have no stock row yet. Co-Authored-By: Claude Opus 5.5 --- src/features/inventory/api.ts | 16 +++ .../EditProductModal.test.tsx | 6 + src/features/inventory/content.ts | 21 ++- .../pages/ProductDetailPage.test.tsx | 121 +++++++++++++++- .../inventory/pages/ProductDetailPage.tsx | 78 +++++----- src/features/inventory/types.ts | 30 ++++ src/features/inventory/utils/conflict.test.ts | 6 + src/features/inventory/utils/payload.test.ts | 7 + src/features/inventory/utils/stock.test.ts | 133 +++++++++++------- src/features/inventory/utils/stock.ts | 111 ++++++++------- 10 files changed, 378 insertions(+), 151 deletions(-) diff --git a/src/features/inventory/api.ts b/src/features/inventory/api.ts index fedcc04..a581c86 100644 --- a/src/features/inventory/api.ts +++ b/src/features/inventory/api.ts @@ -35,6 +35,7 @@ interface ApiStock { quantity: number warehouse_id: number warehouse: ApiWarehouse + stock_status: StockStatus } interface ApiProductSummary { @@ -54,8 +55,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[] } @@ -81,14 +87,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 9319f40..0a54305 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 fb3b4f2..f6d737c 100644 --- a/src/features/inventory/pages/ProductDetailPage.tsx +++ b/src/features/inventory/pages/ProductDetailPage.tsx @@ -21,10 +21,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 @@ -46,11 +47,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, @@ -70,6 +73,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 }, ] } @@ -90,13 +96,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', @@ -108,9 +115,8 @@ function buildBuckets(): StockBucket[] { { id: 'inTransit', label: masterCopy.buckets.inTransit, - icon: , - value: specsCopy.unknown, - unknown: true, + icon: , + value: formatUnits(product.inTransitQuantity), }, { id: 'availableToPromise', @@ -123,23 +129,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', + })) } /** @@ -242,17 +247,14 @@ export function ProductDetailPage() { ) } - const total = totalOnHand(product.data.stocks) - const level = stockLevel(total) - return ( setEditing(true)} /> @@ -265,9 +267,9 @@ export function ProductDetailPage() { setEditing(true)} /> diff --git a/src/features/inventory/types.ts b/src/features/inventory/types.ts index 58aa2ff..64632ed 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] +}