630 lines
20 KiB
TypeScript
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),
|
|
};
|
|
}
|