import type { FastifyInstance } from 'fastify'; import type { FastifySchema } from 'fastify'; import type pg from 'pg'; import { z } from 'zod'; import { requireRole, type Authenticate } from '../../../shared/auth.js'; import { AppError } from '../../../shared/errors.js'; import { errorSchema } from '../../../shared/swagger.js'; import { parseJson } from '../../../shared/http-input.js'; import { InventoryService } from '../application/inventory-service.js'; import { InsufficientReservedStockError, InsufficientStockError, InvalidStockQuantityError, } from '../domain/errors.js'; import type { SetAvailableStockCommand, StockItem } from '../domain/stock.js'; import { DEFAULT_STORE_ID } from '../domain/stock.js'; import { PgInventoryRepository } from '../infrastructure/pg-inventory-repository.js'; export interface InventoryRoutesDeps { pool: pg.Pool; authenticate: Authenticate; } interface InventoryOverviewRow { product_id: string; product_name: string; product_slug: string; product_active: boolean; product_created_at: Date; expiration_date: string | null; variant_id: string; sku: string; ean: string | null; available_quantity: number; net_unit_amount_cents: number | null; offer_cents: number | null; cost_cents: number | null; vat_rate: 'general' | 'reduced' | 'super-reduced' | null; gross_unit_amount_cents: number | null; margin_percent: number | null; } interface InventoryOverviewStatsRow { total: number; in_stock: number; low_stock: number; out_of_stock: number; } const variantParamSchema = z.object({ variantId: z.uuid() }); const productParamSchema = z.object({ productId: z.uuid() }); const availabilityQuerySchema = z.object({ quantity: z.coerce.number().int().positive().default(1), storeId: z.uuid().optional(), }); const stockBodySchema = z.object({ quantity: z.number().int().min(0), storeId: z.uuid().optional(), }); const stockCommandBodySchema = z.object({ quantity: z.number().int().positive(), storeId: z.uuid().optional(), }); const bulkAdjustItemSchema = z.object({ variantId: z.uuid(), quantity: z.number().int().min(0), }); const bulkAdjustBodySchema = z.object({ items: z.array(bulkAdjustItemSchema).min(1).max(100), }); const inventoryOverviewFilterSchema = z.enum([ 'all', 'in_stock', 'low_stock', 'out_of_stock', 'expiring', 'low_margin', ]); const inventoryOverviewQuerySchema = z.object({ q: z.string().trim().max(120).optional(), filter: inventoryOverviewFilterSchema.default('all'), limit: z.coerce.number().int().min(1).max(200).default(50), offset: z.coerce.number().int().min(0).max(100_000).default(0), }); export async function registerInventoryRoutes( app: FastifyInstance, deps: InventoryRoutesDeps, ): Promise { const inventory = new InventoryService(new PgInventoryRepository(deps.pool)); const inventoryOverviewSchema: FastifySchema = { tags: ['Inventory'], summary: 'Inventory overview (admin)', querystring: { type: 'object', properties: { q: { type: 'string', maxLength: 120 }, filter: { type: 'string', enum: ['all', 'in_stock', 'low_stock', 'out_of_stock', 'expiring', 'low_margin'], default: 'all', }, limit: { type: 'integer', minimum: 1, maximum: 200, default: 50 }, offset: { type: 'integer', minimum: 0, default: 0 }, }, }, response: { 401: errorSchema, 403: errorSchema }, }; app.get('/inventory/admin/overview', { schema: inventoryOverviewSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const { q, filter, limit, offset } = parseJson(inventoryOverviewQuerySchema, request.query ?? {}); const params: unknown[] = [DEFAULT_STORE_ID]; let nextParam = 2; const searchConditions: string[] = []; if (q) { const placeholder = `$${nextParam++}`; params.push(`%${q}%`); searchConditions.push( `(product_name ILIKE ${placeholder} OR sku ILIKE ${placeholder} OR COALESCE(ean, '') ILIKE ${placeholder})`, ); } const filterCondition = inventoryOverviewFilterClause(filter); const whereSearch = searchConditions.length > 0 ? `WHERE ${searchConditions.join(' AND ')}` : ''; const whereItems = [ ...searchConditions, ...(filterCondition ? [filterCondition] : []), ]; const whereItemsSql = whereItems.length > 0 ? `WHERE ${whereItems.join(' AND ')}` : ''; const orderBy = inventoryOverviewOrderBy(filter); const baseSql = ` WITH raw AS ( SELECT p.id AS product_id, p.name AS product_name, p.slug AS product_slug, p.state = 'active' AS product_active, p.created_at AS product_created_at, p.expiration_date, v.id AS variant_id, v.sku AS variant_sku, v.ean, COALESCE(stock.available, 0) AS available_quantity, price.net_unit_amount_cents, price.offer_cents, price.cost_cents, price.vat_rate, COALESCE( price.offer_cents, ROUND( price.net_unit_amount_cents * CASE price.vat_rate WHEN 'general' THEN 1.21 WHEN 'reduced' THEN 1.10 WHEN 'super-reduced' THEN 1.04 ELSE 1.21 END )::int ) AS gross_unit_amount_cents FROM catalog_product_variants v JOIN catalog_products p ON p.id = v.product_id LEFT JOIN inventory_stock stock ON stock.variant_id = v.id AND stock.store_id = $1 LEFT JOIN pricing_variant_prices price ON price.variant_id = v.id AND price.currency = 'EUR' ), base AS ( SELECT product_id, product_name, product_slug, product_active, product_created_at, expiration_date, variant_id, variant_sku AS sku, ean, available_quantity, net_unit_amount_cents, offer_cents, cost_cents, vat_rate, gross_unit_amount_cents, CASE WHEN cost_cents IS NULL OR gross_unit_amount_cents IS NULL OR gross_unit_amount_cents <= 0 THEN NULL ELSE ROUND((((gross_unit_amount_cents - cost_cents)::numeric / gross_unit_amount_cents::numeric) * 100))::int END AS margin_percent FROM raw ) `; const statsResult = await deps.pool.query( `${baseSql} SELECT COUNT(*)::int AS total, COUNT(*) FILTER (WHERE available_quantity >= 5)::int AS in_stock, COUNT(*) FILTER (WHERE available_quantity > 0 AND available_quantity < 5)::int AS low_stock, COUNT(*) FILTER (WHERE available_quantity = 0)::int AS out_of_stock FROM base ${whereSearch}`, params, ); const countParams = [...params, limit, offset]; const countResult = await deps.pool.query<{ total: string }>( `${baseSql} SELECT COUNT(*)::int AS total FROM base ${whereItemsSql}`, params, ); const rows = await deps.pool.query( `${baseSql} SELECT * FROM base ${whereItemsSql} ORDER BY ${orderBy} LIMIT $${nextParam++} OFFSET $${nextParam++}`, countParams, ); return reply.send({ items: rows.rows.map(serializeInventoryOverviewRow), total: Number(countResult.rows[0]?.total ?? 0), stats: serializeInventoryOverviewStats(statsResult.rows[0]), }); }); const availabilitySchema: FastifySchema = { tags: ['Inventory'], summary: 'Check availability (público)', params: { type: 'object', required: ['variantId'], properties: { variantId: { type: 'string', format: 'uuid' } }, }, querystring: { type: 'object', properties: { quantity: { type: 'integer', default: 1 }, storeId: { type: 'string', format: 'uuid' }, }, }, }; app.get( '/inventory/:variantId/availability', { schema: availabilitySchema }, async (request, reply) => { const { variantId } = parseJson(variantParamSchema, request.params); const { quantity, storeId } = parseJson(availabilityQuerySchema, request.query); const availability = await inventory.checkAvailability( variantId, storeId ?? DEFAULT_STORE_ID, quantity, ); return reply.send(availability); }, ); // GET /inventory/product/:productId/availability — lookup first variant and return stock const productAvailabilitySchema: FastifySchema = { tags: ['Inventory'], summary: 'Check availability by productId (público)', params: { type: 'object', required: ['productId'], properties: { productId: { type: 'string', format: 'uuid' } }, }, querystring: { type: 'object', properties: { quantity: { type: 'integer', default: 1 }, storeId: { type: 'string', format: 'uuid' }, }, }, }; app.get( '/inventory/product/:productId/availability', { schema: productAvailabilitySchema }, async (request, reply) => { const { productId } = parseJson(productParamSchema, request.params); const { quantity, storeId } = parseJson(availabilityQuerySchema, request.query); // Lookup the first variant for this product const variantResult = await deps.pool.query( `SELECT id FROM product_variants WHERE product_id = $1 LIMIT 1`, [productId], ); if (variantResult.rows.length === 0) { throw new AppError(404, 'VARIANT_NOT_FOUND', 'No variant found for this product'); } const variantId = variantResult.rows[0].id; const availability = await inventory.checkAvailability( variantId, storeId ?? DEFAULT_STORE_ID, quantity, ); return reply.send(availability); }, ); const setStockSchema: FastifySchema = { tags: ['Inventory'], summary: 'Set available stock (admin)', params: { type: 'object', required: ['variantId'], properties: { variantId: { type: 'string', format: 'uuid' } }, }, body: { type: 'object', required: ['quantity'], properties: { quantity: { type: 'integer', minimum: 0 } }, }, response: { 401: errorSchema, 403: errorSchema }, }; app.put('/inventory/:variantId/stock', { schema: setStockSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const { variantId } = parseJson(variantParamSchema, request.params); const { quantity, storeId } = parseJson(stockBodySchema, request.body); try { const item = await inventory.setAvailable({ variantId, storeId: storeId ?? DEFAULT_STORE_ID, quantity, }); return reply.send(serializeStockItem(item)); } catch (error) { throw mapInventoryError(error); } }); const reserveSchema: FastifySchema = { tags: ['Inventory'], summary: 'Reserve stock (admin)', params: { type: 'object', required: ['variantId'], properties: { variantId: { type: 'string', format: 'uuid' } }, }, body: { type: 'object', required: ['quantity'], properties: { quantity: { type: 'integer', minimum: 1 } }, }, response: { 401: errorSchema, 403: errorSchema }, }; app.post( '/inventory/:variantId/reservations', { schema: reserveSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const { variantId } = parseJson(variantParamSchema, request.params); const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body); try { const item = await inventory.reserve({ variantId, storeId: storeId ?? DEFAULT_STORE_ID, quantity, }); return reply.code(201).send(serializeStockItem(item)); } catch (error) { throw mapInventoryError(error); } }, ); const releaseSchema: FastifySchema = { tags: ['Inventory'], summary: 'Release reservation (admin)', params: { type: 'object', required: ['variantId'], properties: { variantId: { type: 'string', format: 'uuid' } }, }, body: { type: 'object', required: ['quantity'], properties: { quantity: { type: 'integer', minimum: 1 } }, }, response: { 401: errorSchema, 403: errorSchema }, }; app.post( '/inventory/:variantId/reservations/release', { schema: releaseSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const { variantId } = parseJson(variantParamSchema, request.params); const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body); try { const item = await inventory.release({ variantId, storeId: storeId ?? DEFAULT_STORE_ID, quantity, }); return reply.send(serializeStockItem(item)); } catch (error) { throw mapInventoryError(error); } }, ); const confirmSchema: FastifySchema = { tags: ['Inventory'], summary: 'Confirm reservation (admin)', params: { type: 'object', required: ['variantId'], properties: { variantId: { type: 'string', format: 'uuid' } }, }, body: { type: 'object', required: ['quantity'], properties: { quantity: { type: 'integer', minimum: 1 } }, }, response: { 401: errorSchema, 403: errorSchema }, }; app.post( '/inventory/:variantId/reservations/confirm', { schema: confirmSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const { variantId } = parseJson(variantParamSchema, request.params); const { quantity, storeId } = parseJson(stockCommandBodySchema, request.body); try { const item = await inventory.confirm({ variantId, storeId: storeId ?? DEFAULT_STORE_ID, quantity, }); return reply.send(serializeStockItem(item)); } catch (error) { throw mapInventoryError(error); } }, ); // POST /inventory/bulk-adjust — atomic bulk stock adjustment const bulkSchema: FastifySchema = { tags: ['Inventory'], summary: 'Bulk stock adjustment (admin)', description: 'Ajuste atómico de stock para múltiples variantes en una transacción.', body: { type: 'object', required: ['items'], properties: { items: { type: 'array', items: { type: 'object', required: ['variantId', 'quantity'], properties: { variantId: { type: 'string', format: 'uuid' }, quantity: { type: 'integer', minimum: 0 }, }, }, }, }, }, response: { 401: errorSchema, 403: errorSchema }, }; app.post('/inventory/bulk-adjust', { schema: bulkSchema }, async (request, reply) => { const user = await deps.authenticate(request); requireRole(user, 'admin'); const { items } = parseJson(bulkAdjustBodySchema, request.body); const client = await deps.pool.connect(); try { await client.query('BEGIN'); const results: StockItem[] = []; for (const item of items) { const command: SetAvailableStockCommand = { variantId: item.variantId, storeId: DEFAULT_STORE_ID, quantity: item.quantity, }; // Use direct SQL for atomicity within the transaction const result = await client.query<{ id: string; variant_id: string; store_id: string; available: number; reserved: number; sold: number; incoming: number; created_at: Date; updated_at: Date; }>( `INSERT INTO inventory_stock (variant_id, store_id, available) VALUES ($1, $2, $3) ON CONFLICT (variant_id, store_id) DO UPDATE SET available = EXCLUDED.available, updated_at = now() RETURNING *`, [command.variantId, command.storeId, command.quantity], ); // Log the movement await client.query( `INSERT INTO inventory_movements (variant_id, store_id, operation, quantity) VALUES ($1, $2, $3, $4)`, [command.variantId, command.storeId, 'bulk_adjust', command.quantity], ); const row = result.rows[0]; if (!row) { throw new Error(`Inventory stock not found for variant ${command.variantId}`); } results.push({ id: row.id, variantId: row.variant_id, storeId: row.store_id, available: row.available, reserved: row.reserved, sold: row.sold, incoming: row.incoming, createdAt: row.created_at, updatedAt: row.updated_at, }); } await client.query('COMMIT'); return reply.code(201).send({ adjusted: results.map(serializeStockItem), errors: [] }); } catch (error) { await client.query('ROLLBACK'); throw mapInventoryError(error); } finally { client.release(); } }); } function mapInventoryError(error: unknown): Error { if (error instanceof InsufficientStockError) { return new AppError(409, 'INSUFFICIENT_STOCK', error.message); } if (error instanceof InsufficientReservedStockError) { return new AppError(409, 'INSUFFICIENT_RESERVED_STOCK', error.message); } if (error instanceof InvalidStockQuantityError) { return new AppError(422, 'INVALID_STOCK_QUANTITY', error.message); } return error instanceof Error ? error : new Error('Unknown inventory error'); } function serializeStockItem(item: StockItem) { return { id: item.id, variantId: item.variantId, available: item.available, reserved: item.reserved, sold: item.sold, incoming: item.incoming, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), }; } function inventoryOverviewFilterClause( filter: z.infer, ): string { if (filter === 'in_stock') return 'available_quantity >= 5'; if (filter === 'low_stock') return 'available_quantity > 0 AND available_quantity < 5'; if (filter === 'out_of_stock') return 'available_quantity = 0'; if (filter === 'expiring') { return "expiration_date IS NOT NULL AND expiration_date <= current_date + 30"; } if (filter === 'low_margin') return 'margin_percent IS NOT NULL AND margin_percent < 30'; return ''; } function inventoryOverviewOrderBy(filter: z.infer): string { if (filter === 'expiring') { return 'expiration_date ASC NULLS LAST, product_name ASC, sku ASC'; } if (filter === 'low_margin') { return 'margin_percent ASC NULLS LAST, product_name ASC, sku ASC'; } return 'product_created_at DESC, product_name ASC, sku ASC'; } function serializeInventoryOverviewRow(row: InventoryOverviewRow) { return { productId: row.product_id, productName: row.product_name, productSlug: row.product_slug, productActive: row.product_active, expirationDate: row.expiration_date, variant: { id: row.variant_id, productId: row.product_id, sku: row.sku, ean: row.ean, attributes: {}, }, stock: { available: row.available_quantity > 0, availableQuantity: Number(row.available_quantity), }, price: row.net_unit_amount_cents === null || row.vat_rate === null ? null : { variantId: row.variant_id, netUnitAmountCents: Number(row.net_unit_amount_cents), offerCents: row.offer_cents === null ? null : Number(row.offer_cents), costCents: row.cost_cents === null ? null : Number(row.cost_cents), vatRate: row.vat_rate, currency: 'EUR', }, marginPercent: row.margin_percent === null ? null : Number(row.margin_percent), }; } function serializeInventoryOverviewStats(row: InventoryOverviewStatsRow | undefined) { return { total: Number(row?.total ?? 0), inStock: Number(row?.in_stock ?? 0), lowStock: Number(row?.low_stock ?? 0), outOfStock: Number(row?.out_of_stock ?? 0), }; }