erp-core-backend-v2/src/modules/reports/services/index.ts
Adrian Flores Cortes 6c6ce41343 [TASK-005] feat: Implement reports backend module (entities, services, controllers)
- Add 12 TypeORM entities matching DDL 31-reports.sql:
  - ReportDefinition, ReportExecution, ReportSchedule
  - ReportRecipient, ScheduleExecution
  - Dashboard, DashboardWidget, WidgetQuery
  - CustomReport, DataModelEntity, DataModelField, DataModelRelationship
- Add enums: ReportType, ExecutionStatus, ExportFormat, DeliveryMethod, WidgetType, ParamType, FilterOperator
- Add DTOs for CRUD operations and filtering
- Add services:
  - ReportsService: definitions, schedules, recipients, custom reports
  - ReportExecutionService: execute reports, history, cancellation
  - ReportSchedulerService: scheduled execution, delivery
  - DashboardsService: dashboards, widgets, queries
- Add controllers:
  - ReportsController: full CRUD for definitions, schedules, executions
  - DashboardsController: dashboards, widgets, widget queries
- Update module and routes with new structure
- Maintain backwards compatibility with legacy service

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:51:51 -06:00

534 lines
16 KiB
TypeScript

// Re-export new services
export { ReportsService } from './reports.service';
export { ReportExecutionService } from './report-execution.service';
export { ReportSchedulerService } from './report-scheduler.service';
export { DashboardsService } from './dashboards.service';
// Legacy types and service (kept for backwards compatibility)
import { DataSource } from 'typeorm';
export interface ReportDateRange {
startDate: Date;
endDate: Date;
}
export interface SalesReportParams {
tenantId: string;
startDate: Date;
endDate: Date;
partnerId?: string;
productId?: string;
groupBy?: 'day' | 'week' | 'month' | 'partner' | 'product';
}
export interface InventoryReportParams {
tenantId: string;
warehouseId?: string;
productId?: string;
categoryId?: string;
lowStockOnly?: boolean;
}
export interface FinancialReportParams {
tenantId: string;
startDate: Date;
endDate: Date;
reportType: 'income' | 'expenses' | 'profit_loss' | 'cash_flow' | 'accounts_receivable' | 'accounts_payable';
}
export interface SalesSummary {
totalOrders: number;
totalRevenue: number;
averageOrderValue: number;
totalItems: number;
}
export interface InventorySummary {
totalProducts: number;
totalValue: number;
lowStockItems: number;
outOfStockItems: number;
}
export interface FinancialSummary {
totalIncome: number;
totalExpenses: number;
netProfit: number;
margin: number;
}
export class LegacyReportsService {
constructor(private readonly dataSource: DataSource) {}
// ==================== Sales Reports ====================
async getSalesReport(params: SalesReportParams): Promise<{
summary: SalesSummary;
data: any[];
period: ReportDateRange;
}> {
const { tenantId, startDate, endDate, partnerId, productId, groupBy = 'day' } = params;
// Build query based on groupBy
let query = `
SELECT
COUNT(DISTINCT o.id) as order_count,
SUM(o.total) as total_revenue,
SUM(oi.quantity) as total_items
FROM sales.sales_orders o
LEFT JOIN sales.sales_order_items oi ON o.id = oi.order_id
WHERE o.tenant_id = $1
AND o.status NOT IN ('cancelled', 'draft')
AND o.order_date BETWEEN $2 AND $3
`;
const queryParams: any[] = [tenantId, startDate, endDate];
let paramIndex = 4;
if (partnerId) {
query += ` AND o.partner_id = $${paramIndex}`;
queryParams.push(partnerId);
paramIndex++;
}
if (productId) {
query += ` AND oi.product_id = $${paramIndex}`;
queryParams.push(productId);
}
try {
const result = await this.dataSource.query(query, queryParams);
const row = result[0] || {};
const summary: SalesSummary = {
totalOrders: parseInt(row.order_count) || 0,
totalRevenue: parseFloat(row.total_revenue) || 0,
averageOrderValue: row.order_count > 0 ? parseFloat(row.total_revenue) / parseInt(row.order_count) : 0,
totalItems: parseInt(row.total_items) || 0,
};
// Get detailed data grouped by period
const detailQuery = this.buildSalesDetailQuery(groupBy);
const detailParams = [tenantId, startDate, endDate];
const detailData = await this.dataSource.query(detailQuery, detailParams);
return {
summary,
data: detailData,
period: { startDate, endDate },
};
} catch (error) {
// Return empty data if tables don't exist yet
return {
summary: { totalOrders: 0, totalRevenue: 0, averageOrderValue: 0, totalItems: 0 },
data: [],
period: { startDate, endDate },
};
}
}
private buildSalesDetailQuery(groupBy: string): string {
const groupByClause = {
day: "DATE_TRUNC('day', o.order_date)",
week: "DATE_TRUNC('week', o.order_date)",
month: "DATE_TRUNC('month', o.order_date)",
partner: 'o.partner_id',
product: 'oi.product_id',
}[groupBy] || "DATE_TRUNC('day', o.order_date)";
return `
SELECT
${groupByClause} as period,
COUNT(DISTINCT o.id) as order_count,
SUM(o.total) as total_revenue
FROM sales.sales_orders o
LEFT JOIN sales.sales_order_items oi ON o.id = oi.order_id
WHERE o.tenant_id = $1
AND o.status NOT IN ('cancelled', 'draft')
AND o.order_date BETWEEN $2 AND $3
GROUP BY ${groupByClause}
ORDER BY period DESC
`;
}
async getTopSellingProducts(
tenantId: string,
startDate: Date,
endDate: Date,
limit: number = 10
): Promise<any[]> {
try {
const query = `
SELECT
oi.product_id,
oi.product_name,
SUM(oi.quantity) as total_quantity,
SUM(oi.line_total) as total_revenue
FROM sales.sales_order_items oi
JOIN sales.sales_orders o ON oi.order_id = o.id
WHERE o.tenant_id = $1
AND o.status NOT IN ('cancelled', 'draft')
AND o.order_date BETWEEN $2 AND $3
GROUP BY oi.product_id, oi.product_name
ORDER BY total_revenue DESC
LIMIT $4
`;
return await this.dataSource.query(query, [tenantId, startDate, endDate, limit]);
} catch {
return [];
}
}
async getTopCustomers(
tenantId: string,
startDate: Date,
endDate: Date,
limit: number = 10
): Promise<any[]> {
try {
const query = `
SELECT
o.partner_id,
o.partner_name,
COUNT(o.id) as order_count,
SUM(o.total) as total_revenue
FROM sales.sales_orders o
WHERE o.tenant_id = $1
AND o.status NOT IN ('cancelled', 'draft')
AND o.order_date BETWEEN $2 AND $3
GROUP BY o.partner_id, o.partner_name
ORDER BY total_revenue DESC
LIMIT $4
`;
return await this.dataSource.query(query, [tenantId, startDate, endDate, limit]);
} catch {
return [];
}
}
// ==================== Inventory Reports ====================
async getInventoryReport(params: InventoryReportParams): Promise<{
summary: InventorySummary;
data: any[];
}> {
const { tenantId, warehouseId, productId, categoryId, lowStockOnly } = params;
try {
let query = `
SELECT
COUNT(DISTINCT sl.product_id) as total_products,
SUM(sl.quantity_on_hand * COALESCE(sl.unit_cost, 0)) as total_value,
COUNT(CASE WHEN sl.quantity_on_hand <= 0 THEN 1 END) as out_of_stock,
COUNT(CASE WHEN sl.quantity_on_hand > 0 AND sl.quantity_on_hand <= 10 THEN 1 END) as low_stock
FROM inventory.stock_levels sl
WHERE sl.tenant_id = $1
`;
const queryParams: any[] = [tenantId];
let paramIndex = 2;
if (warehouseId) {
query += ` AND sl.warehouse_id = $${paramIndex}`;
queryParams.push(warehouseId);
paramIndex++;
}
if (productId) {
query += ` AND sl.product_id = $${paramIndex}`;
queryParams.push(productId);
}
const result = await this.dataSource.query(query, queryParams);
const row = result[0] || {};
const summary: InventorySummary = {
totalProducts: parseInt(row.total_products) || 0,
totalValue: parseFloat(row.total_value) || 0,
lowStockItems: parseInt(row.low_stock) || 0,
outOfStockItems: parseInt(row.out_of_stock) || 0,
};
// Get detailed stock levels
let detailQuery = `
SELECT
sl.product_id,
p.name as product_name,
p.sku,
sl.warehouse_id,
w.name as warehouse_name,
sl.quantity_on_hand,
sl.quantity_reserved,
sl.quantity_available,
sl.unit_cost,
(sl.quantity_on_hand * COALESCE(sl.unit_cost, 0)) as total_value
FROM inventory.stock_levels sl
LEFT JOIN products.products p ON sl.product_id = p.id
LEFT JOIN inventory.warehouses w ON sl.warehouse_id = w.id
WHERE sl.tenant_id = $1
`;
const detailParams: any[] = [tenantId];
let detailIndex = 2;
if (warehouseId) {
detailQuery += ` AND sl.warehouse_id = $${detailIndex}`;
detailParams.push(warehouseId);
detailIndex++;
}
if (lowStockOnly) {
detailQuery += ` AND sl.quantity_on_hand <= 10`;
}
detailQuery += ` ORDER BY sl.quantity_on_hand ASC LIMIT 100`;
const detailData = await this.dataSource.query(detailQuery, detailParams);
return { summary, data: detailData };
} catch {
return {
summary: { totalProducts: 0, totalValue: 0, lowStockItems: 0, outOfStockItems: 0 },
data: [],
};
}
}
async getStockMovementReport(
tenantId: string,
startDate: Date,
endDate: Date,
warehouseId?: string
): Promise<any[]> {
try {
let query = `
SELECT
sm.movement_type,
COUNT(*) as movement_count,
SUM(sm.quantity) as total_quantity,
SUM(sm.total_cost) as total_value
FROM inventory.stock_movements sm
WHERE sm.tenant_id = $1
AND sm.status = 'confirmed'
AND sm.created_at BETWEEN $2 AND $3
`;
const params: any[] = [tenantId, startDate, endDate];
if (warehouseId) {
query += ` AND (sm.source_warehouse_id = $4 OR sm.dest_warehouse_id = $4)`;
params.push(warehouseId);
}
query += ` GROUP BY sm.movement_type ORDER BY total_quantity DESC`;
return await this.dataSource.query(query, params);
} catch {
return [];
}
}
// ==================== Financial Reports ====================
async getFinancialReport(params: FinancialReportParams): Promise<{
summary: FinancialSummary;
data: any[];
period: ReportDateRange;
}> {
const { tenantId, startDate, endDate, reportType } = params;
try {
switch (reportType) {
case 'income':
return await this.getIncomeReport(tenantId, startDate, endDate);
case 'expenses':
return await this.getExpensesReport(tenantId, startDate, endDate);
case 'profit_loss':
return await this.getProfitLossReport(tenantId, startDate, endDate);
case 'accounts_receivable':
return await this.getAccountsReceivableReport(tenantId);
case 'accounts_payable':
return await this.getAccountsPayableReport(tenantId);
default:
return await this.getProfitLossReport(tenantId, startDate, endDate);
}
} catch {
return {
summary: { totalIncome: 0, totalExpenses: 0, netProfit: 0, margin: 0 },
data: [],
period: { startDate, endDate },
};
}
}
private async getIncomeReport(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> {
const query = `
SELECT
DATE_TRUNC('month', i.invoice_date) as period,
SUM(i.total) as total_income
FROM billing.invoices i
WHERE i.tenant_id = $1
AND i.invoice_type = 'sale'
AND i.status NOT IN ('cancelled', 'voided', 'draft')
AND i.invoice_date BETWEEN $2 AND $3
GROUP BY DATE_TRUNC('month', i.invoice_date)
ORDER BY period
`;
const data = await this.dataSource.query(query, [tenantId, startDate, endDate]);
const totalIncome = data.reduce((sum: number, row: any) => sum + parseFloat(row.total_income || 0), 0);
return {
summary: { totalIncome, totalExpenses: 0, netProfit: totalIncome, margin: 100 },
data,
period: { startDate, endDate },
};
}
private async getExpensesReport(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> {
const query = `
SELECT
DATE_TRUNC('month', i.invoice_date) as period,
SUM(i.total) as total_expenses
FROM billing.invoices i
WHERE i.tenant_id = $1
AND i.invoice_type = 'purchase'
AND i.status NOT IN ('cancelled', 'voided', 'draft')
AND i.invoice_date BETWEEN $2 AND $3
GROUP BY DATE_TRUNC('month', i.invoice_date)
ORDER BY period
`;
const data = await this.dataSource.query(query, [tenantId, startDate, endDate]);
const totalExpenses = data.reduce((sum: number, row: any) => sum + parseFloat(row.total_expenses || 0), 0);
return {
summary: { totalIncome: 0, totalExpenses, netProfit: -totalExpenses, margin: 0 },
data,
period: { startDate, endDate },
};
}
private async getProfitLossReport(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> {
const incomeQuery = `
SELECT COALESCE(SUM(total), 0) as total
FROM billing.invoices
WHERE tenant_id = $1
AND invoice_type = 'sale'
AND status NOT IN ('cancelled', 'voided', 'draft')
AND invoice_date BETWEEN $2 AND $3
`;
const expensesQuery = `
SELECT COALESCE(SUM(total), 0) as total
FROM billing.invoices
WHERE tenant_id = $1
AND invoice_type = 'purchase'
AND status NOT IN ('cancelled', 'voided', 'draft')
AND invoice_date BETWEEN $2 AND $3
`;
const [incomeResult, expensesResult] = await Promise.all([
this.dataSource.query(incomeQuery, [tenantId, startDate, endDate]),
this.dataSource.query(expensesQuery, [tenantId, startDate, endDate]),
]);
const totalIncome = parseFloat(incomeResult[0]?.total) || 0;
const totalExpenses = parseFloat(expensesResult[0]?.total) || 0;
const netProfit = totalIncome - totalExpenses;
const margin = totalIncome > 0 ? (netProfit / totalIncome) * 100 : 0;
return {
summary: { totalIncome, totalExpenses, netProfit, margin },
data: [{ totalIncome, totalExpenses, netProfit, margin }],
period: { startDate, endDate },
};
}
private async getAccountsReceivableReport(
tenantId: string
): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> {
const query = `
SELECT
i.id,
i.invoice_number,
i.partner_name,
i.total,
i.amount_paid,
(i.total - i.amount_paid) as amount_due,
i.due_date,
CASE
WHEN i.due_date < CURRENT_DATE THEN 'overdue'
WHEN i.due_date < CURRENT_DATE + INTERVAL '7 days' THEN 'due_soon'
ELSE 'current'
END as status
FROM billing.invoices i
WHERE i.tenant_id = $1
AND i.invoice_type = 'sale'
AND i.status IN ('validated', 'sent', 'partial')
AND (i.total - i.amount_paid) > 0
ORDER BY i.due_date ASC
`;
const data = await this.dataSource.query(query, [tenantId]);
const totalIncome = data.reduce((sum: number, row: any) => sum + parseFloat(row.amount_due || 0), 0);
const now = new Date();
return {
summary: { totalIncome, totalExpenses: 0, netProfit: totalIncome, margin: 0 },
data,
period: { startDate: now, endDate: now },
};
}
private async getAccountsPayableReport(
tenantId: string
): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> {
const query = `
SELECT
i.id,
i.invoice_number,
i.partner_name,
i.total,
i.amount_paid,
(i.total - i.amount_paid) as amount_due,
i.due_date,
CASE
WHEN i.due_date < CURRENT_DATE THEN 'overdue'
WHEN i.due_date < CURRENT_DATE + INTERVAL '7 days' THEN 'due_soon'
ELSE 'current'
END as status
FROM billing.invoices i
WHERE i.tenant_id = $1
AND i.invoice_type = 'purchase'
AND i.status IN ('validated', 'sent', 'partial')
AND (i.total - i.amount_paid) > 0
ORDER BY i.due_date ASC
`;
const data = await this.dataSource.query(query, [tenantId]);
const totalExpenses = data.reduce((sum: number, row: any) => sum + parseFloat(row.amount_due || 0), 0);
const now = new Date();
return {
summary: { totalIncome: 0, totalExpenses, netProfit: -totalExpenses, margin: 0 },
data,
period: { startDate: now, endDate: now },
};
}
}