feat(F-149): completed feature
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user