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:
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user