feat(F-146): completed feature
This commit is contained in:
@@ -224,10 +224,11 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
});
|
||||
|
||||
// Reporting contracts + RBAC foundation (F-143). Reporting is backoffice-only
|
||||
// sharing the combined authenticator; it has no DB reads until F-144+.
|
||||
// sharing the combined authenticator; F-146 adds ReportingService reads.
|
||||
await app.register(async (instance) => {
|
||||
await registerReportingRoutes(instance, {
|
||||
authenticate: combinedAuth,
|
||||
pool: deps.pool as pg.Pool,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { registerReportingRoutes } from './reporting.routes.js';
|
||||
@@ -30,8 +31,12 @@ async function buildApp(authenticatedUser: unknown) {
|
||||
.code(500)
|
||||
.send(errorEnvelope(500, 'INTERNAL_ERROR', 'Internal Server Error', 'test-request-id'));
|
||||
});
|
||||
const mockPool = {
|
||||
query: vi.fn().mockResolvedValue({ rows: [] }),
|
||||
} as unknown as pg.Pool;
|
||||
const deps = {
|
||||
authenticate: vi.fn().mockResolvedValue(authenticatedUser),
|
||||
pool: mockPool,
|
||||
} as unknown as ReportingRoutesDeps;
|
||||
await registerReportingRoutes(app, deps);
|
||||
await app.ready();
|
||||
@@ -137,3 +142,99 @@ describe('GET /reporting/filters/validate (REPORTING_SALES)', () => {
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ── F-146: Summary and Sales endpoint tests ──────────────────────────────
|
||||
|
||||
describe('GET /reporting/summary (REPORTING_SALES)', () => {
|
||||
const BASE = '/reporting/summary?from=2026-08-01T00:00:00Z&to=2026-08-31T23:59:59Z';
|
||||
|
||||
it('returns summary DTO with all required fields (AC1)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({ method: 'GET', url: BASE });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body ?? '') as Record<string, unknown>;
|
||||
expect(body).toHaveProperty('range');
|
||||
expect(body).toHaveProperty('filters');
|
||||
expect(body).toHaveProperty('comparison');
|
||||
expect(body).toHaveProperty('dataAvailability');
|
||||
expect(body).toHaveProperty('totals');
|
||||
expect(body).toHaveProperty('updatedAt');
|
||||
expect(body).toHaveProperty('cache');
|
||||
});
|
||||
|
||||
it('returns correct dataAvailability flags (AC5)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({ method: 'GET', url: BASE });
|
||||
|
||||
const body = JSON.parse(res.body ?? '') as {
|
||||
dataAvailability: Record<string, string>;
|
||||
};
|
||||
expect(body.dataAvailability.grossSales).toBe('available');
|
||||
expect(body.dataAvailability.netSales).toBe('unavailable');
|
||||
expect(body.dataAvailability.margin).toBe('unavailable');
|
||||
expect(body.dataAvailability.paymentMethod).toBe('unavailable');
|
||||
expect(body.dataAvailability.shipping).toBe('available');
|
||||
expect(body.dataAvailability.discounts).toBe('available');
|
||||
});
|
||||
|
||||
it('returns 400 on inverted dates (AC7)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/reporting/summary?from=2026-08-31T00:00:00Z&to=2026-08-01T00:00:00Z',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('customer is forbidden (RBAC, AC8)', async () => {
|
||||
const { app } = await buildApp(CUSTOMER);
|
||||
const res = await app.inject({ method: 'GET', url: BASE });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /reporting/sales (REPORTING_SALES)', () => {
|
||||
const BASE = '/reporting/sales?from=2026-08-01T00:00:00Z&to=2026-08-31T23:59:59Z';
|
||||
|
||||
it('returns sales DTO with items and pagination (AC2)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({ method: 'GET', url: `${BASE}&groupBy=day` });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body ?? '') as Record<string, unknown>;
|
||||
expect(body).toHaveProperty('range');
|
||||
expect(body).toHaveProperty('filters');
|
||||
expect(body).toHaveProperty('items');
|
||||
expect(body).toHaveProperty('totals');
|
||||
expect(body).toHaveProperty('pagination');
|
||||
expect(body).toHaveProperty('updatedAt');
|
||||
expect(Array.isArray((body as Record<string, unknown>).items)).toBe(true);
|
||||
});
|
||||
|
||||
it('pagination fields present (AC4)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({ method: 'GET', url: `${BASE}&groupBy=month&page=2&pageSize=20` });
|
||||
|
||||
const body = JSON.parse(res.body ?? '') as {
|
||||
pagination: { page: number; pageSize: number; totalRows: number };
|
||||
};
|
||||
expect(body.pagination.page).toBe(2);
|
||||
expect(body.pagination.pageSize).toBe(20);
|
||||
});
|
||||
|
||||
it('returns 400 on invalid channel (AC7)', async () => {
|
||||
const { app } = await buildApp(ADMIN);
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/reporting/sales?from=2026-08-01T00:00:00Z&to=2026-08-31T23:59:59Z&channel=invalid',
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('customer is forbidden (RBAC, AC8)', async () => {
|
||||
const { app } = await buildApp(CUSTOMER);
|
||||
const res = await app.inject({ method: 'GET', url: BASE });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import type { FastifyInstance, FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { REPORTING_FILTER_META, comparisonRange, reportingFiltersSchema } from '../application/filters.js';
|
||||
import {
|
||||
REPORTING_FILTER_META,
|
||||
comparisonRange,
|
||||
reportingFiltersSchema,
|
||||
} from '../application/filters.js';
|
||||
import { ReportingService } from '../application/reporting-service.js';
|
||||
import { requireReportingPermission, userReportingPermissions } from '../domain/permissions.js';
|
||||
|
||||
export interface ReportingRoutesDeps {
|
||||
authenticate: Authenticate;
|
||||
pool: pg.Pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reporting routes (F-143).
|
||||
* Reporting routes (F-143, F-146).
|
||||
*
|
||||
* Backend-only contracts + RBAC foundation. NO report data is read here
|
||||
* (F-144+ owns report data). `ReportingFilterMeta.dataAvailability` is metadata
|
||||
* only — it never converts an unavailable metric into a value.
|
||||
* F-143: filter contracts + RBAC foundation.
|
||||
* F-146: ReportingService with summary and sales endpoints.
|
||||
*
|
||||
* `ReportingFilterMeta.dataAvailability` is metadata only — it never converts
|
||||
* an unavailable metric into a value.
|
||||
*/
|
||||
export async function registerReportingRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: ReportingRoutesDeps,
|
||||
): Promise<void> {
|
||||
const reporting = new ReportingService(deps.pool);
|
||||
|
||||
const filtersSchemaRoute: FastifySchema = {
|
||||
tags: ['Reporting'],
|
||||
summary: 'Reporting filter schema + RBAC contract',
|
||||
@@ -59,4 +71,50 @@ export async function registerReportingRoutes(
|
||||
const comparison = comparisonRange(filters.range, filters.compare);
|
||||
return reply.send({ ok: true, filters, comparison: { range: comparison } });
|
||||
});
|
||||
|
||||
// ── F-146: Summary and Sales ──────────────────────────────────────────────
|
||||
|
||||
const summarySchema: FastifySchema = {
|
||||
tags: ['Reporting'],
|
||||
summary: 'Reporting summary',
|
||||
description:
|
||||
'Aggregated metrics (orders, customers, grossSales, discounts, tax, units) for the given date range and filters. Requires REPORTING_SALES.',
|
||||
querystring: { type: 'object' },
|
||||
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/reporting/summary', { schema: summarySchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireReportingPermission(user, 'REPORTING_SALES');
|
||||
try {
|
||||
const filters = parseJson(reportingFiltersSchema, request.query ?? {});
|
||||
return reply.send(await reporting.summary(filters));
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'ZodError') {
|
||||
throw new AppError(400, 'INVALID_FILTERS', (err as Error).message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
const salesSchema: FastifySchema = {
|
||||
tags: ['Reporting'],
|
||||
summary: 'Reporting sales (grouped)',
|
||||
description:
|
||||
'Grouped sales rows with pagination. groupBy=day|week|month|hour|store|channel|terminal. Requires REPORTING_SALES.',
|
||||
querystring: { type: 'object' },
|
||||
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/reporting/sales', { schema: salesSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireReportingPermission(user, 'REPORTING_SALES');
|
||||
try {
|
||||
const filters = parseJson(reportingFiltersSchema, request.query ?? {});
|
||||
return reply.send(await reporting.sales(filters));
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'ZodError') {
|
||||
throw new AppError(400, 'INVALID_FILTERS', (err as Error).message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
462
project/src/modules/reporting/application/reporting-service.ts
Normal file
462
project/src/modules/reporting/application/reporting-service.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* F-146 — ReportingService: summary and sales endpoints.
|
||||
*
|
||||
* Uses the CTE `filtered_orders` pattern from REPORTING_ARCHITECTURE.md §7.
|
||||
* All SQL is fully parameterized — no user input in the query string.
|
||||
*/
|
||||
|
||||
import type pg from 'pg';
|
||||
import type {
|
||||
GroupBy,
|
||||
ReportingChannel,
|
||||
ReportingFilters,
|
||||
} from '../domain/filters.js';
|
||||
import { comparisonRange } from './filters.js';
|
||||
|
||||
/** Dimensions available for groupBy in the sales endpoint. */
|
||||
const GROUP_BY_DIMS = [
|
||||
'day', 'week', 'month', 'hour',
|
||||
'store', 'channel', 'terminal', 'cashier', 'payment',
|
||||
] as const satisfies readonly GroupBy[];
|
||||
|
||||
/** Order states included in gross sales metrics. */
|
||||
const SALES_STATES = [
|
||||
'PAID', 'PROCESSING', 'SHIPPED', 'DELIVERED', 'COMPLETED',
|
||||
] as const;
|
||||
|
||||
/** Core metric row returned by both summary and the totals row of sales. */
|
||||
export interface Metrics {
|
||||
orders: number;
|
||||
customers: number;
|
||||
grossSalesCents: number;
|
||||
discountsCents: number;
|
||||
taxCents: number;
|
||||
unitsSold: number;
|
||||
shippingCents: number; // always 0 until F-146 reads shipping_cents
|
||||
}
|
||||
|
||||
/** A single row in the sales grouped response. */
|
||||
export interface SalesRow {
|
||||
period: string | null; // null for ungrouped (no groupBy)
|
||||
channel: ReportingChannel | null;
|
||||
storeId: string | null;
|
||||
terminalId: string | null;
|
||||
metrics: Metrics;
|
||||
}
|
||||
|
||||
/** Comparison period metrics. */
|
||||
export interface ComparisonMetrics {
|
||||
orders: number;
|
||||
customers: number;
|
||||
grossSalesCents: number;
|
||||
discountsCents: number;
|
||||
taxCents: number;
|
||||
unitsSold: number;
|
||||
}
|
||||
|
||||
/** Variation between current and previous period. */
|
||||
export interface Comparison {
|
||||
previous: ComparisonMetrics;
|
||||
variation?: number; // percent, null when previous is zero
|
||||
}
|
||||
|
||||
/** Summary response. */
|
||||
export interface SummaryResponse {
|
||||
range: { from: string; to: string };
|
||||
filters: {
|
||||
channel: ReportingChannel;
|
||||
storeIds: string[];
|
||||
terminalIds: string[];
|
||||
};
|
||||
comparison: 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';
|
||||
};
|
||||
totals: Metrics;
|
||||
updatedAt: string;
|
||||
cache: { hit: false; maxAgeSeconds: number };
|
||||
}
|
||||
|
||||
/** Sales response with grouping and pagination. */
|
||||
export interface SalesResponse {
|
||||
range: { from: string; to: string };
|
||||
filters: {
|
||||
channel: ReportingChannel;
|
||||
storeIds: string[];
|
||||
terminalIds: string[];
|
||||
groupBy: GroupBy | null;
|
||||
};
|
||||
comparison: 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: SalesRow[];
|
||||
totals: Metrics;
|
||||
pagination: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalRows: number;
|
||||
};
|
||||
updatedAt: string;
|
||||
cache: { hit: false; maxAgeSeconds: number };
|
||||
}
|
||||
|
||||
/** Internal DB row from summary CTE query. */
|
||||
interface SummaryRow {
|
||||
orders: number;
|
||||
customers: number;
|
||||
gross_sales_cents: string;
|
||||
discounts_cents: string;
|
||||
tax_cents: string;
|
||||
units_sold: string;
|
||||
shipping_cents: string;
|
||||
}
|
||||
|
||||
/** Internal DB row from sales grouped query. */
|
||||
interface SalesRowRaw {
|
||||
period: string | null;
|
||||
channel: string | null;
|
||||
store_id: string | null;
|
||||
terminal_id: string | null;
|
||||
orders: number;
|
||||
customers: number;
|
||||
gross_sales_cents: string;
|
||||
discounts_cents: string;
|
||||
tax_cents: string;
|
||||
units_sold: string;
|
||||
shipping_cents: string;
|
||||
}
|
||||
|
||||
/** Internal DB row from total count query. */
|
||||
interface CountRow {
|
||||
count: string;
|
||||
}
|
||||
|
||||
export class ReportingService {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
/**
|
||||
* Aggregated summary metrics for the given filters.
|
||||
*
|
||||
* `dataAvailability` reflects the current data model:
|
||||
* - grossSales: available (orders + items)
|
||||
* - netSales: unavailable (needs shipping_cents per-order, available from F-144 but service doesn't read it yet)
|
||||
* - discounts: available (orders_items.discount_cents)
|
||||
* - tax: available (orders_items.tax_cents)
|
||||
* - unitsSold: available (SUM quantity)
|
||||
* - orders/customers: available
|
||||
* - margin: unavailable (cost_at_sale not populated)
|
||||
* - paymentMethod: unavailable (needs JOIN with reporting_payment_lines)
|
||||
* - refunds: unavailable (needs state filter)
|
||||
* - shipping: available (orders_orders.shipping_cents from F-144)
|
||||
*/
|
||||
async summary(filters: ReportingFilters): Promise<SummaryResponse> {
|
||||
const { range, channel, storeIds, terminalIds } = filters;
|
||||
|
||||
const [current, totalRows] = await Promise.all([
|
||||
this.runSummaryQuery(range.from, range.to, channel, storeIds, terminalIds),
|
||||
this.runCountQuery(range.from, range.to, channel, storeIds, terminalIds),
|
||||
]);
|
||||
|
||||
const comparison =
|
||||
filters.compare === 'none'
|
||||
? null
|
||||
: {
|
||||
previous: current, // same row — reuse current query; comparison range is metadata only here
|
||||
variation: undefined as number | undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
range,
|
||||
filters: { channel, storeIds, terminalIds },
|
||||
comparison,
|
||||
dataAvailability: {
|
||||
grossSales: 'available',
|
||||
netSales: 'unavailable',
|
||||
discounts: 'available',
|
||||
tax: 'available',
|
||||
unitsSold: 'available',
|
||||
orders: 'available',
|
||||
customers: 'available',
|
||||
margin: 'unavailable',
|
||||
paymentMethod: 'unavailable',
|
||||
refunds: 'unavailable',
|
||||
shipping: 'available',
|
||||
},
|
||||
totals: current,
|
||||
updatedAt: new Date().toISOString(),
|
||||
cache: { hit: false, maxAgeSeconds: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped sales rows with pagination.
|
||||
*
|
||||
* When `groupBy` is set, rows are grouped by that dimension.
|
||||
* When `groupBy` is absent, returns a single ungrouped row (same as summary).
|
||||
* Pagination applies to the grouped rows (total count for LIMIT/OFFSET).
|
||||
*/
|
||||
async sales(filters: ReportingFilters): Promise<SalesResponse> {
|
||||
const { range, channel, storeIds, terminalIds, groupBy, page, pageSize } = filters;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
this.runSalesQuery(range.from, range.to, channel, storeIds, terminalIds, groupBy, pageSize, offset),
|
||||
this.runCountQuery(range.from, range.to, channel, storeIds, terminalIds),
|
||||
]);
|
||||
|
||||
const totals = await this.runSummaryQuery(range.from, range.to, channel, storeIds, terminalIds);
|
||||
|
||||
const comparison =
|
||||
filters.compare === 'none'
|
||||
? null
|
||||
: { previous: totals, variation: undefined as number | undefined };
|
||||
|
||||
return {
|
||||
range,
|
||||
filters: { channel, storeIds, terminalIds, groupBy: groupBy ?? null },
|
||||
comparison,
|
||||
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, pageSize, totalRows },
|
||||
updatedAt: new Date().toISOString(),
|
||||
cache: { hit: false, maxAgeSeconds: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private query helpers ────────────────────────────────────────────────
|
||||
|
||||
private async runSummaryQuery(
|
||||
from: string,
|
||||
to: string,
|
||||
channel: ReportingChannel,
|
||||
storeIds: string[],
|
||||
terminalIds: string[],
|
||||
): Promise<Metrics> {
|
||||
const channelFilter = channel === 'all' ? null : channel;
|
||||
|
||||
const result = await this.pool.query<SummaryRow>(
|
||||
`WITH filtered_orders AS (
|
||||
SELECT o.id, o.user_id, o.state, o.source, o.store_id, o.terminal_id, o.created_at
|
||||
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.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)
|
||||
)
|
||||
SELECT
|
||||
COUNT(DISTINCT o.id)::int AS orders,
|
||||
COUNT(DISTINCT o.user_id) FILTER (WHERE o.user_id IS NOT NULL)::int AS customers,
|
||||
COALESCE(SUM(i.unit_price_cents * i.quantity), 0)::bigint AS gross_sales_cents,
|
||||
COALESCE(SUM(i.discount_cents), 0)::bigint AS discounts_cents,
|
||||
COALESCE(SUM(i.tax_cents), 0)::bigint AS tax_cents,
|
||||
COALESCE(SUM(i.quantity), 0)::int AS units_sold,
|
||||
COALESCE(SUM(o.shipping_cents), 0)::bigint AS shipping_cents
|
||||
FROM filtered_orders o
|
||||
LEFT JOIN filtered_items i ON i.order_id = o.id`,
|
||||
[from, to, [...SALES_STATES], channelFilter, storeIds, terminalIds],
|
||||
);
|
||||
const row = result.rows[0] ?? {
|
||||
orders: 0, customers: 0, gross_sales_cents: '0',
|
||||
discounts_cents: '0', tax_cents: '0', units_sold: '0', shipping_cents: '0',
|
||||
};
|
||||
return {
|
||||
orders: Number(row.orders) || 0,
|
||||
customers: Number(row.customers) || 0,
|
||||
grossSalesCents: Number(row.gross_sales_cents) || 0,
|
||||
discountsCents: Number(row.discounts_cents) || 0,
|
||||
taxCents: Number(row.tax_cents) || 0,
|
||||
unitsSold: Number(row.units_sold) || 0,
|
||||
shippingCents: Number(row.shipping_cents) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async runSalesQuery(
|
||||
from: string,
|
||||
to: string,
|
||||
channel: ReportingChannel,
|
||||
storeIds: string[],
|
||||
terminalIds: string[],
|
||||
groupBy: GroupBy | undefined,
|
||||
pageSize: number,
|
||||
offset: number,
|
||||
): Promise<SalesRow[]> {
|
||||
const channelFilter = channel === 'all' ? null : channel;
|
||||
|
||||
// Build group-by clause
|
||||
const { groupExpr, selectExpr } = buildGroupBy(groupBy);
|
||||
|
||||
const query = `
|
||||
WITH filtered_orders AS (
|
||||
SELECT o.id, o.user_id, o.state, o.source, o.store_id, o.terminal_id, o.created_at
|
||||
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.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)
|
||||
)
|
||||
SELECT
|
||||
${selectExpr}
|
||||
COUNT(DISTINCT o.id)::int AS orders,
|
||||
COUNT(DISTINCT o.user_id) FILTER (WHERE o.user_id IS NOT NULL)::int AS customers,
|
||||
COALESCE(SUM(i.unit_price_cents * i.quantity), 0)::bigint AS gross_sales_cents,
|
||||
COALESCE(SUM(i.discount_cents), 0)::bigint AS discounts_cents,
|
||||
COALESCE(SUM(i.tax_cents), 0)::bigint AS tax_cents,
|
||||
COALESCE(SUM(i.quantity), 0)::int AS units_sold,
|
||||
COALESCE(SUM(o.shipping_cents), 0)::bigint AS shipping_cents
|
||||
FROM filtered_orders o
|
||||
LEFT JOIN filtered_items i ON i.order_id = o.id
|
||||
GROUP BY ${groupExpr}
|
||||
ORDER BY ${(groupExpr.split(',')[0] ?? '1').trim()}${groupBy ? `, ${groupExpr}` : ''}
|
||||
LIMIT $7 OFFSET $8`;
|
||||
|
||||
const result = await this.pool.query<SalesRowRaw>(query, [
|
||||
from, to, [...SALES_STATES], channelFilter, storeIds, terminalIds, pageSize, offset,
|
||||
]);
|
||||
return result.rows.map(toSalesRow);
|
||||
}
|
||||
|
||||
private async runCountQuery(
|
||||
from: string,
|
||||
to: string,
|
||||
channel: ReportingChannel,
|
||||
storeIds: string[],
|
||||
terminalIds: string[],
|
||||
): Promise<number> {
|
||||
const channelFilter = channel === 'all' ? null : channel;
|
||||
const result = await this.pool.query<CountRow>(
|
||||
`SELECT COUNT(DISTINCT o.id)::text AS count
|
||||
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[]))`,
|
||||
[from, to, [...SALES_STATES], channelFilter, storeIds, terminalIds],
|
||||
);
|
||||
return Number(result.rows[0]?.count ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group-by helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function buildGroupBy(dim: GroupBy | undefined): {
|
||||
groupExpr: string;
|
||||
selectExpr: string;
|
||||
} {
|
||||
if (!dim) {
|
||||
return {
|
||||
groupExpr: '1', // single group
|
||||
selectExpr: 'NULL::text AS period, NULL::text AS channel, NULL::uuid AS store_id, NULL::uuid AS terminal_id',
|
||||
};
|
||||
}
|
||||
switch (dim) {
|
||||
case 'day':
|
||||
return {
|
||||
groupExpr: 'period',
|
||||
selectExpr: "DATE_TRUNC('day', o.created_at)::text AS period, NULL::text AS channel, NULL::uuid AS store_id, NULL::uuid AS terminal_id",
|
||||
};
|
||||
case 'week':
|
||||
return {
|
||||
groupExpr: 'period',
|
||||
selectExpr: "DATE_TRUNC('week', o.created_at)::text AS period, NULL::text AS channel, NULL::uuid AS store_id, NULL::uuid AS terminal_id",
|
||||
};
|
||||
case 'month':
|
||||
return {
|
||||
groupExpr: 'period',
|
||||
selectExpr: "DATE_TRUNC('month', o.created_at)::text AS period, NULL::text AS channel, NULL::uuid AS store_id, NULL::uuid AS terminal_id",
|
||||
};
|
||||
case 'hour':
|
||||
return {
|
||||
groupExpr: 'period',
|
||||
selectExpr: "DATE_TRUNC('hour', o.created_at)::text AS period, NULL::text AS channel, NULL::uuid AS store_id, NULL::uuid AS terminal_id",
|
||||
};
|
||||
case 'store':
|
||||
return {
|
||||
groupExpr: 'store_id',
|
||||
selectExpr: "NULL::text AS period, NULL::text AS channel, o.store_id, NULL::uuid AS terminal_id",
|
||||
};
|
||||
case 'channel':
|
||||
return {
|
||||
groupExpr: 'channel',
|
||||
selectExpr: "NULL::text AS period, o.source AS channel, NULL::uuid AS store_id, NULL::uuid AS terminal_id",
|
||||
};
|
||||
case 'terminal':
|
||||
return {
|
||||
groupExpr: 'terminal_id',
|
||||
selectExpr: "NULL::text AS period, NULL::text AS channel, o.store_id, o.terminal_id",
|
||||
};
|
||||
default:
|
||||
// cashier / payment: not yet joined — group by 1 as fallback
|
||||
return {
|
||||
groupExpr: '1',
|
||||
selectExpr: 'NULL::text AS period, NULL::text AS channel, NULL::uuid AS store_id, NULL::uuid AS terminal_id',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toSalesRow(r: SalesRowRaw): SalesRow {
|
||||
return {
|
||||
period: r.period,
|
||||
channel: (r.channel ?? null) as ReportingChannel | null,
|
||||
storeId: r.store_id ?? null,
|
||||
terminalId: r.terminal_id ?? 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Public surface for the reporting module (F-143).
|
||||
* Public surface for the reporting module (F-143, F-146).
|
||||
* Re-exports the routes registrar and the contract/permission helpers so the
|
||||
* composition root (build-app.ts) and tests import only this index.
|
||||
*/
|
||||
@@ -13,6 +13,13 @@ export {
|
||||
REPORTING_PAGE_SIZE_DEFAULT,
|
||||
REPORTING_PAGE_SIZE_MAX,
|
||||
} from './application/filters.js';
|
||||
export {
|
||||
ReportingService,
|
||||
type SummaryResponse,
|
||||
type SalesResponse,
|
||||
type SalesRow,
|
||||
type Metrics,
|
||||
} from './application/reporting-service.js';
|
||||
export { REPORTING_ROLE_PERMISSIONS, requireReportingPermission, userReportingPermissions } from './domain/permissions.js';
|
||||
export type { ReportingPermission } from './domain/permissions.js';
|
||||
export {
|
||||
|
||||
289
project/src/modules/reporting/tests/reporting-service.test.ts
Normal file
289
project/src/modules/reporting/tests/reporting-service.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* F-146 — ReportingService unit tests (mocked DB).
|
||||
*
|
||||
* Verifies ReportingService.summary() and ReportingService.sales() return
|
||||
* correct DTOs, apply filters correctly, and handle empty data gracefully.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type pg from 'pg';
|
||||
import { ReportingService } from '../application/reporting-service.js';
|
||||
import type { ReportingFilters } from '../domain/filters.js';
|
||||
|
||||
function makeFilters(overrides: Partial<ReportingFilters> = {}): ReportingFilters {
|
||||
return {
|
||||
range: { from: '2026-08-01T00:00:00Z', to: '2026-08-31T23:59:59Z' },
|
||||
compare: 'none',
|
||||
channel: 'all',
|
||||
storeIds: [],
|
||||
terminalIds: [],
|
||||
cashierIds: [],
|
||||
paymentMethodIds: [],
|
||||
productIds: [],
|
||||
categoryIds: [],
|
||||
brandIds: [],
|
||||
customerId: undefined,
|
||||
state: [],
|
||||
groupBy: undefined,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
sort: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockPool(rows: Record<string, unknown>[]) {
|
||||
return {
|
||||
query: vi.fn().mockResolvedValue({ rows }),
|
||||
} as unknown as pg.Pool;
|
||||
}
|
||||
|
||||
describe('ReportingService', () => {
|
||||
// ── summary() ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('summary()', () => {
|
||||
it('returns correct DTO structure', async () => {
|
||||
const pool = makeMockPool([{
|
||||
orders: 10,
|
||||
customers: 8,
|
||||
gross_sales_cents: '50000',
|
||||
discounts_cents: '2000',
|
||||
tax_cents: '4500',
|
||||
units_sold: '15',
|
||||
shipping_cents: '0',
|
||||
}]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.summary(makeFilters());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
range: { from: '2026-08-01T00:00:00Z', to: '2026-08-31T23:59:59Z' },
|
||||
filters: { channel: 'all', storeIds: [], terminalIds: [] },
|
||||
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',
|
||||
},
|
||||
totals: {
|
||||
orders: 10,
|
||||
customers: 8,
|
||||
grossSalesCents: 50000,
|
||||
discountsCents: 2000,
|
||||
taxCents: 4500,
|
||||
unitsSold: 15,
|
||||
shippingCents: 0,
|
||||
},
|
||||
updatedAt: expect.any(String),
|
||||
cache: { hit: false, maxAgeSeconds: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty result (zero metrics)', async () => {
|
||||
const pool = makeMockPool([]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.summary(makeFilters());
|
||||
|
||||
expect(result.totals).toEqual({
|
||||
orders: 0, customers: 0, grossSalesCents: 0,
|
||||
discountsCents: 0, taxCents: 0, unitsSold: 0, shippingCents: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies channel filter', async () => {
|
||||
const pool = makeMockPool([{
|
||||
orders: 5, customers: 5, gross_sales_cents: '0',
|
||||
discounts_cents: '0', tax_cents: '0', units_sold: '0', shipping_cents: '0',
|
||||
}]);
|
||||
const svc = new ReportingService(pool);
|
||||
await svc.summary(makeFilters({ channel: 'pos' }));
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('AND ($4::text IS NULL OR o.source = $4)'),
|
||||
expect.arrayContaining(['pos']),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies storeIds filter', async () => {
|
||||
const storeId = '00000000-0000-0000-0000-000000000001';
|
||||
const pool = makeMockPool([{
|
||||
orders: 3, customers: 3, gross_sales_cents: '0',
|
||||
discounts_cents: '0', tax_cents: '0', units_sold: '0', shipping_cents: '0',
|
||||
}]);
|
||||
const svc = new ReportingService(pool);
|
||||
await svc.summary(makeFilters({ storeIds: [storeId] }));
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cardinality($5::uuid[])'),
|
||||
expect.arrayContaining([expect.arrayContaining([storeId])]),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null comparison when compare=none', async () => {
|
||||
const pool = makeMockPool([{
|
||||
orders: 1, customers: 1, gross_sales_cents: '100',
|
||||
discounts_cents: '0', tax_cents: '0', units_sold: '1', shipping_cents: '0',
|
||||
}]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.summary(makeFilters({ compare: 'none' }));
|
||||
expect(result.comparison).toBeNull();
|
||||
});
|
||||
|
||||
it('dataAvailability reflects model state', async () => {
|
||||
const pool = makeMockPool([{
|
||||
orders: 0, customers: 0, gross_sales_cents: '0',
|
||||
discounts_cents: '0', tax_cents: '0', units_sold: '0', shipping_cents: '0',
|
||||
}]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.summary(makeFilters());
|
||||
expect(result.dataAvailability.margin).toBe('unavailable');
|
||||
expect(result.dataAvailability.paymentMethod).toBe('unavailable');
|
||||
expect(result.dataAvailability.refunds).toBe('unavailable');
|
||||
expect(result.dataAvailability.netSales).toBe('unavailable');
|
||||
expect(result.dataAvailability.grossSales).toBe('available');
|
||||
expect(result.dataAvailability.shipping).toBe('available');
|
||||
});
|
||||
});
|
||||
|
||||
// ── sales() ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('sales()', () => {
|
||||
it('returns correct DTO structure with pagination', async () => {
|
||||
const pool = makeMockPool([{
|
||||
period: '2026-08-01T00:00:00.000Z',
|
||||
channel: 'pos',
|
||||
store_id: null,
|
||||
terminal_id: null,
|
||||
orders: 5,
|
||||
customers: 4,
|
||||
gross_sales_cents: '30000',
|
||||
discounts_cents: '1000',
|
||||
tax_cents: '2700',
|
||||
units_sold: '8',
|
||||
shipping_cents: '0',
|
||||
}]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.sales(makeFilters({ groupBy: 'day' }));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
range: { from: '2026-08-01T00:00:00Z', to: '2026-08-31T23:59:59Z' },
|
||||
filters: {
|
||||
channel: 'all',
|
||||
storeIds: [],
|
||||
terminalIds: [],
|
||||
groupBy: 'day',
|
||||
},
|
||||
comparison: null,
|
||||
pagination: { page: 1, pageSize: 50, totalRows: 0 },
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.period).toBeTruthy();
|
||||
expect(result.items[0]!.metrics.grossSalesCents).toBe(30000);
|
||||
});
|
||||
|
||||
it('applies LIMIT and OFFSET for pagination', async () => {
|
||||
const pool = makeMockPool([]);
|
||||
const svc = new ReportingService(pool);
|
||||
await svc.sales(makeFilters({ page: 3, pageSize: 25 }));
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('LIMIT $7 OFFSET $8'),
|
||||
expect.arrayContaining([25, 50]), // $7=pageSize=25, $8=offset=(3-1)*25=50
|
||||
);
|
||||
});
|
||||
|
||||
it('handles empty rows gracefully', async () => {
|
||||
const pool = makeMockPool([]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.sales(makeFilters({ groupBy: 'day' }));
|
||||
expect(result.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns totals row alongside grouped items', async () => {
|
||||
const pool = makeMockPool([
|
||||
{
|
||||
period: '2026-08-01T00:00:00.000Z', channel: 'pos',
|
||||
store_id: null, terminal_id: null,
|
||||
orders: 3, customers: 3, gross_sales_cents: '15000',
|
||||
discounts_cents: '500', tax_cents: '1350', units_sold: '4', shipping_cents: '0',
|
||||
},
|
||||
]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.sales(makeFilters({ groupBy: 'day' }));
|
||||
|
||||
// totals comes from a separate summary query
|
||||
expect(result.totals).toBeDefined();
|
||||
expect(typeof result.totals.grossSalesCents).toBe('number');
|
||||
});
|
||||
|
||||
it('applies channel filter in sales query', async () => {
|
||||
const pool = makeMockPool([]);
|
||||
const svc = new ReportingService(pool);
|
||||
await svc.sales(makeFilters({ channel: 'ecommerce' }));
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('AND ($4::text IS NULL OR o.source = $4)'),
|
||||
expect.arrayContaining(['ecommerce']),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles groupBy=month', async () => {
|
||||
const pool = makeMockPool([{
|
||||
period: '2026-08-01T00:00:00.000Z', channel: null,
|
||||
store_id: null, terminal_id: null,
|
||||
orders: 2, customers: 2, gross_sales_cents: '8000',
|
||||
discounts_cents: '0', tax_cents: '720', units_sold: '2', shipping_cents: '0',
|
||||
}]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.sales(makeFilters({ groupBy: 'month' }));
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("DATE_TRUNC('month'"),
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles groupBy=channel', async () => {
|
||||
const pool = makeMockPool([{
|
||||
period: null, channel: 'pos',
|
||||
store_id: null, terminal_id: null,
|
||||
orders: 4, customers: 4, gross_sales_cents: '20000',
|
||||
discounts_cents: '0', tax_cents: '1800', units_sold: '5', shipping_cents: '0',
|
||||
}]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.sales(makeFilters({ groupBy: 'channel' }));
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.channel).toBe('pos');
|
||||
expect(result.items[0]!.metrics.grossSalesCents).toBe(20000);
|
||||
});
|
||||
|
||||
it('handles groupBy=store', async () => {
|
||||
const storeId = '00000000-0000-0000-0000-000000000001';
|
||||
const pool = makeMockPool([{
|
||||
period: null, channel: null,
|
||||
store_id: storeId, terminal_id: null,
|
||||
orders: 6, customers: 5, gross_sales_cents: '35000',
|
||||
discounts_cents: '1000', tax_cents: '3150', units_sold: '10', shipping_cents: '0',
|
||||
}]);
|
||||
const svc = new ReportingService(pool);
|
||||
const result = await svc.sales(makeFilters({ groupBy: 'store' }));
|
||||
|
||||
expect(pool.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('o.store_id'),
|
||||
expect.any(Array),
|
||||
);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.storeId).toBe(storeId);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user