Files
mercadodevida/project/src/modules/inventory/api/inventory.routes.ts

630 lines
20 KiB
TypeScript

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<void> {
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<InventoryOverviewStatsRow>(
`${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<InventoryOverviewRow>(
`${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<typeof inventoryOverviewFilterSchema>,
): 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<typeof inventoryOverviewFilterSchema>): 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),
};
}