feat: 周期账单 — 自动生成定期交易(房租/订阅/工资)

This commit is contained in:
2026-06-08 17:17:16 +08:00
parent 986c187156
commit 057c69800e
10 changed files with 844 additions and 2 deletions

View File

@@ -0,0 +1,205 @@
import { Router } from 'express'
import pool from '../db/connection'
import { authMiddleware } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
/** 获取用户的周期模板列表 */
router.get('/', async (req, res) => {
try {
const userId = (req as any).userId
const [rows] = await pool.execute(
`SELECT rt.*, c.name as category_name, c.icon as category_icon, c.color as category_color
FROM recurring_templates rt
LEFT JOIN categories c ON rt.category_id = c.id
WHERE rt.user_id = ?
ORDER BY rt.is_active DESC, rt.next_date ASC`,
[userId]
)
res.json({ code: 0, data: rows })
} catch (error) {
console.error('Get recurring templates error:', error)
res.status(500).json({ code: 50000, message: '获取失败' })
}
})
/** 创建周期模板 */
router.post('/', async (req, res) => {
try {
const userId = (req as any).userId
const { amount, type, category_id, note, frequency, next_date, end_date, group_id } = req.body
if (!amount || amount <= 0) {
return res.status(400).json({ code: 40001, message: '请输入金额' })
}
if (!type || !['expense', 'income'].includes(type)) {
return res.status(400).json({ code: 40001, message: '请选择类型' })
}
if (!category_id) {
return res.status(400).json({ code: 40001, message: '请选择分类' })
}
if (!frequency || !['weekly', 'monthly', 'yearly'].includes(frequency)) {
return res.status(400).json({ code: 40001, message: '请选择周期' })
}
if (!next_date) {
return res.status(400).json({ code: 40001, message: '请选择开始日期' })
}
const [result] = await pool.execute(
`INSERT INTO recurring_templates (user_id, amount, type, category_id, note, frequency, next_date, end_date, group_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, amount, type, category_id, note || '', frequency, next_date, end_date || null, group_id || null]
)
res.json({ code: 0, data: { id: (result as any).insertId } })
} catch (error) {
console.error('Create recurring template error:', error)
res.status(500).json({ code: 50000, message: '创建失败' })
}
})
/** 更新周期模板 */
router.put('/:id', async (req, res) => {
try {
const userId = (req as any).userId
const { id } = req.params
const { amount, type, category_id, note, frequency, next_date, end_date, is_active } = req.body
// 校验所有权
const [rows] = await pool.execute(
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
[id, userId]
)
if ((rows as any[]).length === 0) {
return res.status(404).json({ code: 40400, message: '模板不存在' })
}
const updates: string[] = []
const params: any[] = []
if (amount !== undefined) { updates.push('amount = ?'); params.push(amount) }
if (type !== undefined) { updates.push('type = ?'); params.push(type) }
if (category_id !== undefined) { updates.push('category_id = ?'); params.push(category_id) }
if (note !== undefined) { updates.push('note = ?'); params.push(note) }
if (frequency !== undefined) { updates.push('frequency = ?'); params.push(frequency) }
if (next_date !== undefined) { updates.push('next_date = ?'); params.push(next_date) }
if (end_date !== undefined) { updates.push('end_date = ?'); params.push(end_date) }
if (is_active !== undefined) { updates.push('is_active = ?'); params.push(is_active) }
if (updates.length === 0) {
return res.status(400).json({ code: 40001, message: '无更新字段' })
}
params.push(id, userId)
await pool.execute(
`UPDATE recurring_templates SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`,
params
)
res.json({ code: 0 })
} catch (error) {
console.error('Update recurring template error:', error)
res.status(500).json({ code: 50000, message: '更新失败' })
}
})
/** 删除周期模板 */
router.delete('/:id', async (req, res) => {
try {
const userId = (req as any).userId
const { id } = req.params
const [rows] = await pool.execute(
'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?',
[id, userId]
)
if ((rows as any[]).length === 0) {
return res.status(404).json({ code: 40400, message: '模板不存在' })
}
await pool.execute(
'DELETE FROM recurring_templates WHERE id = ? AND user_id = ?',
[id, userId]
)
res.json({ code: 0 })
} catch (error) {
console.error('Delete recurring template error:', error)
res.status(500).json({ code: 50000, message: '删除失败' })
}
})
/** 同步:检查所有活跃模板,生成到期交易 */
router.post('/sync', async (req, res) => {
try {
const userId = (req as any).userId
// 获取该用户所有活跃且到期next_date <= 今天)的模板
const [templates] = await pool.execute(
`SELECT * FROM recurring_templates
WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE()
ORDER BY next_date ASC`,
[userId]
)
let generated = 0
const today = new Date()
const todayStr = today.toISOString().slice(0, 10)
for (const t of templates as any[]) {
let next = new Date(t.next_date)
const end = t.end_date ? new Date(t.end_date) : null
// 生成所有错过的交易
while (next <= today) {
if (end && next > end) break
// 检查是否已生成过(避免重复)
const [existing] = await pool.execute(
`SELECT id FROM transactions
WHERE user_id = ? AND recurring_id = ? AND date = ?`,
[userId, t.id, next.toISOString().slice(0, 10)]
)
if ((existing as any[]).length === 0) {
await pool.execute(
`INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[userId, t.amount, t.type, t.category_id, t.note,
next.toISOString().slice(0, 10), t.group_id, t.id]
)
generated++
}
// 推进到下一周期
if (t.frequency === 'weekly') {
next = new Date(next.getTime() + 7 * 86400000)
} else if (t.frequency === 'monthly') {
next = new Date(next.getFullYear(), next.getMonth() + 1, next.getDate())
} else if (t.frequency === 'yearly') {
next = new Date(next.getFullYear() + 1, next.getMonth(), next.getDate())
}
}
// 更新 next_date 到下一周期
let newNext: Date
if (t.frequency === 'weekly') {
newNext = new Date(new Date(t.next_date).getTime() + 7 * 86400000)
} else if (t.frequency === 'monthly') {
const d = new Date(t.next_date)
newNext = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate())
} else {
const d = new Date(t.next_date)
newNext = new Date(d.getFullYear() + 1, d.getMonth(), d.getDate())
}
await pool.execute(
'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
[newNext.toISOString().slice(0, 10), t.id]
)
}
res.json({ code: 0, data: { generated } })
} catch (error) {
console.error('Sync recurring error:', error)
res.status(500).json({ code: 50000, message: '同步失败' })
}
})
export default router