- 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>
534 lines
16 KiB
TypeScript
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 },
|
|
};
|
|
}
|
|
}
|