245 lines
6.4 KiB
JavaScript
Executable File
245 lines
6.4 KiB
JavaScript
Executable File
const express = require('express');
|
|
const { getDb } = require('../db');
|
|
const { roundCurrency } = require('../utils/accounting');
|
|
const { getWeekRange } = require('../utils/date');
|
|
|
|
const router = express.Router();
|
|
|
|
function getUsers(db) {
|
|
return db.all('SELECT name FROM users ORDER BY sort_order, id');
|
|
}
|
|
|
|
function getTotalByType(db, whereClause, params, type) {
|
|
const row = db.get(
|
|
`SELECT COALESCE(SUM(amount), 0) AS total FROM records WHERE ${whereClause} AND type = ?`,
|
|
...params,
|
|
type
|
|
);
|
|
|
|
return roundCurrency(row?.total);
|
|
}
|
|
|
|
function getUserStats(db, whereClause, params) {
|
|
const users = getUsers(db);
|
|
const result = Object.fromEntries(
|
|
users.map((user) => [user.name, { income: 0, expense: 0 }])
|
|
);
|
|
|
|
const rows = db.all(`
|
|
SELECT user, type, ROUND(SUM(amount), 2) AS total
|
|
FROM records
|
|
WHERE ${whereClause}
|
|
GROUP BY user, type
|
|
`, ...params);
|
|
|
|
rows.forEach((row) => {
|
|
if (!result[row.user] || !['income', 'expense'].includes(row.type)) {
|
|
return;
|
|
}
|
|
|
|
result[row.user][row.type] = roundCurrency(row.total);
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
function getCategoriesWithUsers(db, whereClause, params) {
|
|
const users = getUsers(db);
|
|
const categories = db.all(`
|
|
SELECT c.id, c.name, c.icon, ROUND(SUM(r.amount), 2) AS total
|
|
FROM records r
|
|
JOIN categories c ON r.category_id = c.id
|
|
WHERE ${whereClause} AND r.type = 'expense'
|
|
GROUP BY c.id
|
|
ORDER BY total DESC, c.sort_order ASC, c.id ASC
|
|
`, ...params);
|
|
|
|
const userRows = db.all(`
|
|
SELECT c.id AS category_id, r.user, ROUND(SUM(r.amount), 2) AS total
|
|
FROM records r
|
|
JOIN categories c ON r.category_id = c.id
|
|
WHERE ${whereClause} AND r.type = 'expense'
|
|
GROUP BY c.id, r.user
|
|
`, ...params);
|
|
|
|
const userTotals = new Map();
|
|
userRows.forEach((row) => {
|
|
if (!userTotals.has(row.category_id)) {
|
|
userTotals.set(row.category_id, new Map());
|
|
}
|
|
userTotals.get(row.category_id).set(row.user, roundCurrency(row.total));
|
|
});
|
|
|
|
return categories.map((category) => ({
|
|
name: category.name,
|
|
icon: category.icon,
|
|
total: roundCurrency(category.total),
|
|
users: users.map((user) => ({
|
|
user: user.name,
|
|
total: userTotals.get(category.id)?.get(user.name) || 0,
|
|
})),
|
|
}));
|
|
}
|
|
|
|
function getGroupedSeries(db, whereClause, params, bucketExpr, bucketAlias) {
|
|
return db.all(`
|
|
SELECT ${bucketExpr} AS ${bucketAlias}, type, user, ROUND(SUM(amount), 2) AS total
|
|
FROM records
|
|
WHERE ${whereClause}
|
|
GROUP BY ${bucketAlias}, type, user
|
|
ORDER BY ${bucketAlias}, type, user
|
|
`, ...params).map((row) => ({
|
|
...row,
|
|
total: roundCurrency(row.total),
|
|
}));
|
|
}
|
|
|
|
function buildStatsPayload(db, whereClause, params, periodPayload, seriesKey, seriesValue) {
|
|
const income = getTotalByType(db, whereClause, params, 'income');
|
|
const expense = getTotalByType(db, whereClause, params, 'expense');
|
|
|
|
return {
|
|
...periodPayload,
|
|
income,
|
|
expense,
|
|
balance: roundCurrency(income - expense),
|
|
[seriesKey]: seriesValue,
|
|
categories: getCategoriesWithUsers(db, whereClause, params),
|
|
userStats: getUserStats(db, whereClause, params),
|
|
};
|
|
}
|
|
|
|
router.get('/daily', (req, res) => {
|
|
const db = getDb();
|
|
const { date } = req.query;
|
|
|
|
if (!date) {
|
|
return res.status(400).json({ error: '请提供日期参数' });
|
|
}
|
|
|
|
const dailyData = getGroupedSeries(db, 'date = ?', [date], 'date', 'date');
|
|
res.json(buildStatsPayload(db, 'date = ?', [date], { date }, 'dailyData', dailyData));
|
|
});
|
|
|
|
router.get('/weekly', (req, res) => {
|
|
const db = getDb();
|
|
const { date } = req.query;
|
|
|
|
if (!date) {
|
|
return res.status(400).json({ error: '请提供日期参数' });
|
|
}
|
|
|
|
const range = getWeekRange(date);
|
|
if (!range) {
|
|
return res.status(400).json({ error: '日期格式不正确' });
|
|
}
|
|
|
|
const { startDate, endDate } = range;
|
|
const dailyData = getGroupedSeries(
|
|
db,
|
|
'date BETWEEN ? AND ?',
|
|
[startDate, endDate],
|
|
'date',
|
|
'date'
|
|
);
|
|
|
|
res.json(
|
|
buildStatsPayload(
|
|
db,
|
|
'date BETWEEN ? AND ?',
|
|
[startDate, endDate],
|
|
{ startDate, endDate },
|
|
'dailyData',
|
|
dailyData
|
|
)
|
|
);
|
|
});
|
|
|
|
router.get('/monthly', (req, res) => {
|
|
const db = getDb();
|
|
const { month } = req.query;
|
|
|
|
if (!/^\d{4}-\d{2}$/.test(String(month ?? ''))) {
|
|
return res.status(400).json({ error: '请提供正确的月份参数' });
|
|
}
|
|
|
|
const pattern = `${month}%`;
|
|
const dailyData = getGroupedSeries(db, 'date LIKE ?', [pattern], 'date', 'date');
|
|
|
|
res.json(
|
|
buildStatsPayload(db, 'date LIKE ?', [pattern], { month }, 'dailyData', dailyData)
|
|
);
|
|
});
|
|
|
|
router.get('/yearly', (req, res) => {
|
|
const db = getDb();
|
|
const { year } = req.query;
|
|
|
|
if (!/^\d{4}$/.test(String(year ?? ''))) {
|
|
return res.status(400).json({ error: '请提供正确的年份参数' });
|
|
}
|
|
|
|
const pattern = `${year}%`;
|
|
const monthlyData = getGroupedSeries(
|
|
db,
|
|
'date LIKE ?',
|
|
[pattern],
|
|
'substr(date, 1, 7)',
|
|
'month'
|
|
);
|
|
|
|
res.json(
|
|
buildStatsPayload(db, 'date LIKE ?', [pattern], { year }, 'monthlyData', monthlyData)
|
|
);
|
|
});
|
|
|
|
router.get('/balance', (req, res) => {
|
|
const db = getDb();
|
|
const totalIncome = getTotalByType(db, '1 = 1', [], 'income');
|
|
const totalExpense = getTotalByType(db, '1 = 1', [], 'expense');
|
|
|
|
const userBalances = getUsers(db).map((user) => {
|
|
const initialBalance = roundCurrency(
|
|
db.get('SELECT initial_balance FROM user_balance_settings WHERE user_name = ?', user.name)
|
|
?.initial_balance
|
|
);
|
|
const income = getTotalByType(db, 'user = ?', [user.name], 'income');
|
|
const expense = getTotalByType(db, 'user = ?', [user.name], 'expense');
|
|
|
|
return {
|
|
user: user.name,
|
|
initial_balance: initialBalance,
|
|
income,
|
|
expense,
|
|
balance: roundCurrency(initialBalance + income - expense),
|
|
};
|
|
});
|
|
|
|
const totalInitialBalance = roundCurrency(
|
|
userBalances.reduce((sum, item) => sum + item.initial_balance, 0)
|
|
);
|
|
|
|
res.json({
|
|
initial_balance: totalInitialBalance,
|
|
total_income: totalIncome,
|
|
total_expense: totalExpense,
|
|
current_balance: roundCurrency(totalInitialBalance + totalIncome - totalExpense),
|
|
userBalances,
|
|
});
|
|
});
|
|
|
|
router.get('/trend', (req, res) => {
|
|
const db = getDb();
|
|
const { start, end } = req.query;
|
|
|
|
if (!start || !end) {
|
|
return res.status(400).json({ error: '请提供开始和结束日期' });
|
|
}
|
|
|
|
res.json(
|
|
getGroupedSeries(db, 'date BETWEEN ? AND ?', [start, end], 'date', 'date')
|
|
);
|
|
});
|
|
|
|
module.exports = router;
|