feat(financial-reports): add institute-wide analytics endpoint

Adds GET /financial-reports/admin/analytics returning daily/weekly income
trends (cash received vs. accrued session income), a multi-month
received/payout/expense/profit breakdown, a per-class income-per-session
leaderboard, payment-status distribution, and a due-date driven cash-flow
forecast (overdue + upcoming installments) to power richer charts on the
dashboard's financial reports page.
This commit is contained in:
2026-08-23 18:24:32 +03:30
parent 3cc813f24e
commit e3b51a1629
4 changed files with 587 additions and 1 deletions
+111
View File
@@ -0,0 +1,111 @@
'use strict';
/**
* Time-bucketing helpers for financial analytics (daily / weekly / monthly /
* forward-looking forecast windows). Weeks follow the Iranian convention of
* starting on Saturday, ending on Friday.
*/
const startOfDay = (date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
};
const endOfDay = (date) => {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
};
const addDays = (date, days) => {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
};
const addMonths = (date, months) => {
const d = new Date(date);
d.setMonth(d.getMonth() + months);
return d;
};
/** JS getDay(): Sun=0..Sat=6. Iranian week starts Saturday, so Sat -> offset 0, Sun -> 1, ... Fri -> 6. */
const getWeekStart = (date) => {
const d = startOfDay(date);
const offset = (d.getDay() + 1) % 7;
return addDays(d, -offset);
};
/** Builds `days` daily buckets ending on (and including) `endDate`, oldest first. */
const buildDailyBuckets = (days, endDate = new Date()) => {
const end = startOfDay(endDate);
const buckets = [];
for (let i = days - 1; i >= 0; i--) {
const dayStart = addDays(end, -i);
buckets.push({ start: dayStart, end: endOfDay(dayStart), key: dayStart.toISOString().slice(0, 10) });
}
return buckets;
};
/** Builds `weeks` weekly buckets (Sat-Fri) ending on the week containing `endDate`, oldest first. */
const buildWeeklyBuckets = (weeks, endDate = new Date()) => {
const currentWeekStart = getWeekStart(endDate);
const buckets = [];
for (let i = weeks - 1; i >= 0; i--) {
const weekStart = addDays(currentWeekStart, -7 * i);
buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) });
}
return buckets;
};
/** Builds `weeks` forward-looking weekly buckets (Sat-Fri) starting from the week containing `startDate`. */
const buildForecastWeeklyBuckets = (weeks, startDate = new Date()) => {
const currentWeekStart = getWeekStart(startDate);
const buckets = [];
for (let i = 0; i < weeks; i++) {
const weekStart = addDays(currentWeekStart, 7 * i);
buckets.push({ start: weekStart, end: endOfDay(addDays(weekStart, 6)), key: weekStart.toISOString().slice(0, 10) });
}
return buckets;
};
/** Builds `months` monthly (Gregorian calendar month) buckets ending on the month containing `endDate`, oldest first. */
const buildMonthlyBuckets = (months, endDate = new Date()) => {
const base = new Date(endDate.getFullYear(), endDate.getMonth(), 1, 0, 0, 0, 0);
const buckets = [];
for (let i = months - 1; i >= 0; i--) {
const monthStart = addMonths(base, -i);
const monthEnd = endOfDay(addDays(addMonths(monthStart, 1), -1));
buckets.push({
start: monthStart,
end: monthEnd,
year: monthStart.getFullYear(),
month: monthStart.getMonth() + 1,
key: `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
});
}
return buckets;
};
/** Linear search is fine — bucket arrays used here are always small (<=~90 entries). */
const findBucketIndex = (buckets, date) => {
if (!date) return -1;
const d = date instanceof Date ? date : new Date(date);
if (Number.isNaN(d.getTime())) return -1;
const t = d.getTime();
return buckets.findIndex((b) => t >= b.start.getTime() && t <= b.end.getTime());
};
module.exports = {
startOfDay,
endOfDay,
addDays,
addMonths,
getWeekStart,
buildDailyBuckets,
buildWeeklyBuckets,
buildForecastWeeklyBuckets,
buildMonthlyBuckets,
findBucketIndex
};