feat(F-150): completed feature

This commit is contained in:
chattie
2026-08-22 13:08:16 +02:00
parent 6a51d1ee74
commit 4a30f321a8
14 changed files with 296 additions and 24 deletions

View File

@@ -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;
}
},
);
}

View File

@@ -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;

View File

@@ -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',