- 数据库: hours 表新增 exported_hours 字段(默认 0),支持部分核销与多次核销 - 后端: 新增 /export/four-hour/preview 预览接口与 /export/four-hour/confirm 事务确认接口 - 分配算法: 按部员累计未核销 ≥ 4h 筛选;单活动本次最多计 4h,超出标记「截断」;按活动时间正序抵扣 - 前端: 新增独立管理页面 FourHourExport,支持矩阵/平铺/汇总三视图、Excel/图片导出、核销确认 - 导航: 侧边栏新增「四小时导出」菜单项,图标使用新增 IconFourHour/IconCheck SVG
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user