feat: 新增「四小时导出」核销功能
Deploy to Server / deploy (push) Successful in 1m58s

- 数据库: hours 表新增 exported_hours 字段(默认 0),支持部分核销与多次核销
- 后端: 新增 /export/four-hour/preview 预览接口与 /export/four-hour/confirm 事务确认接口
- 分配算法: 按部员累计未核销 ≥ 4h 筛选;单活动本次最多计 4h,超出标记「截断」;按活动时间正序抵扣
- 前端: 新增独立管理页面 FourHourExport,支持矩阵/平铺/汇总三视图、Excel/图片导出、核销确认
- 导航: 侧边栏新增「四小时导出」菜单项,图标使用新增 IconFourHour/IconCheck SVG
This commit is contained in:
Trae Assistant
2026-10-07 00:45:14 +08:00
parent 52c6ef2362
commit ef73970930
9 changed files with 802 additions and 6 deletions
+42
View File
@@ -177,6 +177,48 @@ export async function initDb() {
try { d.execCompat('ROLLBACK'); } catch {}
}
try {
const pragma = d.prepareCompat(`PRAGMA table_info('hours')`).all() || [];
const hasExported = pragma.some((c: any) => String(c.name || '').toLowerCase() === 'exported_hours');
if (!hasExported) {
console.log('[DB] Migrating hours: add exported_hours column (default 0)');
d.prepareCompat(`ALTER TABLE hours ADD COLUMN exported_hours REAL NOT NULL DEFAULT 0`).run();
d.save();
console.log('[DB] exported_hours column added');
}
} catch (e: any) {
console.error('exported_hours migration error, try rebuild path:', e?.message || e);
try {
console.log('[DB] Fallback: rebuild hours table with exported_hours');
d.execCompat('BEGIN');
d.execCompat(`CREATE TABLE IF NOT EXISTS hours_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
hours REAL NOT NULL DEFAULT 0,
exported_hours REAL NOT NULL DEFAULT 0,
remark TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
const cols = d.prepareCompat(`PRAGMA table_info('hours')`).all() || [];
const existingCols = (cols as any[]).map((c: any) => String(c.name || '')).filter(n =>
['id','activity_id','user_id','hours','remark','created_at'].includes(n)
).join(',');
d.execCompat(`INSERT INTO hours_new (${existingCols}, exported_hours)
SELECT ${existingCols}, 0 AS exported_hours FROM hours`);
d.execCompat('DROP TABLE IF EXISTS hours');
d.execCompat('ALTER TABLE hours_new RENAME TO hours');
d.execCompat('COMMIT');
d.save();
console.log('[DB] Rebuild hours migration done');
} catch (e2: any) {
console.error('Rebuild hours migration failed', e2?.message || e2);
try { d.execCompat('ROLLBACK'); } catch {}
}
}
const adminCount = d.prepareCompat('SELECT COUNT(*) as c FROM admins').get();
if (!adminCount || adminCount.c === 0) {
const hash = bcrypt.hashSync('admin123', 10);
+1
View File
@@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS hours (
activity_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
hours REAL NOT NULL DEFAULT 0,
exported_hours REAL NOT NULL DEFAULT 0,
remark TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','+8 hours')),
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
+222
View File
@@ -157,4 +157,226 @@ router.post('/', authMiddleware, (req: AuthRequest, res) => {
res.json(result);
});
type HourRow = {
id: number;
activity_id: number;
user_id: number;
hours: number;
exported_hours: number;
remark: string | null;
created_at: string;
};
router.post('/four-hour/preview', authMiddleware, (_req: AuthRequest, res) => {
const db = getDb();
const usersRaw = db.prepareCompat(`
SELECT u.id, u.class, u.name, u.volunteer_no,
GROUP_CONCAT(g.name) AS _group_names_csv
FROM users u
LEFT JOIN user_groups ug ON u.id = ug.user_id
LEFT JOIN work_groups g ON ug.group_id = g.id
GROUP BY u.id
ORDER BY u.class, u.name
`).all() as any[];
const users = withGroupNames(usersRaw);
const activities = db.prepareCompat(`
SELECT id, title, start_time FROM activities ORDER BY start_time ASC, id ASC
`).all() as any[];
const hoursRows = db.prepareCompat(`
SELECT h.id, h.activity_id, h.user_id, h.hours,
COALESCE(h.exported_hours, 0) AS exported_hours,
h.remark, h.created_at, a.start_time
FROM hours h
JOIN activities a ON a.id = h.activity_id
ORDER BY a.start_time ASC, h.id ASC
`).all() as (HourRow & { start_time: string })[];
type UAKey = string;
const uaRemaining = new Map<UAKey, number>();
const uaRemark = new Map<UAKey, string>();
const uaHourIds = new Map<UAKey, number[]>();
const uaHourRemains = new Map<number, number>();
for (const h of hoursRows) {
const key: UAKey = `${h.user_id}|${h.activity_id}`;
const total = Number(h.hours || 0);
const exported = Number(h.exported_hours || 0);
const remain = Math.max(0, Number((total - exported).toFixed(1)));
uaRemaining.set(key, Number(((uaRemaining.get(key) || 0) + remain).toFixed(1)));
if (h.remark) {
const prev = uaRemark.get(key);
uaRemark.set(key, prev ? `${prev};${String(h.remark)}` : String(h.remark));
}
let list = uaHourIds.get(key);
if (!list) { list = []; uaHourIds.set(key, list); }
list.push(h.id);
uaHourRemains.set(h.id, remain);
}
const perUserRemaining = new Map<number, number>();
for (const [k, v] of uaRemaining.entries()) {
const [uidS] = k.split('|');
const uid = Number(uidS);
perUserRemaining.set(uid, Number(((perUserRemaining.get(uid) || 0) + v).toFixed(1)));
}
const eligibleUsers = users.filter((u) => (perUserRemaining.get(Number(u.id)) || 0) >= 4);
const eligibleUserIds = new Set(eligibleUsers.map(u => Number(u.id)));
const perUaAlloc = new Map<UAKey, number>();
const perUaTruncated = new Map<UAKey, boolean>();
const touchedActivityIds = new Set<number>();
const plan: Array<{ hour_id: number; delta: number }> = [];
for (const u of eligibleUsers) {
const uid = Number(u.id);
let quota = 4;
for (const a of activities) {
if (quota <= 0.0001) break;
const key: UAKey = `${uid}|${a.id}`;
const uaR = uaRemaining.get(key) || 0;
if (uaR <= 0.0001) continue;
const take = Math.min(uaR, quota, 4);
const takeFixed = Number(take.toFixed(1));
perUaAlloc.set(key, takeFixed);
perUaTruncated.set(key, uaR - takeFixed > 0.0001);
touchedActivityIds.add(Number(a.id));
quota = Number((quota - takeFixed).toFixed(1));
const hourIds = uaHourIds.get(key) || [];
let remainForUA = takeFixed;
for (const hid of hourIds) {
if (remainForUA <= 0.0001) break;
const rowR = uaHourRemains.get(hid) || 0;
if (rowR <= 0.0001) continue;
const d = Math.min(rowR, remainForUA);
const dFixed = Number(d.toFixed(1));
plan.push({ hour_id: hid, delta: dFixed });
uaHourRemains.set(hid, Number((rowR - dFixed).toFixed(1)));
remainForUA = Number((remainForUA - dFixed).toFixed(1));
}
}
}
const matrixActivities = activities.filter(a => touchedActivityIds.has(Number(a.id)))
.sort((x, y) => String(y.start_time).localeCompare(String(x.start_time)) || Number(y.id) - Number(x.id));
const matrixUsers = eligibleUsers.map((u) => {
const uid = Number(u.id);
const row: Record<string, any> = {
id: u.id,
class: u.class,
name: u.name,
volunteer_no: u.volunteer_no,
group_name: u.group_name,
_remarks: {} as Record<string, string>,
_truncated: {} as Record<string, boolean>,
};
let total = 0;
for (const a of matrixActivities) {
const key: UAKey = `${uid}|${a.id}`;
const alloc = perUaAlloc.get(key) || 0;
row[String(a.id)] = Number(alloc.toFixed(1));
total += alloc;
const rmk = uaRemark.get(key);
if (rmk) (row._remarks as any)[String(a.id)] = rmk;
if (perUaTruncated.get(key)) (row._truncated as any)[String(a.id)] = true;
}
row._total = Number(total.toFixed(1));
return row;
});
const flatRows: any[] = [];
let serial = 1;
for (const a of matrixActivities) {
for (const uRow of matrixUsers) {
const v = Number(uRow[String(a.id)] || 0);
if (v <= 0) continue;
flatRows.push({
idx: serial++,
class: uRow.class,
name: uRow.name,
volunteer_no: uRow.volunteer_no,
group_name: uRow.group_name,
activity_title: a.title,
activity_start_time: a.start_time,
hours: v,
truncated: !!((uRow as any)._truncated?.[String(a.id)]),
remark: (uRow as any)._remarks?.[String(a.id)] || null,
});
}
}
flatRows.sort((a, b) =>
String(a.activity_start_time).localeCompare(String(b.activity_start_time)) ||
String(a.class).localeCompare(String(b.class)) ||
String(a.name).localeCompare(String(b.name))
);
flatRows.forEach((r, i) => r.idx = i + 1);
const userTotals = matrixUsers.map((u, i) => ({
rank: i + 1,
id: u.id,
class: u.class,
name: u.name,
volunteer_no: u.volunteer_no,
group_name: u.group_name,
exported_total: Number(u._total || 0),
unexported_remaining: Number(((perUserRemaining.get(Number(u.id)) || 0) - Number(u._total || 0)).toFixed(1)),
})).sort((a, b) => b.exported_total - a.exported_total || String(a.class).localeCompare(String(b.class)));
userTotals.forEach((r, i) => r.rank = i + 1);
res.json({
ok: true,
generated_at: new Date().toISOString(),
summary: {
eligible_count: eligibleUsers.length,
eligible_hours_total: Number(matrixUsers.reduce((s, u) => s + Number(u._total || 0), 0).toFixed(1)),
},
activity_matrix: {
activities: matrixActivities,
users: matrixUsers,
},
hours: flatRows,
user_totals: userTotals,
confirm_plan: plan,
per_user_remaining: perUserRemaining.size ? Array.from(perUserRemaining.entries()).map(([id, r]) => ({ user_id: id, remaining: r })) : [],
});
});
router.post('/four-hour/confirm', authMiddleware, (req: AuthRequest, res) => {
const plan: Array<{ hour_id: number; delta: number }> | undefined = (req.body as any)?.plan;
if (!Array.isArray(plan) || plan.length === 0) {
res.status(400).json({ error: '导出计划为空,请先生成预览' });
return;
}
const db = getDb();
try {
db.execCompat('BEGIN');
const updStmt = db.prepareCompat(`
UPDATE hours SET exported_hours = COALESCE(exported_hours, 0) + ?
WHERE id = ? AND COALESCE(exported_hours, 0) + ? <= hours + 0.0001
`);
let updated = 0;
let skipped = 0;
for (const p of plan) {
const hid = Number(p.hour_id);
const d = Number(p.delta || 0);
if (!hid || d <= 0.0001) { skipped++; continue; }
const r = updStmt.run(d, hid, d);
if (r && r.changes > 0) updated++;
else skipped++;
}
db.save();
db.execCompat('COMMIT');
res.json({ ok: true, updated, skipped, total_processed: plan.length });
} catch (e: any) {
try { db.execCompat('ROLLBACK'); } catch {}
console.error('[4h-export] confirm error', e);
res.status(500).json({ error: e?.message || '确认导出失败,已回滚' });
}
});
export default router;