uup
This commit is contained in:
361
app/lib/financial-reporting.server.ts
Normal file
361
app/lib/financial-reporting.server.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { prisma } from "./db.server";
|
||||
import { getTotalExpenses, getExpensesByCategory } from "./expense-management.server";
|
||||
|
||||
// Financial summary interface
|
||||
export interface FinancialSummary {
|
||||
totalIncome: number;
|
||||
totalExpenses: number;
|
||||
netProfit: number;
|
||||
incomeCount: number;
|
||||
expenseCount: number;
|
||||
profitMargin: number;
|
||||
}
|
||||
|
||||
// Monthly financial data interface
|
||||
export interface MonthlyFinancialData {
|
||||
month: string;
|
||||
year: number;
|
||||
income: number;
|
||||
expenses: number;
|
||||
profit: number;
|
||||
}
|
||||
|
||||
// Category breakdown interface
|
||||
export interface CategoryBreakdown {
|
||||
category: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// Get financial summary for a date range
|
||||
export async function getFinancialSummary(
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<FinancialSummary> {
|
||||
const whereClause: any = {};
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
whereClause.incomeDate = {};
|
||||
if (dateFrom) {
|
||||
whereClause.incomeDate.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.incomeDate.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Get income data
|
||||
const incomeResult = await prisma.income.aggregate({
|
||||
where: whereClause,
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const totalIncome = incomeResult._sum.amount || 0;
|
||||
const incomeCount = incomeResult._count.id;
|
||||
|
||||
// Get expense data
|
||||
const totalExpenses = await getTotalExpenses(dateFrom, dateTo);
|
||||
const expenseCount = await prisma.expense.count({
|
||||
where: dateFrom || dateTo ? {
|
||||
expenseDate: {
|
||||
...(dateFrom && { gte: dateFrom }),
|
||||
...(dateTo && { lte: dateTo }),
|
||||
},
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
// Calculate derived metrics
|
||||
const netProfit = totalIncome - totalExpenses;
|
||||
const profitMargin = totalIncome > 0 ? (netProfit / totalIncome) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalIncome,
|
||||
totalExpenses,
|
||||
netProfit,
|
||||
incomeCount,
|
||||
expenseCount,
|
||||
profitMargin,
|
||||
};
|
||||
}
|
||||
|
||||
// Get monthly financial data for the last 12 months
|
||||
export async function getMonthlyFinancialData(): Promise<MonthlyFinancialData[]> {
|
||||
const months: MonthlyFinancialData[] = [];
|
||||
const currentDate = new Date();
|
||||
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const date = new Date(currentDate.getFullYear(), currentDate.getMonth() - i, 1);
|
||||
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1);
|
||||
|
||||
const monthNumber = date.getMonth() + 1; // 1-12
|
||||
const year = date.getFullYear();
|
||||
|
||||
// Get income for this month
|
||||
const incomeResult = await prisma.income.aggregate({
|
||||
where: {
|
||||
incomeDate: {
|
||||
gte: date,
|
||||
lt: nextMonth,
|
||||
},
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Get expenses for this month
|
||||
const expenseResult = await prisma.expense.aggregate({
|
||||
where: {
|
||||
expenseDate: {
|
||||
gte: date,
|
||||
lt: nextMonth,
|
||||
},
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
});
|
||||
|
||||
const income = incomeResult._sum.amount || 0;
|
||||
const expenses = expenseResult._sum.amount || 0;
|
||||
const profit = income - expenses;
|
||||
|
||||
months.push({
|
||||
month: monthNumber.toString(),
|
||||
year,
|
||||
income,
|
||||
expenses,
|
||||
profit,
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
// Get income breakdown by maintenance type
|
||||
export async function getIncomeByMaintenanceType(
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<CategoryBreakdown[]> {
|
||||
const whereClause: any = {};
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
whereClause.incomeDate = {};
|
||||
if (dateFrom) {
|
||||
whereClause.incomeDate.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.incomeDate.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Get income grouped by maintenance type
|
||||
const result = await prisma.income.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
maintenanceVisit: {
|
||||
select: {
|
||||
maintenanceJobs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Group by maintenance type
|
||||
const grouped = result.reduce((acc, income) => {
|
||||
try {
|
||||
const jobs = JSON.parse(income.maintenanceVisit.maintenanceJobs);
|
||||
|
||||
// Calculate total cost from individual jobs to determine weights
|
||||
const totalJobCost = jobs.reduce((sum: number, job: any) => sum + (job.cost || 0), 0);
|
||||
|
||||
if (totalJobCost > 0) {
|
||||
// Weighted distribution based on specific costs
|
||||
jobs.forEach((job: any) => {
|
||||
const type = job.job || 'غير محدد';
|
||||
const jobCost = job.cost || 0;
|
||||
// Calculate weight: if total cost is 0 (shouldn't happen due to if check), avoid division by zero
|
||||
const weight = jobCost / totalJobCost;
|
||||
const attributedAmount = income.amount * weight;
|
||||
|
||||
if (!acc[type]) {
|
||||
acc[type] = { amount: 0, count: 0 };
|
||||
}
|
||||
acc[type].amount += attributedAmount;
|
||||
acc[type].count += 1;
|
||||
});
|
||||
} else {
|
||||
// Fallback: Equal distribution (Legacy behavior)
|
||||
const types = jobs.map((job: any) => job.job || 'غير محدد');
|
||||
types.forEach((type: string) => {
|
||||
if (!acc[type]) {
|
||||
acc[type] = { amount: 0, count: 0 };
|
||||
}
|
||||
acc[type].amount += income.amount / types.length;
|
||||
acc[type].count += 1;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fallback for invalid JSON
|
||||
const type = 'غير محدد';
|
||||
if (!acc[type]) {
|
||||
acc[type] = { amount: 0, count: 0 };
|
||||
}
|
||||
acc[type].amount += income.amount;
|
||||
acc[type].count += 1;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, { amount: number; count: number }>);
|
||||
|
||||
// Calculate total for percentage calculation
|
||||
const total = Object.values(grouped).reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
// Convert to array with percentages
|
||||
return Object.entries(grouped)
|
||||
.map(([category, data]) => ({
|
||||
category,
|
||||
amount: data.amount,
|
||||
count: data.count,
|
||||
percentage: total > 0 ? (data.amount / total) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}
|
||||
|
||||
// Get expense breakdown by category
|
||||
export async function getExpenseBreakdown(
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<CategoryBreakdown[]> {
|
||||
const categoryData = await getExpensesByCategory(dateFrom, dateTo);
|
||||
const total = categoryData.reduce((sum, item) => sum + item.total, 0);
|
||||
|
||||
return categoryData.map(item => ({
|
||||
category: item.category,
|
||||
amount: item.total,
|
||||
count: item.count,
|
||||
percentage: total > 0 ? (item.total / total) * 100 : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// Get top customers by revenue
|
||||
export async function getTopCustomersByRevenue(
|
||||
limit: number = 10,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<{
|
||||
customerId: number;
|
||||
customerName: string;
|
||||
totalRevenue: number;
|
||||
visitCount: number;
|
||||
}[]> {
|
||||
const whereClause: any = {};
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
whereClause.incomeDate = {};
|
||||
if (dateFrom) {
|
||||
whereClause.incomeDate.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.incomeDate.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.income.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
maintenanceVisit: {
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Group by customer
|
||||
const customerRevenue = result.reduce((acc, income) => {
|
||||
const customer = income.maintenanceVisit.customer;
|
||||
const customerId = customer.id;
|
||||
|
||||
if (!acc[customerId]) {
|
||||
acc[customerId] = {
|
||||
customerId,
|
||||
customerName: customer.name,
|
||||
totalRevenue: 0,
|
||||
visitCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
acc[customerId].totalRevenue += income.amount;
|
||||
acc[customerId].visitCount += 1;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<number, {
|
||||
customerId: number;
|
||||
customerName: string;
|
||||
totalRevenue: number;
|
||||
visitCount: number;
|
||||
}>);
|
||||
|
||||
return Object.values(customerRevenue)
|
||||
.sort((a, b) => b.totalRevenue - a.totalRevenue)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
// Get financial trends (comparing current period with previous period)
|
||||
export async function getFinancialTrends(
|
||||
dateFrom: Date,
|
||||
dateTo: Date
|
||||
): Promise<{
|
||||
currentPeriod: FinancialSummary;
|
||||
previousPeriod: FinancialSummary;
|
||||
trends: {
|
||||
incomeGrowth: number;
|
||||
expenseGrowth: number;
|
||||
profitGrowth: number;
|
||||
};
|
||||
}> {
|
||||
// Calculate previous period dates
|
||||
const periodLength = dateTo.getTime() - dateFrom.getTime();
|
||||
const previousDateTo = new Date(dateFrom.getTime() - 1);
|
||||
const previousDateFrom = new Date(previousDateTo.getTime() - periodLength);
|
||||
|
||||
// Get current and previous period summaries
|
||||
const [currentPeriod, previousPeriod] = await Promise.all([
|
||||
getFinancialSummary(dateFrom, dateTo),
|
||||
getFinancialSummary(previousDateFrom, previousDateTo),
|
||||
]);
|
||||
|
||||
// Calculate growth percentages
|
||||
const incomeGrowth = previousPeriod.totalIncome > 0
|
||||
? ((currentPeriod.totalIncome - previousPeriod.totalIncome) / previousPeriod.totalIncome) * 100
|
||||
: 0;
|
||||
|
||||
const expenseGrowth = previousPeriod.totalExpenses > 0
|
||||
? ((currentPeriod.totalExpenses - previousPeriod.totalExpenses) / previousPeriod.totalExpenses) * 100
|
||||
: 0;
|
||||
|
||||
const profitGrowth = previousPeriod.netProfit !== 0
|
||||
? ((currentPeriod.netProfit - previousPeriod.netProfit) / Math.abs(previousPeriod.netProfit)) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
currentPeriod,
|
||||
previousPeriod,
|
||||
trends: {
|
||||
incomeGrowth,
|
||||
expenseGrowth,
|
||||
profitGrowth,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user