feat(notif-inventory-alerts): admin bell: add inventory alerts (low stock, out of stock, expired/expiring, low margin)

This commit is contained in:
Deploy
2026-08-27 23:26:01 +02:00
parent 642272e155
commit 1376d3e4f0
14 changed files with 1277 additions and 27 deletions

View File

@@ -75,6 +75,7 @@ const inventoryOverviewFilterSchema = z.enum([
'in_stock',
'low_stock',
'out_of_stock',
'expired',
'expiring',
'low_margin',
]);
@@ -536,6 +537,92 @@ export async function registerInventoryRoutes(
client.release();
}
});
// GET /inventory/admin/notifications
// Counts of low_stock, out_of_stock, expired, expiring (≤30d), low_margin
// for the bell notification badge in admin header. Uses the same CTE as
// /inventory/admin/overview to guarantee consistency between bell counts
// and the filtered inventory page.
const inventoryNotificationsSchema: FastifySchema = {
tags: ['Inventory'],
summary: 'Inventory notification counts for admin bell',
response: { 401: errorSchema, 403: errorSchema },
};
app.get('/inventory/admin/notifications', { schema: inventoryNotificationsSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const countsSql = `
WITH raw AS (
SELECT
p.id AS product_id,
p.expiration_date,
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'
WHERE p.state = 'active'
),
base AS (
SELECT
expiration_date,
available_quantity,
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
)
SELECT
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,
COUNT(*) FILTER (WHERE expiration_date IS NOT NULL AND expiration_date < current_date)::int AS expired,
COUNT(*) FILTER (WHERE expiration_date IS NOT NULL AND expiration_date >= current_date AND expiration_date <= current_date + 30)::int AS expiring_soon,
COUNT(*) FILTER (WHERE margin_percent IS NOT NULL AND margin_percent < 30)::int AS low_margin
FROM base
`;
const result = await deps.pool.query<{
low_stock: number;
out_of_stock: number;
expired: number;
expiring_soon: number;
low_margin: number;
}>(countsSql, [DEFAULT_STORE_ID]);
const r = result.rows[0] ?? {
low_stock: 0, out_of_stock: 0, expired: 0, expiring_soon: 0, low_margin: 0,
};
const candidates: Array<{ id: string; count: number; href: string; label: string; tone: 'amber' | 'red' }> = [
{ id: 'low_stock', count: Number(r.low_stock ?? 0), href: '/inventory?filter=low_stock', label: 'Stock bajo', tone: 'amber' },
{ id: 'out_of_stock', count: Number(r.out_of_stock ?? 0), href: '/inventory?filter=out_of_stock', label: 'Sin stock', tone: 'red' },
{ id: 'expired', count: Number(r.expired ?? 0), href: '/inventory?filter=expired', label: 'Caducados', tone: 'red' },
{ id: 'expiring_soon', count: Number(r.expiring_soon ?? 0), href: '/inventory?filter=expiring', label: 'Por caducar (≤30d)', tone: 'amber' },
{ id: 'low_margin', count: Number(r.low_margin ?? 0), href: '/inventory?filter=low_margin', label: 'Margen <30%', tone: 'red' },
];
const items = candidates.filter((c) => c.count > 0);
const total = items.reduce((sum, c) => sum + c.count, 0);
return reply.send({ items, total });
});
}
function mapInventoryError(error: unknown): Error {
@@ -570,6 +657,9 @@ function inventoryOverviewFilterClause(
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 === 'expired') {
return 'expiration_date IS NOT NULL AND expiration_date < current_date';
}
if (filter === 'expiring') {
return "expiration_date IS NOT NULL AND expiration_date <= current_date + 30";
}
@@ -578,6 +668,9 @@ function inventoryOverviewFilterClause(
}
function inventoryOverviewOrderBy(filter: z.infer<typeof inventoryOverviewFilterSchema>): string {
if (filter === 'expired') {
return 'expiration_date DESC NULLS LAST, product_name ASC, sku ASC';
}
if (filter === 'expiring') {
return 'expiration_date ASC NULLS LAST, product_name ASC, sku ASC';
}

View File

@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type pg from 'pg';
import Fastify, { type FastifyInstance } from 'fastify';
import { AppError, errorEnvelope } from '../../../shared/errors.js';
import { registerInventoryRoutes } from '../api/inventory.routes.js';
import type { InventoryRoutesDeps } from '../api/inventory.routes.js';
const created: FastifyInstance[] = [];
const ADMIN = { id: 'a1', email: 'ana@example.com', role: 'admin' };
const CUSTOMER = { id: 'c1', email: 'c1@example.com', role: 'customer' };
/**
* Minimal app: inventory routes + mocked authenticator (no DB).
* Mirrors build-app.ts error mapping so AppError(403) surfaces with real
* status code.
*/
async function buildApp(authenticatedUser: unknown, mockRows: Record<string, number> = {}) {
const app = Fastify();
created.push(app);
app.setSerializerCompiler(() => (payload: unknown) => JSON.stringify(payload));
app.setErrorHandler(async (err, _request, reply) => {
if (err instanceof AppError) {
return reply
.code(err.statusCode)
.send(errorEnvelope(err.statusCode, err.code, err.message, 'test-request-id', err.details));
}
return reply
.code(500)
.send(errorEnvelope(500, 'INTERNAL_ERROR', 'Internal Server Error', 'test-request-id'));
});
const mockPool = {
query: vi.fn().mockResolvedValue({
rows: [
{
low_stock: mockRows.low_stock ?? 0,
out_of_stock: mockRows.out_of_stock ?? 0,
expired: mockRows.expired ?? 0,
expiring_soon: mockRows.expiring_soon ?? 0,
low_margin: mockRows.low_margin ?? 0,
},
],
}),
} as unknown as pg.Pool;
const deps = {
authenticate: vi.fn().mockResolvedValue(authenticatedUser),
pool: mockPool,
} as unknown as InventoryRoutesDeps;
await registerInventoryRoutes(app, deps);
await app.ready();
return { app };
}
afterEach(async () => {
for (const app of created) {
try {
await app.close();
} catch {
/* ignore */
}
}
created.length = 0;
});
describe('GET /inventory/admin/notifications', () => {
it('admin receives items with count > 0 and correct total (AC1, AC2)', async () => {
const { app } = await buildApp(ADMIN, {
low_stock: 3,
out_of_stock: 2,
expired: 1,
expiring_soon: 5,
low_margin: 4,
});
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
expect(res.statusCode).toBe(200);
const body = res.json() as {
items: Array<{ id: string; count: number; href: string; label: string; tone: 'amber' | 'red' }>;
total: number;
};
expect(body.total).toBe(15);
expect(body.items.map((i) => i.id)).toEqual([
'low_stock',
'out_of_stock',
'expired',
'expiring_soon',
'low_margin',
]);
expect(body.items[0]).toMatchObject({
id: 'low_stock',
count: 3,
href: '/inventory?filter=low_stock',
label: 'Stock bajo',
tone: 'amber',
});
expect(body.items[2]).toMatchObject({
id: 'expired',
count: 1,
href: '/inventory?filter=expired',
label: 'Caducados',
tone: 'red',
});
expect(body.items[4]).toMatchObject({
id: 'low_margin',
count: 4,
href: '/inventory?filter=low_margin',
label: 'Margen <30%',
tone: 'red',
});
});
it('omits items with count = 0', async () => {
const { app } = await buildApp(ADMIN, { low_stock: 0, expired: 2 });
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
expect(res.statusCode).toBe(200);
const body = res.json() as { items: Array<{ id: string; count: number }>; total: number };
expect(body.items.map((i) => i.id)).toEqual(['expired']);
expect(body.total).toBe(2);
});
it('returns empty items and total 0 when no alerts', async () => {
const { app } = await buildApp(ADMIN);
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
expect(res.statusCode).toBe(200);
const body = res.json() as { items: unknown[]; total: number };
expect(body.items).toEqual([]);
expect(body.total).toBe(0);
});
it('rejects non-admin with HTTP 403', async () => {
const { app } = await buildApp(CUSTOMER);
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
expect(res.statusCode).toBe(403);
});
it('expiring_soon uses /inventory?filter=expiring (not a separate filter)', async () => {
const { app } = await buildApp(ADMIN, { expiring_soon: 7 });
const res = await app.inject({ method: 'GET', url: '/inventory/admin/notifications' });
const body = res.json() as { items: Array<{ id: string; href: string }> };
const expiring = body.items.find((i) => i.id === 'expiring_soon');
expect(expiring?.href).toBe('/inventory?filter=expiring');
});
});