first
This commit is contained in:
@@ -0,0 +1,657 @@
|
||||
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', (req, res) => {
|
||||
const db = getDB(req);
|
||||
const year = parseInt(req.query.year) || new Date().getFullYear();
|
||||
const workdays = db.all('SELECT * FROM workdays WHERE year = ? ORDER BY month', [year]);
|
||||
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: '设置成功' });
|
||||
});
|
||||
|
||||
// ========================
|
||||
// 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;
|
||||
@@ -0,0 +1,65 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const db = require('../db');
|
||||
const { JWT_SECRET } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ code: 400, message: '用户名和密码不能为空' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
if (!user) {
|
||||
return res.status(401).json({ code: 401, message: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
if (user.status !== 1) {
|
||||
return res.status(403).json({ code: 403, message: '账号已被禁用' });
|
||||
}
|
||||
|
||||
if (user.role === 'sales') {
|
||||
return res.status(403).json({ code: 403, message: '销售人员无需登录系统' });
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(password, user.password)) {
|
||||
return res.status(401).json({ code: 401, message: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username, role: user.role, realname: user.realname },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
message: '登录成功',
|
||||
data: {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
realname: user.realname,
|
||||
role: user.role
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', (req, res) => {
|
||||
// This route requires authentication (handled in middleware)
|
||||
const user = db.prepare('SELECT id, username, realname, role, status FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ code: 404, message: '用户不存在' });
|
||||
}
|
||||
res.json({ code: 200, data: user });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,176 @@
|
||||
const express = require('express');
|
||||
const { requireRole } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireRole('rd'));
|
||||
|
||||
function getDB(req) { return req.app.locals.db; }
|
||||
|
||||
// GET /api/rd/clients — active clients for dropdown
|
||||
router.get('/clients', (req, res) => {
|
||||
const db = getDB(req);
|
||||
const clients = db.all("SELECT c.id, c.name, c.service_types, u.realname as sales_name FROM clients c JOIN users u ON c.sales_id = u.id WHERE c.status = 1 ORDER BY c.name");
|
||||
const result = clients.map(c => ({ ...c, service_types: JSON.parse(c.service_types || '[]') }));
|
||||
res.json({ code: 200, data: result });
|
||||
});
|
||||
|
||||
// GET /api/rd/project-types — for dropdown in timesheet form
|
||||
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') });
|
||||
});
|
||||
|
||||
// GET /api/rd/timesheets?date=YYYY-MM-DD or ?month=YYYY-MM
|
||||
router.get('/timesheets', (req, res) => {
|
||||
const db = getDB(req);
|
||||
const { date, month } = req.query;
|
||||
|
||||
if (date) {
|
||||
const records = db.all(`SELECT t.id, t.user_id, t.client_id, t.work_date, t.hours, t.remark, t.service_type, t.project_type, t.created_at, c.name as client_name
|
||||
FROM timesheets t JOIN clients c ON t.client_id = c.id WHERE t.user_id = ? AND t.work_date = ? ORDER BY t.created_at`,
|
||||
[req.user.id, date]);
|
||||
const dayTotal = db.get('SELECT COALESCE(SUM(hours), 0) as total FROM timesheets WHERE user_id = ? AND work_date = ?', [req.user.id, date]);
|
||||
return res.json({ code: 200, data: { records, day_total: dayTotal ? dayTotal.total : 0 } });
|
||||
}
|
||||
|
||||
if (month) {
|
||||
const records = db.all(`SELECT t.id, t.user_id, t.client_id, t.work_date, t.hours, t.remark, t.service_type, t.project_type, t.created_at, c.name as client_name
|
||||
FROM timesheets t JOIN clients c ON t.client_id = c.id WHERE t.user_id = ? AND t.work_date LIKE ? ORDER BY t.work_date DESC, t.created_at`,
|
||||
[req.user.id, month + '%']);
|
||||
const monthTotal = db.get("SELECT COALESCE(SUM(hours), 0) as total FROM timesheets WHERE user_id = ? AND work_date LIKE ?", [req.user.id, month + '%']);
|
||||
return res.json({ code: 200, data: { records, month_total: monthTotal ? monthTotal.total : 0 } });
|
||||
}
|
||||
|
||||
res.status(400).json({ code: 400, message: '请提供 date 或 month 参数' });
|
||||
});
|
||||
|
||||
// POST /api/rd/timesheets
|
||||
router.post('/timesheets', (req, res) => {
|
||||
const db = getDB(req);
|
||||
const { client_id, work_date, hours, remark, service_type, project_type } = req.body;
|
||||
if (!client_id || !work_date || !hours) return res.status(400).json({ code: 400, message: '客户、日期和工时不能为空' });
|
||||
if (hours <= 0 || hours > 8) return res.status(400).json({ code: 400, message: '单项工时必须在0~8小时之间' });
|
||||
|
||||
// Backfill 14-days check
|
||||
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
|
||||
const todayDate = new Date(todayStr + "T00:00:00+08:00");
|
||||
const minDate = new Date(todayDate);
|
||||
minDate.setDate(minDate.getDate() - 40);
|
||||
const wDate = new Date(work_date + "T00:00:00+08:00");
|
||||
|
||||
if (wDate < minDate || wDate > todayDate) {
|
||||
return res.status(400).json({ code: 400, message: '只能填报今天及前40天内的工时' });
|
||||
}
|
||||
|
||||
// Daily limit check
|
||||
const dayTotal = db.get('SELECT COALESCE(SUM(hours), 0) as total FROM timesheets WHERE user_id = ? AND work_date = ?', [req.user.id, work_date]);
|
||||
const currentDayTotal = dayTotal ? dayTotal.total : 0;
|
||||
if (currentDayTotal + hours > 8) {
|
||||
return res.status(400).json({ code: 400, message: `当日已填报 ${currentDayTotal} 小时,本次填报 ${hours} 小时将超过8小时上限` });
|
||||
}
|
||||
|
||||
// Monthly limit check
|
||||
const yearMonth = work_date.substring(0, 7);
|
||||
const year = parseInt(work_date.substring(0, 4));
|
||||
const month = parseInt(work_date.substring(5, 7));
|
||||
const workdayConfig = db.get('SELECT days FROM workdays WHERE year = ? AND month = ?', [year, month]);
|
||||
if (workdayConfig) {
|
||||
const monthlyLimit = workdayConfig.days * 8;
|
||||
const monthTotal = db.get("SELECT COALESCE(SUM(hours), 0) as total FROM timesheets WHERE user_id = ? AND work_date LIKE ?", [req.user.id, yearMonth + '%']);
|
||||
const currentMonthTotal = monthTotal ? monthTotal.total : 0;
|
||||
if (currentMonthTotal + hours > monthlyLimit) {
|
||||
return res.status(400).json({ code: 400, message: `当月已填报 ${currentMonthTotal} 小时,上限 ${monthlyLimit} 小时(${workdayConfig.days}个工作日×8小时),无法继续填报` });
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicate check
|
||||
const existing = db.get('SELECT id FROM timesheets WHERE user_id = ? AND client_id = ? AND work_date = ? AND project_type = ?', [req.user.id, parseInt(client_id), work_date, project_type || '']);
|
||||
if (existing) return res.status(400).json({ code: 400, message: '该子项目在当日已有工时记录,请修改原有记录,而非重复提交' });
|
||||
|
||||
const result = db.run('INSERT INTO timesheets (user_id, client_id, work_date, hours, remark, service_type, project_type) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[req.user.id, parseInt(client_id), work_date, hours, remark || '', service_type || '售前', project_type || '']);
|
||||
res.json({ code: 200, message: '提交成功', data: { id: result.lastInsertRowid } });
|
||||
});
|
||||
|
||||
// PUT /api/rd/timesheets/:id
|
||||
router.put('/timesheets/:id', (req, res) => {
|
||||
const db = getDB(req);
|
||||
const { id } = req.params;
|
||||
const { hours, remark, service_type, project_type } = req.body;
|
||||
const record = db.get('SELECT * FROM timesheets WHERE id = ? AND user_id = ?', [parseInt(id), req.user.id]);
|
||||
if (!record) return res.status(404).json({ code: 404, message: '记录不存在或无权操作' });
|
||||
|
||||
// Cannot edit if work_date is out of 14-days range (strict enforcement)
|
||||
const todayStr = new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
|
||||
const todayDate = new Date(todayStr + "T00:00:00+08:00");
|
||||
const minDate = new Date(todayDate);
|
||||
minDate.setDate(minDate.getDate() - 40);
|
||||
const wDate = new Date(record.work_date + "T00:00:00+08:00");
|
||||
if (wDate < minDate || wDate > todayDate) {
|
||||
return res.status(400).json({ code: 400, message: '该记录已超出40天填报期限,无法修改' });
|
||||
}
|
||||
|
||||
if (hours !== undefined) {
|
||||
if (hours <= 0 || hours > 8) return res.status(400).json({ code: 400, message: '工时必须在0~8小时之间' });
|
||||
const dayTotal = db.get('SELECT COALESCE(SUM(hours), 0) as total FROM timesheets WHERE user_id = ? AND work_date = ? AND id != ?', [req.user.id, record.work_date, parseInt(id)]);
|
||||
const otherDayTotal = dayTotal ? dayTotal.total : 0;
|
||||
if (otherDayTotal + hours > 8) {
|
||||
return res.status(400).json({ code: 400, message: `当日其他记录已有 ${otherDayTotal} 小时,修改为 ${hours} 小时将超过8小时上限` });
|
||||
}
|
||||
const yearMonth = record.work_date.substring(0, 7);
|
||||
const year = parseInt(record.work_date.substring(0, 4));
|
||||
const month = parseInt(record.work_date.substring(5, 7));
|
||||
const workdayConfig = db.get('SELECT days FROM workdays WHERE year = ? AND month = ?', [year, month]);
|
||||
if (workdayConfig) {
|
||||
const monthlyLimit = workdayConfig.days * 8;
|
||||
const monthTotal = db.get("SELECT COALESCE(SUM(hours), 0) as total FROM timesheets WHERE user_id = ? AND work_date LIKE ? AND id != ?", [req.user.id, yearMonth + '%', parseInt(id)]);
|
||||
const otherMonthTotal = monthTotal ? monthTotal.total : 0;
|
||||
if (otherMonthTotal + hours > monthlyLimit) {
|
||||
return res.status(400).json({ code: 400, message: `当月上限 ${monthlyLimit} 小时,修改后将超出限制` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updates = [], params = [];
|
||||
if (hours !== undefined) { updates.push('hours = ?'); params.push(hours); }
|
||||
if (remark !== undefined) { updates.push('remark = ?'); params.push(remark); }
|
||||
if (service_type !== undefined) { updates.push('service_type = ?'); params.push(service_type); }
|
||||
if (project_type !== undefined) { updates.push('project_type = ?'); params.push(project_type); }
|
||||
if (updates.length === 0) return res.status(400).json({ code: 400, message: '没有可更新的字段' });
|
||||
params.push(parseInt(id));
|
||||
db.run(`UPDATE timesheets SET ${updates.join(', ')} WHERE id = ?`, params);
|
||||
res.json({ code: 200, message: '更新成功' });
|
||||
});
|
||||
|
||||
// DELETE /api/rd/timesheets/:id
|
||||
router.delete('/timesheets/:id', (req, res) => {
|
||||
const db = getDB(req);
|
||||
const record = db.get('SELECT id FROM timesheets WHERE id = ? AND user_id = ?', [parseInt(req.params.id), req.user.id]);
|
||||
if (!record) return res.status(404).json({ code: 404, message: '记录不存在或无权操作' });
|
||||
db.run('DELETE FROM timesheets WHERE id = ?', [parseInt(req.params.id)]);
|
||||
res.json({ code: 200, message: '删除成功' });
|
||||
});
|
||||
|
||||
// GET /api/rd/stats
|
||||
router.get('/stats', (req, res) => {
|
||||
const db = getDB(req);
|
||||
const { start_date, end_date } = req.query;
|
||||
|
||||
let tCond = '';
|
||||
const tParams = [req.user.id];
|
||||
|
||||
if (start_date) { tCond += ' AND t.work_date >= ?'; tParams.push(start_date); }
|
||||
if (end_date) { tCond += ' AND t.work_date <= ?'; tParams.push(end_date); }
|
||||
|
||||
const projects = db.all(`
|
||||
SELECT c.id as client_id, c.name as client_name, COALESCE(SUM(t.hours), 0) 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 WHERE t.user_id = ? ${tCond} GROUP BY c.id ORDER BY total_hours DESC
|
||||
`, tParams);
|
||||
|
||||
const total = db.get(`SELECT COALESCE(SUM(hours), 0) as total_hours FROM timesheets t WHERE t.user_id = ? ${tCond}`, tParams);
|
||||
|
||||
res.json({ code: 200, data: { projects, total_hours: total ? total.total_hours : 0 } });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user