802 lines
33 KiB
TypeScript
802 lines
33 KiB
TypeScript
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;
|