Files
tongjiweb/server/routes/admin.js
T

699 lines
33 KiB
JavaScript

const express = require('express');
const bcrypt = require('bcryptjs');
const XLSX = require('xlsx');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
// All admin routes require admin role
router.use(requireRole('admin'));
// Helper to get db from app.locals
function getDB(req) { return req.app.locals.db; }
// ========================
// User Management
// ========================
router.get('/users', (req, res) => {
const db = getDB(req);
const users = db.all('SELECT id, username, realname, role, status, created_at FROM users ORDER BY id');
res.json({ code: 200, data: users });
});
router.post('/users', (req, res) => {
const db = getDB(req);
let { username, password, realname, role } = req.body;
if (!realname || !role) return res.status(400).json({ code: 400, message: '姓名和角色不能为空' });
if (!['admin', 'sales', 'rd'].includes(role)) return res.status(400).json({ code: 400, message: '角色类型无效' });
// Auto-generate for sales
if (role === 'sales') {
if (!username) username = `sales_${Date.now()}`;
if (!password) password = `sales_${Date.now()}`;
} else {
if (!username || !password) return res.status(400).json({ code: 400, message: '用户名和密码不能为空' });
}
const existing = db.get('SELECT id FROM users WHERE username = ?', [username]);
if (existing) return res.status(400).json({ code: 400, message: '用户名已存在' });
const hash = bcrypt.hashSync(password, 10);
const result = db.run('INSERT INTO users (username, password, realname, role, status) VALUES (?, ?, ?, ?, 1)', [username, hash, realname, role]);
res.json({ code: 200, message: '创建成功', data: { id: result.lastInsertRowid } });
});
router.put('/users/:id', (req, res) => {
const db = getDB(req);
const { id } = req.params;
const { realname, role, status, password } = req.body;
const user = db.get('SELECT id FROM users WHERE id = ?', [parseInt(id)]);
if (!user) return res.status(404).json({ code: 404, message: '用户不存在' });
if (role && !['admin', 'sales', 'rd'].includes(role)) return res.status(400).json({ code: 400, message: '角色类型无效' });
const updates = []; const params = [];
if (realname !== undefined) { updates.push('realname = ?'); params.push(realname); }
if (role !== undefined) { updates.push('role = ?'); params.push(role); }
if (status !== undefined) { updates.push('status = ?'); params.push(status); }
if (password) { updates.push('password = ?'); params.push(bcrypt.hashSync(password, 10)); }
if (updates.length === 0) return res.status(400).json({ code: 400, message: '没有可更新的字段' });
params.push(parseInt(id));
db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params);
res.json({ code: 200, message: '更新成功' });
});
router.delete('/users/:id', (req, res) => {
const db = getDB(req);
const { id } = req.params;
if (parseInt(id) === req.user.id) return res.status(400).json({ code: 400, message: '不能删除当前登录的管理员账号' });
const user = db.get('SELECT id FROM users WHERE id = ?', [parseInt(id)]);
if (!user) return res.status(404).json({ code: 404, message: '用户不存在' });
db.run('DELETE FROM users WHERE id = ?', [parseInt(id)]);
res.json({ code: 200, message: '删除成功' });
});
// ========================
// Client Management
// ========================
router.get('/clients', (req, res) => {
const db = getDB(req);
const clients = db.all(`
SELECT c.id, c.name, c.description, c.sales_id, c.service_types, u.realname as sales_name, c.status, c.created_at,
COALESCE(SUM(t.hours), 0) as total_hours,
COUNT(DISTINCT t.user_id) as rd_count
FROM clients c
LEFT JOIN users u ON c.sales_id = u.id
LEFT JOIN timesheets t ON c.id = t.client_id
GROUP BY c.id
ORDER BY c.created_at DESC
`);
// Parse service_types JSON for each client
const result = clients.map(c => ({ ...c, service_types: JSON.parse(c.service_types || '[]') }));
res.json({ code: 200, data: result });
});
router.post('/clients', (req, res) => {
const db = getDB(req);
const { name, description, sales_id, service_types } = req.body;
if (!name) return res.status(400).json({ code: 400, message: '客户名称不能为空' });
if (!sales_id) return res.status(400).json({ code: 400, message: '必须选择所属销售' });
const dup = db.get('SELECT id FROM clients WHERE name = ?', [name]);
if (dup) return res.status(400).json({ code: 400, message: `项目名称「${name}」已存在,请更换一个名称` });
const stJson = JSON.stringify(Array.isArray(service_types) ? service_types : []);
const result = db.run('INSERT INTO clients (name, description, sales_id, status, service_types) VALUES (?, ?, ?, 1, ?)', [name, description || '', parseInt(sales_id), stJson]);
res.json({ code: 200, message: '创建成功', data: { id: result.lastInsertRowid } });
});
router.put('/clients/:id', (req, res) => {
const db = getDB(req);
const { id } = req.params;
const { name, description, status, sales_id, service_types } = req.body;
const client = db.get('SELECT * FROM clients WHERE id = ?', [parseInt(id)]);
if (!client) return res.status(404).json({ code: 404, message: '客户不存在' });
// Check name uniqueness (exclude self)
if (name !== undefined) {
const dup = db.get('SELECT id FROM clients WHERE name = ? AND id != ?', [name, parseInt(id)]);
if (dup) return res.status(400).json({ code: 400, message: `项目名称「${name}」已存在,请更换一个名称` });
}
const updates = [], params = [];
if (name !== undefined) { updates.push('name = ?'); params.push(name); }
if (description !== undefined) { updates.push('description = ?'); params.push(description); }
if (status !== undefined) { updates.push('status = ?'); params.push(status); }
if (sales_id !== undefined) { updates.push('sales_id = ?'); params.push(parseInt(sales_id)); }
if (service_types !== undefined) { updates.push('service_types = ?'); params.push(JSON.stringify(Array.isArray(service_types) ? service_types : [])); }
if (updates.length === 0) return res.status(400).json({ code: 400, message: '没有可更新的字段' });
params.push(parseInt(id));
db.run(`UPDATE clients SET ${updates.join(', ')} WHERE id = ?`, params);
res.json({ code: 200, message: '更新成功' });
});
router.delete('/clients/:id', (req, res) => {
const db = getDB(req);
const { id } = req.params;
const client = db.get('SELECT id FROM clients WHERE id = ?', [parseInt(id)]);
if (!client) return res.status(404).json({ code: 404, message: '客户/项目不存在' });
// Check if any timesheets reference this client
const tsCount = db.get('SELECT COUNT(*) as cnt FROM timesheets WHERE client_id = ?', [parseInt(id)]);
if (tsCount && tsCount.cnt > 0) {
return res.status(400).json({ code: 400, message: `该项目已有 ${tsCount.cnt} 条工时记录,无法删除。如需停用请使用"结算"操作。` });
}
db.run('DELETE FROM clients WHERE id = ?', [parseInt(id)]);
res.json({ code: 200, message: '删除成功' });
});
// ========================
// Workday Settings
// ========================
router.get('/workdays', async (req, res) => {
const db = getDB(req);
const year = parseInt(req.query.year) || new Date().getFullYear();
let workdays = db.all('SELECT * FROM workdays WHERE year = ? ORDER BY month', [year]);
// Auto-calculate and seed if database is empty for this year
if (workdays.length === 0) {
try {
const { getHolidays, calculateMonthlyWorkdays } = require('../utils/holiday');
const holidayMap = await getHolidays(db, year);
const computed = calculateMonthlyWorkdays(year, holidayMap);
for (const item of computed) {
db.run('INSERT OR IGNORE INTO workdays (year, month, days) VALUES (?, ?, ?)', [year, item.month, item.days]);
}
workdays = db.all('SELECT * FROM workdays WHERE year = ? ORDER BY month', [year]);
} catch (e) {
console.error('[Admin] Error auto-seeding workdays:', e.message);
}
}
res.json({ code: 200, data: workdays });
});
router.post('/workdays', (req, res) => {
const db = getDB(req);
const { year, months } = req.body;
if (!year || !Array.isArray(months)) return res.status(400).json({ code: 400, message: '参数格式错误' });
for (const item of months) {
const existing = db.get('SELECT id FROM workdays WHERE year = ? AND month = ?', [year, item.month]);
if (existing) {
db.run('UPDATE workdays SET days = ? WHERE year = ? AND month = ?', [item.days, year, item.month]);
} else {
db.run('INSERT INTO workdays (year, month, days) VALUES (?, ?, ?)', [year, item.month, item.days]);
}
}
res.json({ code: 200, message: '设置成功' });
});
// POST /api/admin/workdays/sync — manually trigger network sync
router.post('/workdays/sync', async (req, res) => {
const db = getDB(req);
const { year } = req.body;
if (!year) return res.status(400).json({ code: 400, message: '请指定要同步的年份' });
try {
const { syncYearWorkdays } = require('../utils/holiday');
const workdaysList = await syncYearWorkdays(db, parseInt(year));
res.json({ code: 200, message: '自动同步成功', data: workdaysList });
} catch (e) {
console.error('[Admin] Sync error:', e.message);
res.status(500).json({ code: 500, message: e.message });
}
});
// GET /api/admin/holidays — retrieve holiday data for a specific year
router.get('/holidays', async (req, res) => {
const db = getDB(req);
const year = parseInt(req.query.year) || new Date().getFullYear();
const { getHolidays } = require('../utils/holiday');
const holidayMap = await getHolidays(db, year);
res.json({ code: 200, data: holidayMap || {} });
});
// ========================
// Global Stats
// ========================
router.get('/stats/sales', (req, res) => {
const db = getDB(req);
const { start_date, end_date, sales_id } = req.query;
let sql = `SELECT u.id as sales_id, u.realname as sales_name, c.id as client_id, c.name as client_name, t.work_date, SUM(t.hours) as total_hours
FROM timesheets t JOIN clients c ON t.client_id = c.id JOIN users u ON c.sales_id = u.id WHERE 1=1`;
const params = [];
if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); }
if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); }
if (sales_id) { sql += ' AND u.id = ?'; params.push(parseInt(sales_id)); }
sql += ' GROUP BY u.id, c.id, t.work_date ORDER BY t.work_date DESC, u.realname';
res.json({ code: 200, data: db.all(sql, params) });
});
// Sales detail: sales → RD person breakdown
router.get('/stats/sales-detail', (req, res) => {
const db = getDB(req);
const { start_date, end_date, sales_id } = req.query;
let sql = `SELECT
s.id as sales_id, s.realname as sales_name,
rd.id as rd_id, rd.realname as rd_name,
SUM(t.hours) as total_hours,
COUNT(DISTINCT t.work_date) as work_days
FROM timesheets t
JOIN clients c ON t.client_id = c.id
JOIN users s ON c.sales_id = s.id
JOIN users rd ON t.user_id = rd.id
WHERE 1=1`;
const params = [];
if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); }
if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); }
if (sales_id) { sql += ' AND s.id = ?'; params.push(parseInt(sales_id)); }
sql += ' GROUP BY s.id, rd.id ORDER BY s.realname, total_hours DESC';
res.json({ code: 200, data: db.all(sql, params) });
});
router.get('/stats/rd', (req, res) => {
const db = getDB(req);
const { start_date, end_date, user_id } = req.query;
let sql = `SELECT u.id as user_id, u.realname as user_name, c.id as client_id,
c.name || CASE WHEN IFNULL(t.project_type, '') != '' THEN '-' || t.project_type ELSE '' END as client_name,
t.work_date, t.hours, t.remark
FROM timesheets t JOIN users u ON t.user_id = u.id JOIN clients c ON t.client_id = c.id WHERE 1=1`;
const params = [];
if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); }
if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); }
if (user_id) { sql += ' AND u.id = ?'; params.push(parseInt(user_id)); }
sql += ' ORDER BY t.work_date DESC, u.realname';
res.json({ code: 200, data: db.all(sql, params) });
});
router.get('/stats/project', (req, res) => {
const db = getDB(req);
const { start_date, end_date, sales_id } = req.query;
let sql = `SELECT c.id as client_id, c.name as client_name, c.status as client_status, s.realname as sales_name,
COALESCE(SUM(t.hours), 0) as total_hours, COUNT(DISTINCT t.user_id) as rd_count
FROM clients c LEFT JOIN timesheets t ON c.id = t.client_id LEFT JOIN users s ON c.sales_id = s.id WHERE 1=1`;
const params = [];
if (sales_id) { sql += ' AND s.id = ?'; params.push(parseInt(sales_id)); }
if (start_date) { sql += ' AND (t.work_date >= ? OR t.work_date IS NULL)'; params.push(start_date); }
if (end_date) { sql += ' AND (t.work_date <= ? OR t.work_date IS NULL)'; params.push(end_date); }
sql += ' GROUP BY c.id ORDER BY total_hours DESC';
res.json({ code: 200, data: db.all(sql, params) });
});
// Project detail: project → RD person breakdown
router.get('/stats/project-detail', (req, res) => {
const db = getDB(req);
const { start_date, end_date, client_id, sales_id } = req.query;
let sql = `SELECT
c.id as client_id, c.name as client_name, c.status as client_status,
s.realname as sales_name,
rd.id as rd_id, rd.realname as rd_name,
SUM(t.hours) as total_hours,
COUNT(DISTINCT t.work_date) as work_days,
MIN(t.work_date) as first_date,
MAX(t.work_date) as last_date
FROM timesheets t
JOIN clients c ON t.client_id = c.id
JOIN users s ON c.sales_id = s.id
JOIN users rd ON t.user_id = rd.id
WHERE 1=1`;
const params = [];
if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); }
if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); }
if (client_id) { sql += ' AND c.id = ?'; params.push(parseInt(client_id)); }
if (sales_id) { sql += ' AND s.id = ?'; params.push(parseInt(sales_id)); }
sql += ' GROUP BY c.id, rd.id ORDER BY c.name, total_hours DESC';
res.json({ code: 200, data: db.all(sql, params) });
});
// ========================
// Excel Export
// ========================
// Helper: build matrix (研发工时汇总) sheet data
function buildMatrixSheetData(db, start_date, end_date) {
let sql = `SELECT u.realname as user_name, c.name as client_name,
COALESCE(t.project_type, '') as sub_project, SUM(t.hours) as hours
FROM timesheets t
JOIN users u ON t.user_id = u.id JOIN clients c ON t.client_id = c.id WHERE 1=1`;
const params = [];
if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); }
if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); }
sql += ' GROUP BY u.realname, c.name, t.project_type';
const rows = db.all(sql, params);
// cols = unique 项目-子项目 labels
const colKeys = [...new Set(rows.map(r => r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name))].sort();
// rows = unique user names
const rowKeys = [...new Set(rows.map(r => r.user_name))].sort();
const cellMap = {};
rows.forEach(r => {
const ck = r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name;
if (!cellMap[r.user_name]) cellMap[r.user_name] = {};
cellMap[r.user_name][ck] = (cellMap[r.user_name][ck] || 0) + r.hours;
});
const headers = ['\u7814\u53d1\u4eba\u5458', ...colKeys, '\u5408\u8ba1'];
const data = rowKeys.map(rk => {
const rowTotal = colKeys.reduce((s, c) => s + (cellMap[rk]?.[c] || 0), 0);
return [rk, ...colKeys.map(c => cellMap[rk]?.[c] || 0), Math.round(rowTotal * 10) / 10];
});
const colTotals = ['\u5408\u8ba1', ...colKeys.map(c => Math.round(data.reduce((s, r) => s + r[colKeys.indexOf(c) + 1], 0) * 10) / 10), Math.round(data.reduce((s, r) => s + r[r.length - 1], 0) * 10) / 10];
data.push(colTotals);
return { headers, data };
}
router.get('/export/all', (req, res) => {
const db = getDB(req);
const { start_date, end_date, sales_id } = req.query;
const wb = XLSX.utils.book_new();
// Sales, Project, R&D sheets
const types = ['sales', 'project', 'rd'];
types.forEach(type => {
const { sheets } = getExportData(db, type, start_date, end_date, sales_id);
sheets.forEach(s => {
const ws = createSheet(s.headers, s.data);
XLSX.utils.book_append_sheet(wb, ws, s.sheetName);
});
});
// Matrix sheet
const { headers: mh, data: md } = buildMatrixSheetData(db, start_date, end_date);
if (md.length > 1) XLSX.utils.book_append_sheet(wb, createSheet(mh, md), '研发工时汇总');
const filename = `总报表_${start_date || '开始'}${end_date || '至今'}`;
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(filename)}.xlsx`);
res.send(Buffer.from(buf));
});
// Matrix export — must be BEFORE /export/:type to avoid route conflict
router.get('/export/matrix', (req, res) => {
const db = getDB(req);
const { start_date, end_date } = req.query;
const { headers, data } = buildMatrixSheetData(db, start_date, end_date);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, createSheet(headers, data), '研发工时汇总');
const filename = `研发工时汇总_${start_date || '开始'}${end_date || '至今'}`;
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(filename)}.xlsx`);
res.send(Buffer.from(buf));
});
router.get('/export/:type', (req, res) => {
const db = getDB(req);
const { type } = req.params;
const { start_date, end_date, sales_id } = req.query;
const { sheets, filename } = getExportData(db, type, start_date, end_date, sales_id);
if (!sheets.length) return res.status(400).json({ code: 400, message: '无效的导出类型' });
const wb = XLSX.utils.book_new();
sheets.forEach(s => {
const ws = createSheet(s.headers, s.data);
XLSX.utils.book_append_sheet(wb, ws, s.sheetName);
});
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(filename)}.xlsx`);
res.send(Buffer.from(buf));
});
function getExportData(db, type, start_date, end_date, sales_id) {
let sheets = [], filename = '';
const conditionBuilder = () => {
let cond = ''; let params = [];
if (start_date) { cond += ' AND t.work_date >= ?'; params.push(start_date); }
if (end_date) { cond += ' AND t.work_date <= ?'; params.push(end_date); }
return { cond, params };
};
const { cond: tCond, params: tParams } = conditionBuilder();
if (type === 'sales') {
let sql1 = `SELECT s.realname as sales_name, c.name as client_name,
COALESCE(SUM(t.hours), 0) as total_hours, COUNT(DISTINCT t.user_id) as rd_count
FROM clients c JOIN users s ON c.sales_id = s.id
LEFT JOIN timesheets t ON c.id = t.client_id`;
let cond1 = '', params1 = [];
if (start_date || end_date) {
cond1 += ' AND (t.work_date IS NULL OR (1=1';
if (start_date) { cond1 += ' AND t.work_date >= ?'; params1.push(start_date); }
if (end_date) { cond1 += ' AND t.work_date <= ?'; params1.push(end_date); }
cond1 += '))';
}
sql1 += cond1 + ' GROUP BY s.id, c.id ORDER BY s.realname, total_hours DESC';
const data1 = db.all(sql1, params1).map(r => [r.sales_name, r.client_name, r.total_hours, r.rd_count]);
sheets.push({ sheetName: '销售汇总', headers: ['销售人员', '客户/项目', '研发总耗时(h)', '参与研发人数'], data: data1 });
let sql2 = `SELECT s.realname as sales_name, rd.realname as rd_name,
SUM(t.hours) as total_hours, COUNT(DISTINCT t.work_date) as work_days
FROM timesheets t JOIN clients c ON t.client_id = c.id
JOIN users s ON c.sales_id = s.id JOIN users rd ON t.user_id = rd.id
WHERE 1=1 ${tCond} GROUP BY s.id, rd.id ORDER BY s.realname, total_hours DESC`;
const data2 = db.all(sql2, tParams).map(r => [r.sales_name, r.rd_name, r.total_hours, r.work_days]);
sheets.push({ sheetName: '销售占用研发明细', headers: ['销售人员', '研发人员', '消耗总工时(h)', '工作天数'], data: data2 });
filename = `销售视角_${start_date || '开始'}${end_date || '至今'}`;
}
else if (type === 'rd') {
let sql = `SELECT u.realname as user_name,
c.name || CASE WHEN IFNULL(t.project_type, '') != '' THEN '-' || t.project_type ELSE '' END as client_name,
t.work_date, t.hours, t.remark
FROM timesheets t JOIN users u ON t.user_id = u.id JOIN clients c ON t.client_id = c.id WHERE 1=1 ${tCond}
ORDER BY u.realname, t.work_date DESC`;
const data = db.all(sql, tParams).map(r => [r.user_name, r.client_name, r.work_date, r.hours, r.remark]);
sheets.push({ sheetName: '研发填报明细', headers: ['研发人员', '客户/项目', '日期', '工时(h)', '备注'], data: data });
filename = `研发视角_${start_date || '开始'}${end_date || '至今'}`;
}
else if (type === 'project') {
let sql1 = `SELECT c.name as client_name, s.realname as sales_name, c.status,
COALESCE(SUM(t.hours), 0) as total_hours, COUNT(DISTINCT t.user_id) as rd_count
FROM clients c LEFT JOIN timesheets t ON c.id = t.client_id LEFT JOIN users s ON c.sales_id = s.id WHERE 1=1`;
let cond1 = '', params1 = [];
if (sales_id) { cond1 += ' AND s.id = ?'; params1.push(parseInt(sales_id)); }
if (start_date || end_date) {
cond1 += ' AND (t.work_date IS NULL OR (1=1';
if (start_date) { cond1 += ' AND t.work_date >= ?'; params1.push(start_date); }
if (end_date) { cond1 += ' AND t.work_date <= ?'; params1.push(end_date); }
cond1 += '))';
}
sql1 += cond1 + ' GROUP BY c.id ORDER BY total_hours DESC';
const data1 = db.all(sql1, params1).map(r => [r.client_name, r.sales_name, r.status === 1 ? '进行中' : '已结算', r.total_hours, r.rd_count]);
sheets.push({ sheetName: '项目总览', headers: ['客户/项目', '所属销售', '状态', '总工时(h)', '参与研发人数'], data: data1 });
let sql2 = `SELECT c.name as client_name, s.realname as sales_name, rd.realname as rd_name, SUM(t.hours) as total_hours,
COUNT(DISTINCT t.work_date) as work_days, MIN(t.work_date) as first_date, MAX(t.work_date) as last_date
FROM timesheets t JOIN clients c ON t.client_id = c.id
JOIN users s ON c.sales_id = s.id JOIN users rd ON t.user_id = rd.id
WHERE 1=1 ${tCond}`;
let params2 = [...tParams];
if (sales_id) { sql2 += ' AND s.id = ?'; params2.push(parseInt(sales_id)); }
sql2 += ' GROUP BY c.id, rd.id ORDER BY c.name, total_hours DESC';
const data2 = db.all(sql2, params2).map(r => [r.client_name, r.sales_name, r.rd_name, r.total_hours, r.work_days, r.first_date, r.last_date]);
sheets.push({ sheetName: '项目占用研发明细', headers: ['客户/项目', '所属销售', '研发人员', '消耗总工时(h)', '工作天数', '首次介入', '最近介入'], data: data2 });
filename = `项目视角_${start_date || '开始'}${end_date || '至今'}`;
}
return { sheets, filename };
}
function createSheet(headers, data) {
const wsData = [headers, ...data];
const ws = XLSX.utils.aoa_to_sheet(wsData);
const colWidths = headers.map((h, i) => {
let max = h.length * 2;
data.forEach(row => { max = Math.max(max, String(row[i] || '').length * 1.5); });
return { wch: Math.min(max + 2, 40) };
});
ws['!cols'] = colWidths;
return ws;
}
// Helper endpoints for dropdowns
router.get('/salesusers', (req, res) => {
const db = getDB(req);
res.json({ code: 200, data: db.all("SELECT id, realname FROM users WHERE role = 'sales' AND status = 1") });
});
router.get('/rdusers', (req, res) => {
const db = getDB(req);
res.json({ code: 200, data: db.all("SELECT id, realname FROM users WHERE role = 'rd' AND status = 1") });
});
// ========================
// Project Type Tags
// ========================
router.get('/project-types', (req, res) => {
const db = getDB(req);
res.json({ code: 200, data: db.all('SELECT id, name FROM project_types ORDER BY id') });
});
router.post('/project-types', (req, res) => {
const db = getDB(req);
const { name } = req.body;
if (!name || !name.trim()) return res.status(400).json({ code: 400, message: '标签名不能为空' });
const existing = db.get('SELECT id FROM project_types WHERE name = ?', [name.trim()]);
if (existing) return res.status(400).json({ code: 400, message: '标签已存在' });
const result = db.run('INSERT INTO project_types (name) VALUES (?)', [name.trim()]);
res.json({ code: 200, message: '创建成功', data: { id: result.lastInsertRowid, name: name.trim() } });
});
router.delete('/project-types/:id', (req, res) => {
const db = getDB(req);
const { id } = req.params;
const pt = db.get('SELECT id FROM project_types WHERE id = ?', [parseInt(id)]);
if (!pt) return res.status(404).json({ code: 404, message: '标签不存在' });
db.run('DELETE FROM project_types WHERE id = ?', [parseInt(id)]);
res.json({ code: 200, message: '删除成功' });
});
// ========================
// Matrix Summary (研发工时汇总)
// rows = 项目-子项目, cols = 研发人员
// ========================
router.get('/summary-matrix', (req, res) => {
const db = getDB(req);
const { start_date, end_date } = req.query;
let sql = `SELECT
u.id as user_id, u.realname as user_name,
c.name as client_name,
COALESCE(t.project_type, '') as sub_project,
SUM(t.hours) as hours
FROM timesheets t
JOIN users u ON t.user_id = u.id
JOIN clients c ON t.client_id = c.id
WHERE 1=1`;
const params = [];
if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); }
if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); }
sql += ' GROUP BY u.id, c.id, t.project_type ORDER BY c.name, t.project_type, u.realname';
const rows = db.all(sql, params);
res.json({ code: 200, data: rows });
});
router.get('/export/matrix', (req, res) => {
const db = getDB(req);
const { start_date, end_date } = req.query;
let sql = `SELECT u.realname as user_name, c.name as client_name,
COALESCE(t.project_type, '') as sub_project, SUM(t.hours) as hours
FROM timesheets t
JOIN users u ON t.user_id = u.id JOIN clients c ON t.client_id = c.id WHERE 1=1`;
const params = [];
if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); }
if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); }
sql += ' GROUP BY u.realname, c.name, t.project_type';
const rows = db.all(sql, params);
// Build matrix
const rowKeys = [...new Set(rows.map(r => r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name))];
const colKeys = [...new Set(rows.map(r => r.user_name))].sort();
const cellMap = {};
rows.forEach(r => {
const rk = r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name;
if (!cellMap[rk]) cellMap[rk] = {};
cellMap[rk][r.user_name] = (cellMap[rk][r.user_name] || 0) + r.hours;
});
const headers = ['项目/子项目', ...colKeys, '合计'];
const data = rowKeys.map(rk => {
const rowTotal = colKeys.reduce((s, c) => s + (cellMap[rk]?.[c] || 0), 0);
return [rk, ...colKeys.map(c => cellMap[rk]?.[c] || 0), rowTotal];
});
const colTotals = ['合计', ...colKeys.map(c => data.reduce((s, r) => s + r[colKeys.indexOf(c) + 1], 0)), data.reduce((s, r) => s + r[r.length - 1], 0)];
data.push(colTotals);
const wb = XLSX.utils.book_new();
const ws = createSheet(headers, data);
XLSX.utils.book_append_sheet(wb, ws, '研发工时汇总');
const filename = `研发工时汇总_${start_date || '开始'}${end_date || '至今'}`;
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(filename)}.xlsx`);
res.send(Buffer.from(buf));
});
// ========================
// Admin: Reset Data
// ========================
router.post('/reset', (req, res) => {
const db = getDB(req);
db.run('PRAGMA foreign_keys = OFF');
db.run('DELETE FROM timesheets');
db.run('DELETE FROM clients');
db.run('PRAGMA foreign_keys = ON');
res.json({ code: 200, message: '所有项目及工时数据已清除,人员账号保留。' });
});
// ========================
// Admin: Backup & Restore
// ========================
const path = require('path');
const fs = require('fs');
const BACKUP_DIR = path.join(__dirname, '..', 'backups');
function ensureBackupDir() {
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
function buildBackupJson(db) {
const users = db.all('SELECT * FROM users');
const clients = db.all('SELECT * FROM clients');
const timesheets = db.all('SELECT * FROM timesheets');
return JSON.stringify({ version: 1, created_at: new Date().toISOString(), users, clients, timesheets }, null, 2);
}
// Manual backup download
router.get('/backup/download', (req, res) => {
const db = getDB(req);
const json = buildBackupJson(db);
const date = new Date().toISOString().slice(0, 10);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename=backup-${date}.json`);
res.send(json);
});
// List auto-backup files
router.get('/backups', (req, res) => {
ensureBackupDir();
const files = fs.readdirSync(BACKUP_DIR)
.filter(f => f.endsWith('.json'))
.map(f => {
const stat = fs.statSync(path.join(BACKUP_DIR, f));
return { name: f, size: stat.size, created_at: stat.mtime.toISOString() };
})
.sort((a, b) => b.name.localeCompare(a.name));
res.json({ code: 200, data: files });
});
// Download a specific auto-backup
router.get('/backups/:filename', (req, res) => {
ensureBackupDir();
const filePath = path.join(BACKUP_DIR, req.params.filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ code: 404, message: '文件不存在' });
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename=${req.params.filename}`);
res.sendFile(filePath);
});
// Restore from JSON
router.post('/restore', (req, res) => {
const db = getDB(req);
const { users, clients, timesheets } = req.body;
if (!users || !clients || !timesheets) {
return res.status(400).json({ code: 400, message: '备份文件格式不正确,缺少必要字段' });
}
try {
db.run('PRAGMA foreign_keys = OFF');
db.run('DELETE FROM timesheets');
db.run('DELETE FROM clients');
db.run('DELETE FROM users');
for (const u of users) {
db.run('INSERT OR IGNORE INTO users (id, username, password, realname, role, status, created_at) VALUES (?,?,?,?,?,?,?)',
[u.id, u.username, u.password, u.realname, u.role, u.status, u.created_at]);
}
for (const c of clients) {
db.run('INSERT OR IGNORE INTO clients (id, name, description, sales_id, status, service_types, created_at) VALUES (?,?,?,?,?,?,?)',
[c.id, c.name, c.description || '', c.sales_id, c.status, c.service_types || '[]', c.created_at]);
}
for (const t of timesheets) {
db.run('INSERT OR IGNORE INTO timesheets (id, user_id, client_id, work_date, hours, remark, service_type, project_type, created_at) VALUES (?,?,?,?,?,?,?,?,?)',
[t.id, t.user_id, t.client_id, t.work_date, t.hours, t.remark || '', t.service_type || '售前', t.project_type || '', t.created_at]);
}
db.run('PRAGMA foreign_keys = ON');
res.json({ code: 200, message: `恢复成功:${users.length} 名用户、${clients.length} 个项目、${timesheets.length} 条工时记录` });
} catch (e) {
db.run('PRAGMA foreign_keys = ON');
res.status(500).json({ code: 500, message: '恢复失败:' + e.message });
}
});
module.exports = router;
module.exports.buildBackupJson = buildBackupJson;
module.exports.BACKUP_DIR = BACKUP_DIR;
module.exports.ensureBackupDir = ensureBackupDir;