first commit
This commit is contained in:
801
backend/src/routes/performance.ts
Normal file
801
backend/src/routes/performance.ts
Normal file
@@ -0,0 +1,801 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate } from '../middlewares/authenticate';
|
||||
import { authorize } from '../middlewares/authorize';
|
||||
import * as PerformanceDAO from '../dao/PerformanceDAO';
|
||||
import { calculateAttendanceScore, calculateLevelAndReward } from '../services/CalculationService';
|
||||
import { exportPerformanceExcel } from '../services/ExportService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All performance routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// ─── 6.1 POST /api/performance/submit ────────────────────────────────────────
|
||||
// Supports draft (暂存) and submit (提交) states.
|
||||
// Writes performance_month, perf_item, attendance in a transaction.
|
||||
// On submit, triggers async AI evaluation (non-blocking).
|
||||
router.post(
|
||||
'/submit',
|
||||
authorize('employee'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { month, status, selfScore, workSummary, items, performanceItems, attendance } = req.body;
|
||||
|
||||
if (!month || !status) {
|
||||
return res.status(400).json({ code: 400, message: '月份和状态不能为空' });
|
||||
}
|
||||
if (status !== 'draft' && status !== 'submitted') {
|
||||
return res.status(400).json({ code: 400, message: '状态值无效,必须为 draft 或 submitted' });
|
||||
}
|
||||
|
||||
// If submitting, check for existing submitted/completed record (idempotency guard)
|
||||
if (status === 'submitted') {
|
||||
const existing = await PerformanceDAO.findByUserAndMonth(user.userId, month);
|
||||
if (existing && (existing.status === 'submitted' || existing.status === 'completed' || existing.status === 'under_review')) {
|
||||
return res.status(400).json({ code: 400, message: '该月份绩效已提交,不可重复提交' });
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate attendance score if attendance data provided
|
||||
let attendanceData: PerformanceDAO.AttendanceRow | undefined;
|
||||
if (attendance) {
|
||||
const score = calculateAttendanceScore({
|
||||
leaveDays: attendance.leave ?? attendance.leave_days ?? 0,
|
||||
lateTimes: attendance.late ?? attendance.late_times ?? 0,
|
||||
lackCardTimes: attendance.lackCard ?? attendance.lack_card_times ?? 0,
|
||||
});
|
||||
attendanceData = {
|
||||
perf_id: 0, // will be set by DAO
|
||||
leave_days: attendance.leave ?? attendance.leave_days ?? 0,
|
||||
late_times: attendance.late ?? attendance.late_times ?? 0,
|
||||
absent_days: attendance.absent ?? attendance.absent_days ?? 0,
|
||||
lack_card_times: attendance.lackCard ?? attendance.lack_card_times ?? 0,
|
||||
attendance_score: score,
|
||||
remark: attendance.remark ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert performanceItems to items format if needed
|
||||
const itemsData = performanceItems || items;
|
||||
const formattedItems = itemsData?.map((item: any) => ({
|
||||
item_name: item.itemName || item.item_name,
|
||||
item_category: item.itemCategory || item.item_category || 'business',
|
||||
weight: item.weight,
|
||||
user_content: item.userContent || item.user_content,
|
||||
self_score: item.selfScore || item.self_score,
|
||||
evidence_url: item.evidence || item.evidence_url,
|
||||
}));
|
||||
|
||||
// 计算自评总分:各项 self_score * weight 加权平均
|
||||
let calculatedSelfScore: number | undefined;
|
||||
if (formattedItems && formattedItems.length > 0) {
|
||||
let weightedSum = 0;
|
||||
let totalWeight = 0;
|
||||
for (const item of formattedItems) {
|
||||
if (item.self_score != null && item.weight != null) {
|
||||
weightedSum += Number(item.self_score) * Number(item.weight);
|
||||
totalWeight += Number(item.weight);
|
||||
}
|
||||
}
|
||||
if (totalWeight > 0) {
|
||||
calculatedSelfScore = parseFloat((weightedSum / totalWeight).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const perfId = await PerformanceDAO.upsert({
|
||||
userId: user.userId,
|
||||
month,
|
||||
status,
|
||||
selfScore: calculatedSelfScore,
|
||||
workSummary,
|
||||
items: formattedItems,
|
||||
attendance: attendanceData,
|
||||
});
|
||||
|
||||
// Trigger async AI evaluation on submit (non-blocking)
|
||||
if (status === 'submitted') {
|
||||
triggerAIEvaluation(perfId).catch((err) => {
|
||||
console.error(`[AI] Evaluation failed for perfId=${perfId}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ code: 200, message: status === 'draft' ? '暂存成功' : '提交成功', data: { perfId } });
|
||||
} catch (err: any) {
|
||||
// Duplicate key = already submitted
|
||||
if (err.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(400).json({ code: 400, message: '该月份绩效已存在' });
|
||||
}
|
||||
console.error('[submit]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 6.2 GET /api/performance/employee/get ───────────────────────────────────
|
||||
// Returns the authenticated employee's performance records.
|
||||
// Supports month filter and pagination. Includes AI result, attendance, review opinion.
|
||||
router.get(
|
||||
'/employee/get',
|
||||
authorize('employee'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { month, page = '1', pageSize = '10', perfId } = req.query as Record<string, string>;
|
||||
|
||||
// 如果提供了 perfId,返回单条记录详情
|
||||
if (perfId) {
|
||||
try {
|
||||
const { findDetailByPerfId } = await import('../dao/PerformanceDAO');
|
||||
const { findByPerfId: findAIByPerfId } = await import('../dao/AIResultDAO');
|
||||
|
||||
const detail = await findDetailByPerfId(parseInt(perfId, 10));
|
||||
if (!detail || detail.performance.user_id !== user.userId) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
const aiResult = await findAIByPerfId(parseInt(perfId, 10));
|
||||
const rec = detail.performance;
|
||||
|
||||
// 转换为驼峰格式,确保数值字段为 number 类型
|
||||
const result = {
|
||||
perfId: rec.perf_id,
|
||||
userId: rec.user_id,
|
||||
month: rec.month,
|
||||
status: rec.status,
|
||||
selfScore: rec.self_score != null ? Number(rec.self_score) : undefined,
|
||||
aiScore: rec.ai_score != null ? Number(rec.ai_score) : undefined,
|
||||
managerScore: rec.manager_score != null ? Number(rec.manager_score) : undefined,
|
||||
totalScore: rec.total_score != null ? Number(rec.total_score) : undefined,
|
||||
level: rec.level,
|
||||
rewardPunish: rec.reward_punish,
|
||||
workSummary: rec.work_summary,
|
||||
submitTime: rec.submit_time,
|
||||
reviewTime: rec.review_time,
|
||||
reviewOpinion: rec.review_opinion,
|
||||
performanceItems: detail.items?.map(item => ({
|
||||
itemId: item.item_id,
|
||||
perfId: item.perf_id,
|
||||
itemName: item.item_name,
|
||||
itemCategory: item.item_category,
|
||||
weight: Number(item.weight),
|
||||
userContent: item.user_content,
|
||||
selfScore: item.self_score != null ? Number(item.self_score) : undefined,
|
||||
aiScore: item.ai_score != null ? Number(item.ai_score) : undefined,
|
||||
aiExplanation: item.ai_explanation,
|
||||
managerScore: item.manager_score != null ? Number(item.manager_score) : undefined,
|
||||
managerExplanation: item.manager_explanation,
|
||||
evidenceUrl: item.evidence_url,
|
||||
})) ?? [],
|
||||
attendance: detail.attendance ? {
|
||||
leave: Number(detail.attendance.leave_days),
|
||||
late: Number(detail.attendance.late_times),
|
||||
absent: Number(detail.attendance.absent_days),
|
||||
lackCard: Number(detail.attendance.lack_card_times),
|
||||
attendanceScore: detail.attendance.attendance_score != null ? Number(detail.attendance.attendance_score) : undefined,
|
||||
remark: detail.attendance.remark,
|
||||
} : null,
|
||||
aiResult: aiResult ? {
|
||||
aiId: aiResult.aiId,
|
||||
perfId: aiResult.perfId,
|
||||
aiScoreDetail: aiResult.aiScoreDetail,
|
||||
aiTotalScore: aiResult.aiTotalScore,
|
||||
aiProblems: aiResult.aiProblems,
|
||||
aiSuggestions: aiResult.aiSuggestions,
|
||||
createTime: aiResult.createTime,
|
||||
} : null,
|
||||
};
|
||||
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: '查询成功',
|
||||
data: result,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[employee/get detail]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
|
||||
// 否则返回列表
|
||||
const pageInfo: PerformanceDAO.PageInfo = {
|
||||
page: Math.max(1, parseInt(page, 10) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await PerformanceDAO.findByUserId(user.userId, month, pageInfo);
|
||||
|
||||
// Enrich each record with attendance and AI result
|
||||
const { findDetailByPerfId } = await import('../dao/PerformanceDAO');
|
||||
const { findByPerfId: findAIByPerfId } = await import('../dao/AIResultDAO');
|
||||
|
||||
const enriched = await Promise.all(
|
||||
result.records.map(async (rec) => {
|
||||
const detail = await findDetailByPerfId(rec.perf_id);
|
||||
const aiResult = await findAIByPerfId(rec.perf_id);
|
||||
|
||||
// 转换为驼峰格式,确保数值字段为 number 类型
|
||||
return {
|
||||
perfId: rec.perf_id,
|
||||
userId: rec.user_id,
|
||||
month: rec.month,
|
||||
status: rec.status,
|
||||
selfScore: rec.self_score != null ? Number(rec.self_score) : undefined,
|
||||
aiScore: rec.ai_score != null ? Number(rec.ai_score) : undefined,
|
||||
managerScore: rec.manager_score != null ? Number(rec.manager_score) : undefined,
|
||||
totalScore: rec.total_score != null ? Number(rec.total_score) : undefined,
|
||||
level: rec.level,
|
||||
rewardPunish: rec.reward_punish,
|
||||
workSummary: rec.work_summary,
|
||||
submitTime: rec.submit_time,
|
||||
reviewTime: rec.review_time,
|
||||
reviewOpinion: rec.review_opinion,
|
||||
performanceItems: detail?.items?.map(item => ({
|
||||
itemId: item.item_id,
|
||||
perfId: item.perf_id,
|
||||
itemName: item.item_name,
|
||||
itemCategory: item.item_category,
|
||||
weight: Number(item.weight),
|
||||
userContent: item.user_content,
|
||||
selfScore: item.self_score != null ? Number(item.self_score) : undefined,
|
||||
aiScore: item.ai_score != null ? Number(item.ai_score) : undefined,
|
||||
aiExplanation: item.ai_explanation,
|
||||
managerScore: item.manager_score != null ? Number(item.manager_score) : undefined,
|
||||
managerExplanation: item.manager_explanation,
|
||||
evidenceUrl: item.evidence_url,
|
||||
})) ?? [],
|
||||
attendance: detail?.attendance ? {
|
||||
leave: Number(detail.attendance.leave_days),
|
||||
late: Number(detail.attendance.late_times),
|
||||
absent: Number(detail.attendance.absent_days),
|
||||
lackCard: Number(detail.attendance.lack_card_times),
|
||||
attendanceScore: detail.attendance.attendance_score != null ? Number(detail.attendance.attendance_score) : undefined,
|
||||
remark: detail.attendance.remark,
|
||||
} : null,
|
||||
aiResult: aiResult ? {
|
||||
aiId: aiResult.aiId,
|
||||
perfId: aiResult.perfId,
|
||||
aiScoreDetail: aiResult.aiScoreDetail,
|
||||
aiTotalScore: aiResult.aiTotalScore,
|
||||
aiProblems: aiResult.aiProblems,
|
||||
aiSuggestions: aiResult.aiSuggestions,
|
||||
createTime: aiResult.createTime,
|
||||
} : null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: '查询成功',
|
||||
data: { total: result.total, records: enriched },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[employee/get]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 6.3 POST /api/performance/request-modification ──────────────────────────
|
||||
// Employee requests modification of a submitted performance record.
|
||||
// Records the reason and notifies the manager (logged for now).
|
||||
router.post(
|
||||
'/request-modification',
|
||||
authorize('employee'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { perfId, reason } = req.body;
|
||||
|
||||
if (!perfId || !reason) {
|
||||
return res.status(400).json({ code: 400, message: '绩效ID和申请原因不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(Number(perfId));
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (detail.performance.user_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
|
||||
// Only submitted records can request modification
|
||||
if (detail.performance.status !== 'submitted' && detail.performance.status !== 'under_review') {
|
||||
return res.status(400).json({ code: 400, message: '当前状态不允许申请修改' });
|
||||
}
|
||||
|
||||
// Record the modification request in operation_log
|
||||
const pool = (await import('../config/database')).default;
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'request_modification', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ reason })]
|
||||
);
|
||||
|
||||
// Notify manager: in a real system this would send a notification;
|
||||
// here we log it as a manager-targeted operation log entry.
|
||||
console.log(`[修改申请] 员工 ${user.name}(${user.userId}) 申请修改绩效 ${perfId},原因:${reason}`);
|
||||
|
||||
return res.json({ code: 200, message: '修改申请已提交,等待管理层审批' });
|
||||
} catch (err) {
|
||||
console.error('[request-modification]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 8.1 GET /api/performance/manager/list ───────────────────────────────────
|
||||
// Returns subordinates' performance list with optional filters and pagination.
|
||||
// Supports filtering by month, department, employee name, and status.
|
||||
// Requirements: 4.1, 7.1, 7.2
|
||||
router.get(
|
||||
'/manager/list',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const {
|
||||
month,
|
||||
department,
|
||||
employeeName,
|
||||
status,
|
||||
page = '1',
|
||||
pageSize = '10',
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const pageInfo: PerformanceDAO.PageInfo = {
|
||||
page: Math.max(1, parseInt(page, 10) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)),
|
||||
};
|
||||
|
||||
const filters: PerformanceDAO.PerformanceFilter = {
|
||||
month: month || undefined,
|
||||
department: department || undefined,
|
||||
employeeName: employeeName || undefined,
|
||||
status: (status as PerformanceDAO.PerformanceStatus) || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await PerformanceDAO.findByManagerId(user.userId, filters, pageInfo);
|
||||
|
||||
// Enrich each record with employee info
|
||||
const pool = (await import('../config/database')).default;
|
||||
const enriched = await Promise.all(
|
||||
result.records.map(async (rec) => {
|
||||
const [userRows] = await pool.query<any[]>(
|
||||
'SELECT name, department, position FROM user WHERE user_id = ? LIMIT 1',
|
||||
[rec.user_id]
|
||||
);
|
||||
return {
|
||||
perfId: rec.perf_id,
|
||||
userId: rec.user_id,
|
||||
month: rec.month,
|
||||
status: rec.status,
|
||||
selfScore: rec.self_score != null ? Number(rec.self_score) : null,
|
||||
aiScore: rec.ai_score != null ? Number(rec.ai_score) : null,
|
||||
managerScore: rec.manager_score != null ? Number(rec.manager_score) : null,
|
||||
totalScore: rec.total_score != null ? Number(rec.total_score) : null,
|
||||
level: rec.level,
|
||||
submitTime: rec.submit_time,
|
||||
reviewTime: rec.review_time,
|
||||
// 员工信息
|
||||
userName: userRows[0]?.name ?? null,
|
||||
userDepartment: userRows[0]?.department ?? null,
|
||||
userPosition: userRows[0]?.position ?? null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: '查询成功',
|
||||
data: { total: result.total, records: enriched },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[manager/list]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /api/performance/manager/detail/:perfId ─────────────────────────────
|
||||
// Returns full detail of a subordinate's performance record for manager review.
|
||||
router.get(
|
||||
'/manager/detail/:perfId',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const perfId = parseInt(req.params.perfId, 10);
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(perfId);
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify subordinate relationship (generalManager can view all)
|
||||
if (user.role !== 'generalManager') {
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id, name, department, position FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足' });
|
||||
}
|
||||
}
|
||||
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT name, department, position FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
|
||||
const { findByPerfId: findAIByPerfId } = await import('../dao/AIResultDAO');
|
||||
const aiResult = await findAIByPerfId(perfId);
|
||||
const rec = detail.performance;
|
||||
|
||||
const result = {
|
||||
perfId: rec.perf_id,
|
||||
userId: rec.user_id,
|
||||
month: rec.month,
|
||||
status: rec.status,
|
||||
selfScore: rec.self_score != null ? Number(rec.self_score) : null,
|
||||
aiScore: rec.ai_score != null ? Number(rec.ai_score) : null,
|
||||
managerScore: rec.manager_score != null ? Number(rec.manager_score) : null,
|
||||
totalScore: rec.total_score != null ? Number(rec.total_score) : null,
|
||||
level: rec.level,
|
||||
rewardPunish: rec.reward_punish,
|
||||
workSummary: rec.work_summary,
|
||||
submitTime: rec.submit_time,
|
||||
reviewTime: rec.review_time,
|
||||
reviewOpinion: rec.review_opinion,
|
||||
// 员工信息
|
||||
userName: empRows[0]?.name ?? null,
|
||||
userDepartment: empRows[0]?.department ?? null,
|
||||
userPosition: empRows[0]?.position ?? null,
|
||||
performanceItems: detail.items?.map(item => ({
|
||||
itemId: item.item_id,
|
||||
itemName: item.item_name,
|
||||
itemCategory: item.item_category,
|
||||
weight: Number(item.weight),
|
||||
userContent: item.user_content,
|
||||
selfScore: item.self_score != null ? Number(item.self_score) : null,
|
||||
aiScore: item.ai_score != null ? Number(item.ai_score) : null,
|
||||
aiExplanation: item.ai_explanation,
|
||||
managerScore: item.manager_score != null ? Number(item.manager_score) : null,
|
||||
managerExplanation: item.manager_explanation,
|
||||
evidenceUrl: item.evidence_url,
|
||||
})) ?? [],
|
||||
attendance: detail.attendance ? {
|
||||
leave: Number(detail.attendance.leave_days),
|
||||
late: Number(detail.attendance.late_times),
|
||||
absent: Number(detail.attendance.absent_days),
|
||||
lackCard: Number(detail.attendance.lack_card_times),
|
||||
attendanceScore: detail.attendance.attendance_score != null ? Number(detail.attendance.attendance_score) : null,
|
||||
remark: detail.attendance.remark,
|
||||
} : null,
|
||||
aiResult: aiResult ? {
|
||||
aiTotalScore: aiResult.aiTotalScore,
|
||||
aiProblems: aiResult.aiProblems,
|
||||
aiSuggestions: aiResult.aiSuggestions,
|
||||
} : null,
|
||||
};
|
||||
|
||||
return res.json({ code: 200, message: '查询成功', data: result });
|
||||
} catch (err) {
|
||||
console.error('[manager/detail]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 8.2 POST /api/performance/manager/review ────────────────────────────────
|
||||
// Manager reviews a performance record: updates item scores, calculates final
|
||||
// total score, level, reward/punishment, and archives the record (status → completed).
|
||||
// Requirements: 4.3, 4.4, 4.5, 4.7
|
||||
router.post(
|
||||
'/manager/review',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { perfId, reviewOpinion, itemScores } = req.body;
|
||||
|
||||
if (!perfId || !reviewOpinion || !Array.isArray(itemScores)) {
|
||||
return res.status(400).json({ code: 400, message: '绩效ID、审核意见和考核项评分不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(Number(perfId));
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify the performance belongs to a subordinate of this manager
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
// Only submitted/under_review records can be reviewed
|
||||
if (
|
||||
detail.performance.status !== 'submitted' &&
|
||||
detail.performance.status !== 'under_review'
|
||||
) {
|
||||
return res.status(400).json({ code: 400, message: '当前状态不允许审核' });
|
||||
}
|
||||
|
||||
// Build a map of item scores provided by manager
|
||||
const scoreMap = new Map<string, { managerScore: number; managerExplanation: string }>();
|
||||
for (const s of itemScores) {
|
||||
if (typeof s.itemName !== 'string' || typeof s.managerScore !== 'number') {
|
||||
return res.status(400).json({ code: 400, message: '考核项评分格式无效' });
|
||||
}
|
||||
scoreMap.set(s.itemName, {
|
||||
managerScore: s.managerScore,
|
||||
managerExplanation: s.managerExplanation ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate manager total score: weighted average of item manager scores
|
||||
let weightedSum = 0;
|
||||
let totalWeight = 0;
|
||||
for (const item of detail.items) {
|
||||
const scored = scoreMap.get(item.item_name);
|
||||
const score = scored ? scored.managerScore : (item.ai_score ?? item.self_score ?? 0);
|
||||
weightedSum += score * item.weight;
|
||||
totalWeight += item.weight;
|
||||
}
|
||||
const managerScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
|
||||
// Get attendance score
|
||||
const attendanceScore = detail.attendance?.attendance_score ?? 0;
|
||||
|
||||
// Final total score = manager score (already weighted across items) + attendance contribution
|
||||
// The items already include attendance-category items; use managerScore directly as total
|
||||
const totalScore = Math.min(100, Math.max(0, parseFloat(managerScore.toFixed(2))));
|
||||
|
||||
const { level, rewardPunish } = calculateLevelAndReward(totalScore);
|
||||
|
||||
const reviewData: PerformanceDAO.ReviewData = {
|
||||
managerScore: parseFloat(managerScore.toFixed(2)),
|
||||
reviewOpinion,
|
||||
totalScore,
|
||||
level,
|
||||
rewardPunish,
|
||||
itemScores: detail.items.map((item) => {
|
||||
const scored = scoreMap.get(item.item_name);
|
||||
return {
|
||||
itemName: item.item_name,
|
||||
managerScore: scored ? scored.managerScore : (item.ai_score ?? item.self_score ?? 0),
|
||||
managerExplanation: scored ? scored.managerExplanation : '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
await PerformanceDAO.updateReview(Number(perfId), reviewData);
|
||||
|
||||
// Log the review action
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'review_performance', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ totalScore, level, rewardPunish })]
|
||||
);
|
||||
|
||||
return res.json({
|
||||
code: 200,
|
||||
message: '审核完成',
|
||||
data: { totalScore, level, rewardPunish },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[manager/review]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 8.3 POST /api/performance/manager/reject ────────────────────────────────
|
||||
// Manager rejects a performance record, recording the reason and setting status to rejected.
|
||||
// Requirements: 4.6
|
||||
router.post(
|
||||
'/manager/reject',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { perfId, reason } = req.body;
|
||||
|
||||
if (!perfId || !reason) {
|
||||
return res.status(400).json({ code: 400, message: '绩效ID和驳回原因不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(Number(perfId));
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify subordinate relationship
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
if (
|
||||
detail.performance.status !== 'submitted' &&
|
||||
detail.performance.status !== 'under_review'
|
||||
) {
|
||||
return res.status(400).json({ code: 400, message: '当前状态不允许驳回' });
|
||||
}
|
||||
|
||||
// Update status to rejected and record the reason in review_opinion
|
||||
await pool.query(
|
||||
`UPDATE performance_month
|
||||
SET status = 'rejected', review_opinion = ?, review_time = NOW()
|
||||
WHERE perf_id = ?`,
|
||||
[reason, perfId]
|
||||
);
|
||||
|
||||
// Log the rejection
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'reject_performance', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ reason })]
|
||||
);
|
||||
|
||||
console.log(`[驳回] 管理层 ${user.name}(${user.userId}) 驳回绩效 ${perfId},原因:${reason}`);
|
||||
|
||||
return res.json({ code: 200, message: '驳回成功,已通知员工' });
|
||||
} catch (err) {
|
||||
console.error('[manager/reject]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 8.4 POST /api/performance/manager/approve-modification ──────────────────
|
||||
// Manager approves or rejects an employee's modification request.
|
||||
// Approve: unlocks the performance record (status → draft).
|
||||
// Reject: notifies employee (logged).
|
||||
// Requirements: 12.3, 12.4
|
||||
router.post(
|
||||
'/manager/approve-modification',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { perfId, approved, rejectReason } = req.body;
|
||||
|
||||
if (!perfId || typeof approved !== 'boolean') {
|
||||
return res.status(400).json({ code: 400, message: '绩效ID和审批结果不能为空' });
|
||||
}
|
||||
if (!approved && !rejectReason) {
|
||||
return res.status(400).json({ code: 400, message: '拒绝时必须填写拒绝原因' });
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(Number(perfId));
|
||||
if (!detail) {
|
||||
return res.status(404).json({ code: 404, message: '绩效记录不存在' });
|
||||
}
|
||||
|
||||
// Verify subordinate relationship
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id, name FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
// Unlock: reset status to draft so employee can re-edit
|
||||
await pool.query(
|
||||
`UPDATE performance_month SET status = 'draft' WHERE perf_id = ?`,
|
||||
[perfId]
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'approve_modification', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ approved: true })]
|
||||
);
|
||||
|
||||
console.log(`[修改审批] 管理层 ${user.name}(${user.userId}) 同意员工 ${empRows[0].name} 修改绩效 ${perfId}`);
|
||||
return res.json({ code: 200, message: '已同意修改申请,绩效表单已解锁' });
|
||||
} else {
|
||||
// Reject: log the rejection reason as notification to employee
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, target_id, operation_detail)
|
||||
VALUES (?, 'reject_modification', 'performance', ?, ?)`,
|
||||
[user.userId, perfId, JSON.stringify({ approved: false, rejectReason })]
|
||||
);
|
||||
|
||||
console.log(`[修改审批] 管理层 ${user.name}(${user.userId}) 拒绝员工 ${empRows[0].name} 修改绩效 ${perfId},原因:${rejectReason}`);
|
||||
return res.json({ code: 200, message: '已拒绝修改申请,已通知员工' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[manager/approve-modification]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 10.3 GET /api/performance/export ────────────────────────────────────────
|
||||
// Exports performance records as an Excel file.
|
||||
// Query params:
|
||||
// - userId: export a single employee's history (employee self or manager/GM)
|
||||
// - month: restrict to a specific month
|
||||
// - scope: 'team' (manager's subordinates) | 'company' (GM, all employees)
|
||||
// Requirements: 7.3, 7.6, 8.4
|
||||
router.get(
|
||||
'/export',
|
||||
authorize('employee', 'manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { userId, month, scope } = req.query as Record<string, string>;
|
||||
|
||||
try {
|
||||
let filter: Parameters<typeof exportPerformanceExcel>[0] = {};
|
||||
|
||||
if (user.role === 'employee') {
|
||||
// Employees can only export their own data
|
||||
filter.userId = user.userId;
|
||||
if (month) filter.month = month;
|
||||
} else if (user.role === 'manager') {
|
||||
if (userId) {
|
||||
// Export a specific subordinate's history — verify relationship
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [empRows] = await pool.query<any[]>(
|
||||
'SELECT manager_id FROM user WHERE user_id = ? LIMIT 1',
|
||||
[Number(userId)]
|
||||
);
|
||||
if (!empRows[0] || empRows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
filter.userId = Number(userId);
|
||||
} else {
|
||||
// Export entire team
|
||||
filter.managerId = user.userId;
|
||||
}
|
||||
if (month) filter.month = month;
|
||||
} else if (user.role === 'generalManager') {
|
||||
if (userId) {
|
||||
filter.userId = Number(userId);
|
||||
} else if (scope === 'company') {
|
||||
filter.allEmployees = true;
|
||||
} else {
|
||||
filter.allEmployees = true;
|
||||
}
|
||||
if (month) filter.month = month;
|
||||
}
|
||||
|
||||
const buffer = await exportPerformanceExcel(filter);
|
||||
|
||||
const filename = encodeURIComponent(`绩效数据_${month ?? '全部'}.xlsx`);
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${filename}`);
|
||||
return res.send(buffer);
|
||||
} catch (err) {
|
||||
console.error('[performance/export]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Internal helper ──────────────────────────────────────────────────────────
|
||||
async function triggerAIEvaluation(perfId: number): Promise<void> {
|
||||
const { evaluatePerformance } = await import('../services/AIEvaluationService');
|
||||
await evaluatePerformance(perfId);
|
||||
}
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user