feat(POS-004): completed feature
This commit is contained in:
@@ -43,6 +43,7 @@ import { registerCheckoutRoutes } from '../modules/checkout/index.js';
|
||||
import { registerPaymentsRoutes } from '../modules/payments/index.js';
|
||||
import { registerNotificationsRoutes } from '../modules/notifications/index.js';
|
||||
import { registerReportingRoutes } from '../modules/reporting/index.js';
|
||||
import { registerPosRoutes } from '../modules/pos/api/pos.routes.js';
|
||||
import { registerReviewsRoutes } from '../modules/reviews/index.js';
|
||||
import { registerCmsRoutes } from '../modules/cms/index.js';
|
||||
import { registerStoreSettingsRoutes } from '../modules/store-settings/index.js';
|
||||
@@ -322,6 +323,16 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
});
|
||||
});
|
||||
|
||||
// POS routes (POS-004)
|
||||
if (deps.pool && combinedAuth) {
|
||||
await app.register(async (instance) => {
|
||||
await registerPosRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const { telemetry, meter } = createInMemoryTelemetry();
|
||||
|
||||
await app.register(async (instance) => {
|
||||
|
||||
328
project/src/modules/pos/api/pos.routes.ts
Normal file
328
project/src/modules/pos/api/pos.routes.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import type { FastifyInstance, FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import type { CurrentUser, Role } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { requireRole, requireAnyRole } from '../../../shared/auth.js';
|
||||
import { z } from 'zod';
|
||||
import { ListStoresUseCase } from '../application/list-stores.js';
|
||||
import { ListTerminalsUseCase } from '../application/list-terminals.js';
|
||||
import { GetPosConfigUseCase } from '../application/get-pos-config.js';
|
||||
import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
|
||||
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
|
||||
import { PgStoreRepository } from '../infrastructure/pg-store-repository.js';
|
||||
import { PgTerminalRepository } from '../infrastructure/pg-terminal-repository.js';
|
||||
import { PgPaymentMethodRepository } from '../infrastructure/pg-payment-method-repository.js';
|
||||
import { PgCashSessionRepository } from '../infrastructure/pg-cash-session-repository.js';
|
||||
|
||||
export interface PosRouteDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: (request: import('fastify').FastifyRequest) => Promise<CurrentUser>;
|
||||
}
|
||||
|
||||
const idParamSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
|
||||
const { pool, authenticate } = deps;
|
||||
|
||||
const storeRepo = new PgStoreRepository(pool);
|
||||
const terminalRepo = new PgTerminalRepository(pool);
|
||||
const paymentMethodRepo = new PgPaymentMethodRepository(pool);
|
||||
const sessionRepo = new PgCashSessionRepository(pool);
|
||||
|
||||
const listStores = new ListStoresUseCase(storeRepo);
|
||||
const listTerminals = new ListTerminalsUseCase(terminalRepo);
|
||||
const getConfig = new GetPosConfigUseCase(storeRepo, terminalRepo, paymentMethodRepo, sessionRepo);
|
||||
const openSession = new OpenCashSessionUseCase(sessionRepo, terminalRepo);
|
||||
const closeSession = new CloseCashSessionUseCase(sessionRepo);
|
||||
|
||||
// ── Admin: stores ─────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/pos/admin/stores', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'List POS stores',
|
||||
querystring: { type: 'object', properties: { active: { type: 'boolean' } } },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { active } = request.query as { active?: boolean };
|
||||
const result = await listStores.execute({ active });
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
app.post('/pos/admin/stores', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Create POS store',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name', 'slug'],
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
slug: { type: 'string', pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' },
|
||||
address: { type: 'string' },
|
||||
taxId: { type: 'string' },
|
||||
contactEmail: { type: 'string' },
|
||||
contactPhone: { type: 'string' },
|
||||
receiptHeader: { type: 'string' },
|
||||
receiptFooter: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const body = parseJson(
|
||||
z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
address: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
contactEmail: z.string().optional(),
|
||||
contactPhone: z.string().optional(),
|
||||
receiptHeader: z.string().optional(),
|
||||
receiptFooter: z.string().optional(),
|
||||
}),
|
||||
request.body ?? {},
|
||||
);
|
||||
const result = await pool.query<{ id: string; name: string; slug: string; active: boolean }>(
|
||||
`INSERT INTO pos_stores (name, slug, address, tax_id, contact_email, contact_phone, receipt_header, receipt_footer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, name, slug, active`,
|
||||
[body.name, body.slug, body.address, body.taxId, body.contactEmail, body.contactPhone, body.receiptHeader, body.receiptFooter],
|
||||
);
|
||||
return reply.code(201).send(result.rows[0]);
|
||||
});
|
||||
|
||||
// ── Admin: terminals ─────────────────────────────────────────────────────
|
||||
|
||||
app.get('/pos/admin/terminals', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'List POS terminals',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
storeId: { type: 'string', format: 'uuid' },
|
||||
status: { type: 'string', enum: ['active', 'disabled', 'decommissioned'] },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { storeId, status } = request.query as { storeId?: string; status?: string };
|
||||
const result = await listTerminals.execute({ storeId, status: status as 'active' | 'disabled' | 'decommissioned' | undefined });
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
app.post('/pos/admin/terminals', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Create POS terminal',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['storeId', 'name'],
|
||||
properties: {
|
||||
storeId: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
},
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const body = parseJson(
|
||||
z.object({ storeId: z.string().uuid(), name: z.string().min(1).max(100) }),
|
||||
request.body ?? {},
|
||||
);
|
||||
// Generate a short binding code (8 hex chars)
|
||||
const bindingCode = Math.random().toString(16).slice(2, 10).toUpperCase();
|
||||
const result = await pool.query<{ id: string; name: string; bindingCode: string; storeId: string }>(
|
||||
`INSERT INTO pos_terminals (store_id, name, binding_code)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, binding_code as "bindingCode", store_id as "storeId"`,
|
||||
[body.storeId, body.name, bindingCode],
|
||||
);
|
||||
return reply.code(201).send(result.rows[0]);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/pos/admin/terminals/:id', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Get terminal',
|
||||
params: idParamSchema,
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const terminal = await terminalRepo.findById(id);
|
||||
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
|
||||
return reply.send(terminal);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/pos/admin/terminals/:id', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Decommission terminal',
|
||||
params: idParamSchema,
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
await pool.query(`UPDATE pos_terminals SET status = 'decommissioned' WHERE id = $1`, [id]);
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// ── Terminal: me + bind + config ───────────────────────────────────────
|
||||
|
||||
app.get('/pos/terminals/me', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Get current terminal info',
|
||||
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const terminalId = request.headers['x-terminal-id'] as string | undefined;
|
||||
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
|
||||
const terminal = await terminalRepo.findById(terminalId);
|
||||
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
|
||||
return reply.send(terminal);
|
||||
});
|
||||
|
||||
app.post('/pos/terminals/bind', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Bind terminal with code',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['bindingCode'],
|
||||
properties: { bindingCode: { type: 'string', minLength: 8, maxLength: 8 } },
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const body = parseJson(z.object({ bindingCode: z.string().length(8) }), request.body ?? {});
|
||||
const terminal = await terminalRepo.findByBindingCode(body.bindingCode.toUpperCase());
|
||||
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
|
||||
if (terminal.status !== 'active') throw new AppError(409, 'TERMINAL_NOT_ACTIVE', 'Terminal is not active');
|
||||
const bound = await terminalRepo.bind(terminal.id, body.bindingCode.toUpperCase());
|
||||
return reply.send({ terminalId: bound.id, storeId: bound.storeId });
|
||||
});
|
||||
|
||||
app.get('/pos/config', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Get POS terminal config',
|
||||
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const terminalId = request.headers['x-terminal-id'] as string | undefined;
|
||||
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
|
||||
const config = await getConfig.execute(terminalId);
|
||||
return reply.send(config);
|
||||
});
|
||||
|
||||
// ── Cash sessions ───────────────────────────────────────────────────────
|
||||
|
||||
app.get('/pos/sessions/me', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Get current open session',
|
||||
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const terminalId = request.headers['x-terminal-id'] as string | undefined;
|
||||
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
|
||||
const session = await sessionRepo.findOpenByTerminal(terminalId);
|
||||
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'No open session');
|
||||
return reply.send(session);
|
||||
});
|
||||
|
||||
app.post('/pos/sessions', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Open cash session',
|
||||
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['openingCashCents'],
|
||||
properties: { openingCashCents: { type: 'integer', minimum: 0 } },
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const terminalId = request.headers['x-terminal-id'] as string | undefined;
|
||||
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
|
||||
const body = parseJson(z.object({ openingCashCents: z.number().int().min(0) }), request.body ?? {});
|
||||
try {
|
||||
const session = await openSession.execute({ terminalId, userId: user.id, openingCashCents: body.openingCashCents });
|
||||
return reply.code(201).send(session);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) throw err;
|
||||
throw new AppError(409, 'SESSION_ERROR', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/pos/sessions/:id/close', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Close cash session',
|
||||
params: idParamSchema,
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['closingCashCents', 'actualCashCents'],
|
||||
properties: {
|
||||
closingCashCents: { type: 'integer', minimum: 0 },
|
||||
actualCashCents: { type: 'integer', minimum: 0 },
|
||||
notes: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const body = parseJson(
|
||||
z.object({
|
||||
closingCashCents: z.number().int().min(0),
|
||||
actualCashCents: z.number().int().min(0),
|
||||
notes: z.string().optional(),
|
||||
}),
|
||||
request.body ?? {},
|
||||
);
|
||||
try {
|
||||
const session = await closeSession.execute({ sessionId: id, ...body });
|
||||
return reply.send(session);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) throw err;
|
||||
throw new AppError(409, 'CLOSE_ERROR', String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { PosStoreRepository } from '../domain/ports.js';
|
||||
import type { PosTerminalRepository } from '../domain/ports.js';
|
||||
import type { PosStoreRepository, PosTerminalRepository, PosCashSessionRepository } from '../domain/ports.js';
|
||||
import type { PosPaymentMethodRepository } from '../infrastructure/pg-payment-method-repository.js';
|
||||
import type { PosCashSessionRepository } from '../domain/ports.js';
|
||||
import type { PosStore } from '../domain/store.js';
|
||||
import type { PosTerminal } from '../domain/terminal.js';
|
||||
import type { PosPaymentMethod } from '../infrastructure/pg-payment-method-repository.js';
|
||||
|
||||
@@ -73,6 +73,7 @@ export class PgCashSessionRepository implements PosCashSessionRepository {
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[input.terminalId, storeId, input.userId, input.openingCashCents],
|
||||
);
|
||||
if (!result.rows[0]) throw new Error('Failed to create session');
|
||||
return toSession(result.rows[0]);
|
||||
}
|
||||
|
||||
@@ -108,7 +109,7 @@ export class PgCashSessionRepository implements PosCashSessionRepository {
|
||||
]);
|
||||
return {
|
||||
sessions: listResult.rows.map(toSession),
|
||||
total: parseInt(countResult.rows[0].count, 10),
|
||||
total: parseInt(countResult.rows[0]?.count ?? '0', 10),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type pg from 'pg';
|
||||
|
||||
export type PaymentMethodKind = 'cash' | 'card' | 'other';
|
||||
|
||||
export interface PosPaymentMethodRepository {
|
||||
listByStore(storeId: string): Promise<PosPaymentMethod[]>;
|
||||
}
|
||||
|
||||
export interface PosPaymentMethod {
|
||||
id: string;
|
||||
storeId: string;
|
||||
@@ -43,7 +47,7 @@ function toPaymentMethod(row: PaymentMethodRow): PosPaymentMethod {
|
||||
};
|
||||
}
|
||||
|
||||
export class PgPaymentMethodRepository {
|
||||
export class PgPaymentMethodRepository implements PosPaymentMethodRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async listByStore(storeId: string): Promise<PosPaymentMethod[]> {
|
||||
|
||||
@@ -50,23 +50,23 @@ export class PgStoreRepository implements PosStoreRepository {
|
||||
async list(options: ListStoresOptions = {}): Promise<{ stores: PosStore[]; total: number }> {
|
||||
const { active = true, limit = 50, offset = 0 } = options;
|
||||
const where = active !== undefined ? 'WHERE active = $1' : '';
|
||||
const params = active !== undefined ? [active] : [];
|
||||
params.push(limit, offset);
|
||||
const countParams: number[] = active !== undefined ? [active ? 1 : 0] : [];
|
||||
const listParams: number[] = [...countParams, limit, offset];
|
||||
|
||||
const [countResult, listResult] = await Promise.all([
|
||||
this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) as count FROM pos_stores ${where}`,
|
||||
params.slice(0, active !== undefined ? 1 : 0),
|
||||
countParams,
|
||||
),
|
||||
this.pool.query<StoreRow>(
|
||||
`SELECT * FROM pos_stores ${where} ORDER BY name LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params,
|
||||
`SELECT * FROM pos_stores ${where} ORDER BY name LIMIT $${listParams.length - 1} OFFSET $${listParams.length}`,
|
||||
listParams,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
stores: listResult.rows.map(toStore),
|
||||
total: parseInt(countResult.rows[0].count, 10),
|
||||
total: parseInt(countResult.rows[0]?.count ?? '0', 10),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export class PgTerminalRepository implements PosTerminalRepository {
|
||||
|
||||
return {
|
||||
terminals: listResult.rows.map(toTerminal),
|
||||
total: parseInt(countResult.rows[0].count, 10),
|
||||
total: parseInt(countResult.rows[0]?.count ?? '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('ListStoresUseCase', () => {
|
||||
const result = await uc.execute({ active: true });
|
||||
expect(result.stores).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.stores[0].name).toBe('Tienda 1');
|
||||
expect(result.stores[0]?.name).toBe('Tienda 1');
|
||||
});
|
||||
|
||||
it('passes options to repository', async () => {
|
||||
|
||||
@@ -152,6 +152,32 @@ interface CountRow {
|
||||
count: string;
|
||||
}
|
||||
|
||||
/** F-149: Product ranking row (module-level, not inside class). */
|
||||
interface ProductRow {
|
||||
productId: string;
|
||||
productName: string;
|
||||
sku: string | null;
|
||||
category: string | null;
|
||||
brand: string | null;
|
||||
metrics: Metrics;
|
||||
}
|
||||
|
||||
/** F-149: Raw DB row for product rankings. */
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
/** F-149: Products ranking response. */
|
||||
export interface ProductsResponse extends Omit<SalesResponse, 'items'> {
|
||||
@@ -330,7 +356,7 @@ export class ReportingService {
|
||||
const channelFilter = channel === 'all' ? null : channel;
|
||||
|
||||
// Build group-by clause
|
||||
const { groupExpr, selectExpr } = buildGroupBy(groupBy);
|
||||
const { groupExpr, selectExpr } = this.buildGroupBy(groupBy);
|
||||
|
||||
const query = `
|
||||
WITH filtered_orders AS (
|
||||
@@ -366,7 +392,7 @@ export class ReportingService {
|
||||
const result = await this.pool.query<SalesRowRaw>(query, [
|
||||
from, to, [...SALES_STATES], channelFilter, storeIds, terminalIds, pageSize, offset,
|
||||
]);
|
||||
return result.rows.map(toSalesRow);
|
||||
return result.rows.map(this.toSalesRow);
|
||||
}
|
||||
|
||||
private async runCountQuery(
|
||||
@@ -390,37 +416,9 @@ export class ReportingService {
|
||||
);
|
||||
return Number(result.rows[0]?.count ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
@@ -535,75 +533,76 @@ export class ReportingService {
|
||||
}
|
||||
|
||||
|
||||
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':
|
||||
private 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private toSalesRow(r: SalesRowRaw): SalesRow {
|
||||
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',
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user