fix(inventory-opt): inventory optimization: pagination, queries, filters for 10k+ products
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user