feat(F-150): completed feature
This commit is contained in:
@@ -10,7 +10,8 @@ import {
|
||||
reportingFiltersSchema,
|
||||
} from '../application/filters.js';
|
||||
import { ReportingService } from '../application/reporting-service.js';
|
||||
import { requireReportingPermission, userReportingPermissions } from '../domain/permissions.js';
|
||||
import { requireReportingPermission, REPORTING_ROLE_PERMISSIONS, userReportingPermissions } from '../domain/permissions.js';
|
||||
import type { ReportingPermission } from '../domain/permissions.js';
|
||||
|
||||
export interface ReportingRoutesDeps {
|
||||
authenticate: Authenticate;
|
||||
@@ -141,4 +142,152 @@ export async function registerReportingRoutes(
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// ── F-150: CSV export ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
app.get<{ Params: { report: string } }>(
|
||||
'/reporting/export/:report',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Reporting'],
|
||||
summary: 'Export reporting data as CSV',
|
||||
description: 'Streams a filtered dataset as CSV. Requires REPORTING_EXPORT.',
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
report: { type: 'string', enum: ['summary', 'sales', 'products'] },
|
||||
},
|
||||
},
|
||||
querystring: { type: 'object' },
|
||||
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireReportingPermission(user, 'REPORTING_EXPORT');
|
||||
|
||||
const { report } = request.params;
|
||||
|
||||
try {
|
||||
const filters = parseJson(reportingFiltersSchema, request.query ?? {});
|
||||
|
||||
// Fetch all rows (up to 10 000) page by page and stream as CSV.
|
||||
const PAGE_SIZE = 500;
|
||||
const MAX_ROWS = 10_000;
|
||||
|
||||
const filename = `${report}-${filters.range.from.slice(0, 10)}-${filters.range.to.slice(0, 10)}.csv`;
|
||||
reply.header('Content-Type', 'text/csv; charset=utf-8');
|
||||
reply.header('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
// Write metadata header
|
||||
reply.raw.write(
|
||||
`# report: ${report}\n` +
|
||||
`# from: ${filters.range.from}\n` +
|
||||
`# to: ${filters.range.to}\n` +
|
||||
`# channel: ${filters.channel}\n` +
|
||||
`# exported_at: ${new Date().toISOString()}\n`,
|
||||
);
|
||||
|
||||
if (report === 'summary') {
|
||||
// Summary: single-row, just write totals
|
||||
const data = await reporting.summary(filters);
|
||||
reply.raw.write(
|
||||
'orders,customers,gross_sales_cents,discounts_cents,tax_cents,units_sold,shipping_cents\n',
|
||||
);
|
||||
reply.raw.write(
|
||||
[
|
||||
data.totals.orders,
|
||||
data.totals.customers,
|
||||
data.totals.grossSalesCents,
|
||||
data.totals.discountsCents,
|
||||
data.totals.taxCents,
|
||||
data.totals.unitsSold,
|
||||
data.totals.shippingCents,
|
||||
].join(',') + '\n',
|
||||
);
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
}
|
||||
|
||||
if (report === 'sales') {
|
||||
const headers = 'period,channel,store_id,terminal_id,orders,customers,gross_sales_cents,discounts_cents,tax_cents,units_sold,shipping_cents\n';
|
||||
reply.raw.write(headers);
|
||||
let page = 1;
|
||||
let totalRows = 0;
|
||||
while (totalRows < MAX_ROWS) {
|
||||
const data = await reporting.sales({ ...filters, page, pageSize: PAGE_SIZE });
|
||||
if (data.items.length === 0) break;
|
||||
for (const row of data.items) {
|
||||
reply.raw.write(
|
||||
[
|
||||
row.period ?? '',
|
||||
row.channel ?? '',
|
||||
row.storeId ?? '',
|
||||
row.terminalId ?? '',
|
||||
row.metrics.orders,
|
||||
row.metrics.customers,
|
||||
row.metrics.grossSalesCents,
|
||||
row.metrics.discountsCents,
|
||||
row.metrics.taxCents,
|
||||
row.metrics.unitsSold,
|
||||
row.metrics.shippingCents,
|
||||
]
|
||||
.map((v) => JSON.stringify(v ?? ''))
|
||||
.join(',') + '\n',
|
||||
);
|
||||
}
|
||||
totalRows += data.items.length;
|
||||
if (data.items.length < PAGE_SIZE) break;
|
||||
page++;
|
||||
}
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
}
|
||||
|
||||
if (report === 'products') {
|
||||
const headers = 'product_id,product_name,sku,category,brand,orders,customers,gross_sales_cents,discounts_cents,tax_cents,units_sold,shipping_cents\n';
|
||||
reply.raw.write(headers);
|
||||
let page = 1;
|
||||
let totalRows = 0;
|
||||
while (totalRows < MAX_ROWS) {
|
||||
const data = await reporting.products({ ...filters, page, pageSize: PAGE_SIZE });
|
||||
if (data.items.length === 0) break;
|
||||
for (const row of data.items) {
|
||||
reply.raw.write(
|
||||
[
|
||||
row.productId,
|
||||
row.productName,
|
||||
row.sku ?? '',
|
||||
row.category ?? '',
|
||||
row.brand ?? '',
|
||||
row.metrics.orders,
|
||||
row.metrics.customers,
|
||||
row.metrics.grossSalesCents,
|
||||
row.metrics.discountsCents,
|
||||
row.metrics.taxCents,
|
||||
row.metrics.unitsSold,
|
||||
row.metrics.shippingCents,
|
||||
]
|
||||
.map((v) => JSON.stringify(String(v ?? '')))
|
||||
.join(',') + '\n',
|
||||
);
|
||||
}
|
||||
totalRows += data.items.length;
|
||||
if (data.items.length < PAGE_SIZE) break;
|
||||
page++;
|
||||
}
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
}
|
||||
|
||||
throw new AppError(400, 'INVALID_REPORT', `Unknown report: ${report}`);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'ZodError') {
|
||||
throw new AppError(400, 'INVALID_FILTERS', (err as Error).message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ export class ReportingService {
|
||||
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'
|
||||
const sortBy: string = ((filters as { sort?: string }).sort ?? '') === 'revenue'
|
||||
? 'gross_sales_cents'
|
||||
: 'units_sold';
|
||||
const pageSize = filters.pageSize ?? 20;
|
||||
|
||||
@@ -45,7 +45,7 @@ export const REPORTING_ROLE_PERMISSIONS: Record<Role, ReportingPermission[]> = {
|
||||
'REPORTING_EXPORT',
|
||||
'REPORTING_ADMIN',
|
||||
],
|
||||
editor: REPORTING_VIEW_BASE,
|
||||
editor: [...REPORTING_VIEW_BASE, 'REPORTING_EXPORT'],
|
||||
pos_manager: [
|
||||
'REPORTING_VIEW',
|
||||
'REPORTING_SALES',
|
||||
|
||||
Reference in New Issue
Block a user