feat(F-149): completed feature

This commit is contained in:
chattie
2026-08-22 13:05:43 +02:00
parent c497d5be99
commit 6a51d1ee74
15 changed files with 551 additions and 21 deletions

View File

@@ -117,4 +117,28 @@ export async function registerReportingRoutes(
throw err;
}
});
// ── F-149: Products ranking ───────────────────────────────────────────────
const productsSchema: FastifySchema = {
tags: ['Reporting'],
summary: 'Reporting product rankings',
description:
'Top products by units sold or revenue with category/brand. Requires REPORTING_PRODUCTS.',
querystring: { type: 'object' },
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
};
app.get('/reporting/products', { schema: productsSchema }, async (request, reply) => {
const user = await deps.authenticate(request);
requireReportingPermission(user, 'REPORTING_PRODUCTS');
try {
const filters = parseJson(reportingFiltersSchema, request.query ?? {});
return reply.send(await reporting.products(filters));
} catch (err) {
if (err instanceof Error && err.name === 'ZodError') {
throw new AppError(400, 'INVALID_FILTERS', (err as Error).message);
}
throw err;
}
});
}

View File

@@ -152,6 +152,12 @@ interface CountRow {
count: string;
}
/** F-149: Products ranking response. */
export interface ProductsResponse extends Omit<SalesResponse, 'items'> {
items: ProductRow[];
}
export class ReportingService {
constructor(private readonly pool: pg.Pool) {}
@@ -388,6 +394,147 @@ export class ReportingService {
// ── Group-by helpers ────────────────────────────────────────────────────────
// ── F-149: Product rankings ───────────────────────────────────────────
/** Product ranking row. */
interface ProductRow {
productId: string;
productName: string;
sku: string | null;
category: string | null;
brand: string | null;
metrics: Metrics;
}
interface ProductRowRaw {
product_id: string | null;
product_name: string | null;
sku: string | null;
category: string | null;
brand: string | null;
orders: number;
customers: number;
gross_sales_cents: string;
discounts_cents: string;
tax_cents: string;
units_sold: string;
shipping_cents: string;
}
/**
* Top N products by units sold or revenue within the filter range.
* Joins orders_items with catalog_products/categories/brands.
*/
async products(filters: ReportingFilters): Promise<ProductsResponse> {
const { range, channel, storeIds, terminalIds } = filters;
const channelFilter = channel === 'all' ? null : channel;
const sortBy = ((filters as { sort?: string }).sort ?? '') === 'revenue'
? 'gross_sales_cents'
: 'units_sold';
const pageSize = filters.pageSize ?? 20;
const offset = ((filters.page ?? 1) - 1) * pageSize;
const result = await this.pool.query<
ProductRowRaw & { total_count: string }
>(
`WITH filtered_orders AS (
SELECT o.id, o.user_id, o.state, o.source, o.store_id, o.terminal_id
FROM orders_orders o
WHERE o.created_at >= $1
AND o.created_at < $2
AND o.state = ANY($3::text[])
AND ($4::text IS NULL OR o.source = $4)
AND (cardinality($5::uuid[]) = 0 OR o.store_id = ANY($5::uuid[]))
AND (cardinality($6::uuid[]) = 0 OR o.terminal_id = ANY($6::uuid[]))
),
filtered_items AS (
SELECT i.order_id, i.product_id, i.quantity, i.unit_price_cents, i.discount_cents, i.tax_cents
FROM orders_items i
WHERE i.order_id IN (SELECT id FROM filtered_orders)
),
product_rank AS (
SELECT
p.id AS product_id,
p.name AS product_name,
p.sku,
c.name AS category,
b.name AS brand,
COUNT(DISTINCT fi.order_id)::int AS orders,
COUNT(DISTINCT o.user_id) FILTER (WHERE o.user_id IS NOT NULL)::int AS customers,
COALESCE(SUM(fi.unit_price_cents * fi.quantity), 0)::bigint AS gross_sales_cents,
COALESCE(SUM(fi.discount_cents), 0)::bigint AS discounts_cents,
COALESCE(SUM(fi.tax_cents), 0)::bigint AS tax_cents,
COALESCE(SUM(fi.quantity), 0)::int AS units_sold,
COALESCE(SUM(o.shipping_cents), 0)::bigint AS shipping_cents
FROM filtered_items fi
JOIN filtered_orders o ON o.id = fi.order_id
JOIN catalog_products p ON p.id = fi.product_id
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
LEFT JOIN categories_categories c ON c.id = pc.category_id
LEFT JOIN brands_brands b ON b.id = p.brand_id
GROUP BY p.id, p.name, p.sku, c.name, b.name
ORDER BY ${sortBy} DESC
LIMIT $7 OFFSET $8
),
total_count AS (SELECT COUNT(*)::int AS count FROM product_rank)
SELECT pr.*, tc.count AS total_count
FROM product_rank pr
CROSS JOIN total_count tc`,
[
range.from, range.to, [...SALES_STATES],
channelFilter, storeIds, terminalIds,
pageSize, offset,
],
);
const rows: ProductRow[] = result.rows.map((r) => ({
productId: r.product_id ?? '',
productName: r.product_name ?? 'Sin nombre',
sku: r.sku ?? null,
category: r.category ?? null,
brand: r.brand ?? null,
metrics: {
orders: r.orders ?? 0,
customers: r.customers ?? 0,
grossSalesCents: Number(r.gross_sales_cents) || 0,
discountsCents: Number(r.discounts_cents) || 0,
taxCents: Number(r.tax_cents) || 0,
unitsSold: Number(r.units_sold) || 0,
shippingCents: Number(r.shipping_cents) || 0,
},
}));
const totalRows = Number(result.rows[0]?.total_count ?? 0);
const totals = await this.runSummaryQuery(
range.from, range.to, channel, storeIds, terminalIds,
);
return {
range,
filters: { channel, storeIds, terminalIds, groupBy: null },
comparison: null,
dataAvailability: {
grossSales: 'available',
netSales: 'unavailable',
discounts: 'available',
tax: 'available',
unitsSold: 'available',
orders: 'available',
customers: 'available',
margin: 'unavailable',
paymentMethod: 'unavailable',
refunds: 'unavailable',
shipping: 'available',
},
items: rows,
totals,
pagination: { page: filters.page ?? 1, pageSize, totalRows },
updatedAt: new Date().toISOString(),
cache: { hit: false, maxAgeSeconds: 0 },
};
}
function buildGroupBy(dim: GroupBy | undefined): {
groupExpr: string;
selectExpr: string;