fix(inventory-opt): inventory optimization: pagination, queries, filters for 10k+ products

This commit is contained in:
Deploy
2026-08-26 18:07:49 +02:00
parent 49dfd00406
commit c19626bea3
13 changed files with 1114 additions and 361 deletions

View File

@@ -0,0 +1,186 @@
import { randomUUID } from 'node:crypto';
import type { DestinationStream } from 'pino';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { createPool } from '../../infrastructure/db/pool.js';
import { createLogger } from '../../infrastructure/logging/logger.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
function silentLogger() {
const destination: DestinationStream = { write: () => undefined };
return createLogger({ level: 'info', destination });
}
function cookieValue(setCookieHeader: string | string[] | undefined): string {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
expect(raw).toBeDefined();
const pair = (raw as string).split(';')[0] as string;
return pair.slice(pair.indexOf('=') + 1);
}
describe.skipIf(!hasDb)('inventory admin overview (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
let adminCookie = '';
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
const user = { email: 'inventory-admin@example.com', password: 'correct horse battery staple' };
const registered = await app.inject({
method: 'POST',
url: '/auth/register',
headers: { 'content-type': 'application/json' },
payload: user,
});
const id = (registered.json() as { id: string }).id;
await pool.query(
`UPDATE identity_users
SET role = $1,
email_confirmed = true,
confirmed_at = now(),
confirmation_token = null
WHERE id = $2`,
['admin', id],
);
const login = await app.inject({
method: 'POST',
url: '/auth/login',
headers: { 'content-type': 'application/json' },
payload: user,
});
adminCookie = cookieValue(login.headers['set-cookie']);
});
afterAll(async () => {
await app?.close();
await pool?.end();
});
it('returns paginated inventory rows with server-side search, filters and stats', async () => {
const createProduct = async (name: string, slug: string, expirationDate?: string | null) => {
const product = await app.inject({
method: 'POST',
url: '/products',
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { name, slug, state: 'active', expirationDate: expirationDate ?? null },
});
expect(product.statusCode).toBe(201);
return product.json() as { id: string };
};
const productA = await createProduct('Avena bio', `avena-${randomUUID().slice(0, 8)}`);
const productB = await createProduct(
'Semillas lino',
`lino-${randomUUID().slice(0, 8)}`,
new Date(Date.now() + 10 * 86_400_000).toISOString().slice(0, 10),
);
const createVariant = async (productId: string, sku: string, ean: string) => {
const response = await app.inject({
method: 'POST',
url: `/products/${productId}/variants`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { sku, ean },
});
expect(response.statusCode).toBe(201);
return response.json() as { id: string };
};
const variantA = await createVariant(productA.id, `SKU-${randomUUID().slice(0, 8)}`, '111');
const variantB = await createVariant(productB.id, `SKU-${randomUUID().slice(0, 8)}`, '222');
const setPrice = async (
variantId: string,
netUnitAmountCents: number,
costCents: number,
) => {
const response = await app.inject({
method: 'PUT',
url: `/pricing/variants/${variantId}`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { netUnitAmountCents, costCents, vatRate: 'general' },
});
expect(response.statusCode).toBe(200);
};
const setStock = async (variantId: string, quantity: number) => {
const response = await app.inject({
method: 'PUT',
url: `/inventory/${variantId}/stock`,
headers: { 'content-type': 'application/json' },
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
payload: { quantity },
});
expect(response.statusCode).toBe(200);
};
await setPrice(variantA.id, 1000, 600);
await setPrice(variantB.id, 1000, 950);
await setStock(variantA.id, 8);
await setStock(variantB.id, 2);
const search = await app.inject({
method: 'GET',
url: '/inventory/admin/overview?q=111&limit=10&offset=0',
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
});
expect(search.statusCode).toBe(200);
expect(search.json()).toMatchObject({
total: 1,
stats: { inStock: 1, lowStock: 0, outOfStock: 0 },
items: [
{
productName: 'Avena bio',
variant: { ean: '111' },
stock: { availableQuantity: 8 },
},
],
});
const lowMargin = await app.inject({
method: 'GET',
url: '/inventory/admin/overview?filter=low_margin&limit=10&offset=0',
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
});
expect(lowMargin.statusCode).toBe(200);
expect(lowMargin.json()).toMatchObject({
total: 1,
items: [
{
productName: 'Semillas lino',
stock: { availableQuantity: 2 },
marginPercent: 21,
},
],
});
const expiring = await app.inject({
method: 'GET',
url: '/inventory/admin/overview?filter=expiring&limit=10&offset=0',
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
});
expect(expiring.statusCode).toBe(200);
const expiringBody = expiring.json() as {
total: number;
items: Array<{ productName: string }>;
};
expect(expiringBody.total).toBe(2);
expect(expiringBody.items.every((item) => item.productName === 'Semillas lino')).toBe(true);
});
});

View File

@@ -21,6 +21,32 @@ export interface InventoryRoutesDeps {
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({
@@ -44,12 +70,164 @@ 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)',
@@ -385,3 +563,67 @@ function serializeStockItem(item: StockItem) {
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),
};
}