first commit
This commit is contained in:
18
backend/.env.example
Normal file
18
backend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# 数据库配置
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password_here
|
||||
DB_NAME=employee_performance
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=your_jwt_secret_here_change_in_production
|
||||
|
||||
# FastGPT API 配置
|
||||
FASTGPT_API_KEY=your_fastgpt_api_key_here
|
||||
FASTGPT_API_URL=https://api.fastgpt.in/api/v1/chat/completions
|
||||
FASTGPT_MODEL=gpt-4
|
||||
|
||||
# 服务器配置
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
5
backend/.gitignore
vendored
Normal file
5
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
coverage/
|
||||
21
backend/backfill-ai.ts
Normal file
21
backend/backfill-ai.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [results] = await pool.query<any[]>('SELECT ai_id, perf_id, ai_score_json FROM ai_result');
|
||||
for (const row of results) {
|
||||
const items = JSON.parse(row.ai_score_json);
|
||||
for (const item of items) {
|
||||
await pool.query(
|
||||
'UPDATE perf_item SET ai_score = ?, ai_explanation = ? WHERE perf_id = ? AND item_name = ?',
|
||||
[item.aiScore, item.scoreExplanation, row.perf_id, item.itemName]
|
||||
);
|
||||
}
|
||||
console.log(`回写 perfId=${row.perf_id},共 ${items.length} 项`);
|
||||
}
|
||||
await pool.end();
|
||||
console.log('完成');
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
38
backend/backfill-self-score.ts
Normal file
38
backend/backfill-self-score.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
// 找出所有 self_score 为空但有 perf_item 的记录
|
||||
const [perfs] = await pool.query<any[]>(
|
||||
`SELECT perf_id FROM performance_month WHERE self_score IS NULL OR self_score = 0`
|
||||
);
|
||||
|
||||
for (const perf of perfs) {
|
||||
const [items] = await pool.query<any[]>(
|
||||
'SELECT weight, self_score FROM perf_item WHERE perf_id = ? AND self_score IS NOT NULL',
|
||||
[perf.perf_id]
|
||||
);
|
||||
if (items.length === 0) continue;
|
||||
|
||||
let weightedSum = 0;
|
||||
let totalWeight = 0;
|
||||
for (const item of items) {
|
||||
weightedSum += Number(item.self_score) * Number(item.weight);
|
||||
totalWeight += Number(item.weight);
|
||||
}
|
||||
if (totalWeight === 0) continue;
|
||||
|
||||
const selfScore = parseFloat((weightedSum / totalWeight).toFixed(2));
|
||||
await pool.query(
|
||||
'UPDATE performance_month SET self_score = ? WHERE perf_id = ?',
|
||||
[selfScore, perf.perf_id]
|
||||
);
|
||||
console.log(`perfId=${perf.perf_id} 自评总分=${selfScore}`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
console.log('完成');
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
20
backend/check-ai-data.ts
Normal file
20
backend/check-ai-data.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT perf_id, ai_total_score, problems, suggestions FROM ai_result ORDER BY ai_id DESC LIMIT 2'
|
||||
);
|
||||
for (const row of rows) {
|
||||
console.log('=== perfId:', row.perf_id, '===');
|
||||
console.log('ai_total_score:', row.ai_total_score);
|
||||
console.log('problems raw:', row.problems);
|
||||
console.log('suggestions raw:', row.suggestions);
|
||||
console.log('problems parsed:', JSON.parse(row.problems || '[]'));
|
||||
console.log('suggestions parsed:', JSON.parse(row.suggestions || '[]'));
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
23
backend/check-ai-raw.ts
Normal file
23
backend/check-ai-raw.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
import * as AIResultDAO from './src/dao/AIResultDAO';
|
||||
|
||||
async function run() {
|
||||
// 直接查原始数据
|
||||
const [rows] = await pool.query<any[]>('SELECT * FROM ai_result WHERE perf_id = 9');
|
||||
console.log('原始数据库行:', rows[0]);
|
||||
console.log('problems 类型:', typeof rows[0]?.problems);
|
||||
console.log('suggestions 类型:', typeof rows[0]?.suggestions);
|
||||
|
||||
// 通过 DAO 查
|
||||
const result = await AIResultDAO.findByPerfId(9);
|
||||
console.log('\nDAO 返回:');
|
||||
console.log(' aiTotalScore:', result?.aiTotalScore, typeof result?.aiTotalScore);
|
||||
console.log(' aiProblems:', result?.aiProblems);
|
||||
console.log(' aiSuggestions:', result?.aiSuggestions);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
52
backend/check-ai-results.ts
Normal file
52
backend/check-ai-results.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function checkAIResults() {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('=== AI评分结果 ===');
|
||||
const [aiRows] = await conn.query(`
|
||||
SELECT ai_id, perf_id, ai_total_score,
|
||||
LEFT(problems, 100) as problems_preview,
|
||||
LEFT(suggestions, 100) as suggestions_preview,
|
||||
create_time
|
||||
FROM ai_result
|
||||
`);
|
||||
console.table(aiRows);
|
||||
|
||||
console.log('\n=== 绩效记录状态 ===');
|
||||
const [perfRows] = await conn.query(`
|
||||
SELECT perf_id, user_id, month, status, ai_score, submit_time
|
||||
FROM performance_month
|
||||
ORDER BY perf_id DESC
|
||||
`);
|
||||
console.table(perfRows);
|
||||
|
||||
// 检查是否有AI评分但没有更新到performance_month表
|
||||
console.log('\n=== 检查数据一致性 ===');
|
||||
const [inconsistent] = await conn.query(`
|
||||
SELECT pm.perf_id, pm.month, pm.ai_score as perf_ai_score, ar.ai_total_score as ai_result_score
|
||||
FROM performance_month pm
|
||||
LEFT JOIN ai_result ar ON pm.perf_id = ar.perf_id
|
||||
WHERE pm.status = 'submitted'
|
||||
`);
|
||||
console.table(inconsistent);
|
||||
|
||||
await conn.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkAIResults();
|
||||
43
backend/check-api-response.ts
Normal file
43
backend/check-api-response.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
import axios from 'axios';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
async function run() {
|
||||
// 找最新有 ai_result 的记录和对应 user_id
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.perf_id, pm.user_id, pm.month, ar.ai_total_score, ar.problems, ar.suggestions
|
||||
FROM performance_month pm
|
||||
JOIN ai_result ar ON pm.perf_id = ar.perf_id
|
||||
ORDER BY ar.ai_id DESC LIMIT 1`
|
||||
);
|
||||
const rec = rows[0];
|
||||
console.log('测试记录:', rec.perf_id, 'userId:', rec.user_id, 'month:', rec.month);
|
||||
|
||||
// 获取该用户信息
|
||||
const [users] = await pool.query<any[]>('SELECT username, role, name FROM user WHERE user_id = ?', [rec.user_id]);
|
||||
const user = users[0];
|
||||
console.log('用户:', user);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: rec.user_id, username: user.username, role: user.role, name: user.name },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
const { data } = await axios.get('http://localhost:3001/api/performance/employee/get', {
|
||||
params: { perfId: rec.perf_id },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
const aiResult = data.data?.aiResult;
|
||||
console.log('\naiResult:');
|
||||
console.log(' aiTotalScore:', aiResult?.aiTotalScore, typeof aiResult?.aiTotalScore);
|
||||
console.log(' aiProblems:', aiResult?.aiProblems);
|
||||
console.log(' aiSuggestions:', aiResult?.aiSuggestions);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
16
backend/check-completed.ts
Normal file
16
backend/check-completed.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.perf_id, pm.user_id, pm.month, pm.status, pm.total_score, pm.level, pm.manager_score,
|
||||
u.manager_id
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
ORDER BY pm.perf_id`
|
||||
);
|
||||
console.table(rows);
|
||||
await pool.end();
|
||||
}
|
||||
run().catch(console.error);
|
||||
50
backend/check-config.ts
Normal file
50
backend/check-config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [rows] = await pool.query<any[]>('SELECT COUNT(*) as cnt FROM performance_rules');
|
||||
console.log('performance_rules 记录数:', rows[0].cnt);
|
||||
|
||||
if (rows[0].cnt === 0) {
|
||||
console.log('表为空,插入默认规则...');
|
||||
const defaults = [
|
||||
['business_completion_weight', '15', '工作完成度权重'],
|
||||
['business_quality_weight', '10', '工作质量权重'],
|
||||
['business_efficiency_weight', '10', '工作效率权重'],
|
||||
['business_skill_weight', '10', '专业技能权重'],
|
||||
['business_innovation_weight', '5', '创新能力权重'],
|
||||
['business_problem_solving_weight', '5', '问题解决权重'],
|
||||
['business_customer_satisfaction_weight', '5', '客户满意度权重'],
|
||||
['business_teamwork_weight', '5', '团队协作权重'],
|
||||
['business_goal_achievement_weight', '5', '目标达成权重'],
|
||||
['comprehensive_responsibility_weight', '5', '责任心权重'],
|
||||
['comprehensive_initiative_weight', '4', '主动性权重'],
|
||||
['comprehensive_learning_weight', '4', '学习能力权重'],
|
||||
['comprehensive_communication_weight', '4', '沟通能力权重'],
|
||||
['comprehensive_execution_weight', '4', '执行力权重'],
|
||||
['comprehensive_discipline_weight', '4', '纪律性权重'],
|
||||
['comprehensive_team_spirit_weight', '5', '团队精神权重'],
|
||||
['comprehensive_attendance_weight', '10', '考勤权重'],
|
||||
['reward_excellent', '500', '优秀奖励金额'],
|
||||
['punish_qualified_high', '0', '合格(80-89)扣款'],
|
||||
['punish_qualified_low', '100', '合格(70-79)扣款'],
|
||||
['punish_need_motivation', '200', '需激励扣款'],
|
||||
['punish_unqualified', '500', '不合格扣款'],
|
||||
['attendance_leave_deduct', '5', '事假每天扣分'],
|
||||
['attendance_late_deduct', '2', '迟到每次扣分'],
|
||||
['attendance_lack_card_deduct', '2', '缺卡每次扣分'],
|
||||
['attendance_base_score', '10', '考勤基础分'],
|
||||
];
|
||||
for (const [key, value, desc] of defaults) {
|
||||
await pool.query(
|
||||
'INSERT INTO performance_rules (rule_key, rule_value, description) VALUES (?, ?, ?)',
|
||||
[key, value, desc]
|
||||
);
|
||||
}
|
||||
console.log('默认规则插入完成');
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
run().catch(console.error);
|
||||
31
backend/check-db.ts
Normal file
31
backend/check-db.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function checkDatabase() {
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('✓ 已连接到数据库\n');
|
||||
|
||||
// 查看所有表
|
||||
const [tables] = await connection.query('SHOW TABLES');
|
||||
console.log('数据库中的表:');
|
||||
console.log(tables);
|
||||
|
||||
await connection.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('❌ 错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkDatabase();
|
||||
19
backend/check-manager-relation.ts
Normal file
19
backend/check-manager-relation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [users] = await pool.query<any[]>(
|
||||
'SELECT user_id, username, name, role, manager_id FROM user ORDER BY role, user_id'
|
||||
);
|
||||
console.table(users);
|
||||
|
||||
const [perfs] = await pool.query<any[]>(
|
||||
'SELECT pm.perf_id, pm.user_id, u.name, u.manager_id, pm.month, pm.status FROM performance_month pm JOIN user u ON pm.user_id = u.user_id'
|
||||
);
|
||||
console.log('\n绩效记录与管理层关联:');
|
||||
console.table(perfs);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
run().catch(console.error);
|
||||
48
backend/check-records.ts
Normal file
48
backend/check-records.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function checkRecords() {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('=== 所有绩效记录 ===');
|
||||
const [rows] = await conn.query(`
|
||||
SELECT perf_id, user_id, month, status, self_score, ai_score, submit_time, created_at
|
||||
FROM performance_month
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
console.table(rows);
|
||||
|
||||
console.log('\n=== 员工用户信息 ===');
|
||||
const [users] = await conn.query(`
|
||||
SELECT user_id, username, name, role, department, position
|
||||
FROM user
|
||||
WHERE role = 'employee'
|
||||
`);
|
||||
console.table(users);
|
||||
|
||||
console.log('\n=== 绩效项数量统计 ===');
|
||||
const [itemCounts] = await conn.query(`
|
||||
SELECT perf_id, COUNT(*) as item_count
|
||||
FROM perf_item
|
||||
GROUP BY perf_id
|
||||
`);
|
||||
console.table(itemCounts);
|
||||
|
||||
await conn.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkRecords();
|
||||
49
backend/clear-test-data.ts
Normal file
49
backend/clear-test-data.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
console.log('开始清理测试数据...');
|
||||
|
||||
// 1. 清理 AI 结果
|
||||
const [ai] = await pool.query<any>('DELETE FROM ai_result');
|
||||
console.log(`删除 ai_result: ${ai.affectedRows} 条`);
|
||||
|
||||
// 2. 清理操作日志
|
||||
const [logs] = await pool.query<any>('DELETE FROM operation_log');
|
||||
console.log(`删除 operation_log: ${logs.affectedRows} 条`);
|
||||
|
||||
// 3. 清理考勤数据
|
||||
const [att] = await pool.query<any>('DELETE FROM attendance');
|
||||
console.log(`删除 attendance: ${att.affectedRows} 条`);
|
||||
|
||||
// 4. 清理绩效项明细
|
||||
const [items] = await pool.query<any>('DELETE FROM perf_item');
|
||||
console.log(`删除 perf_item: ${items.affectedRows} 条`);
|
||||
|
||||
// 5. 清理绩效主表
|
||||
const [perf] = await pool.query<any>('DELETE FROM performance_month');
|
||||
console.log(`删除 performance_month: ${perf.affectedRows} 条`);
|
||||
|
||||
// 6. 清理测试员工账号(保留管理层和总经理账号)
|
||||
const [users] = await pool.query<any>("DELETE FROM user WHERE role = 'employee'");
|
||||
console.log(`删除测试员工账号: ${users.affectedRows} 条`);
|
||||
|
||||
// 重置自增ID
|
||||
await pool.query('ALTER TABLE ai_result AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE operation_log AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE attendance AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE perf_item AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE performance_month AUTO_INCREMENT = 1');
|
||||
await pool.query('ALTER TABLE user AUTO_INCREMENT = 1');
|
||||
|
||||
// 查看剩余账号
|
||||
const [remaining] = await pool.query<any[]>('SELECT user_id, username, name, role FROM user ORDER BY role');
|
||||
console.log('\n剩余账号:');
|
||||
console.table(remaining);
|
||||
|
||||
await pool.end();
|
||||
console.log('\n清理完成!');
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
38
backend/clear-wyy.ts
Normal file
38
backend/clear-wyy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
// 查找 wyy 用户
|
||||
const [users] = await pool.query<any[]>("SELECT * FROM user WHERE username LIKE '%wyy%' OR name LIKE '%wyy%'");
|
||||
console.log('找到用户:', users);
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('未找到 wyy 用户,查看所有员工账号:');
|
||||
const [all] = await pool.query<any[]>("SELECT user_id, username, name, role FROM user WHERE role = 'employee'");
|
||||
console.table(all);
|
||||
await pool.end();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
const userId = user.user_id;
|
||||
|
||||
// 找到该用户的绩效记录
|
||||
const [perfs] = await pool.query<any[]>('SELECT perf_id FROM performance_month WHERE user_id = ?', [userId]);
|
||||
for (const perf of perfs) {
|
||||
await pool.query('DELETE FROM ai_result WHERE perf_id = ?', [perf.perf_id]);
|
||||
await pool.query('DELETE FROM attendance WHERE perf_id = ?', [perf.perf_id]);
|
||||
await pool.query('DELETE FROM perf_item WHERE perf_id = ?', [perf.perf_id]);
|
||||
}
|
||||
await pool.query('DELETE FROM performance_month WHERE user_id = ?', [userId]);
|
||||
await pool.query('DELETE FROM operation_log WHERE user_id = ?', [userId]);
|
||||
await pool.query('DELETE FROM user WHERE user_id = ?', [userId]);
|
||||
console.log(`已删除用户 ${user.username}(${user.name}) 及其所有数据`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
console.log('完成');
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
14
backend/create-db-user.sql
Normal file
14
backend/create-db-user.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- 创建新的数据库用户(如果你能以 root 身份登录 MySQL)
|
||||
-- 在 MySQL 命令行中执行此脚本
|
||||
|
||||
-- 创建用户(密码设置为 123456)
|
||||
CREATE USER IF NOT EXISTS 'emp_user'@'localhost' IDENTIFIED BY '123456';
|
||||
|
||||
-- 授予权限
|
||||
GRANT ALL PRIVILEGES ON employee_performance.* TO 'emp_user'@'localhost';
|
||||
|
||||
-- 刷新权限
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- 显示用户
|
||||
SELECT User, Host FROM mysql.user WHERE User = 'emp_user';
|
||||
48
backend/init-config-rules.ts
Normal file
48
backend/init-config-rules.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
const defaults: [string, string, string][] = [
|
||||
['business_completion_weight', '15', '工作完成度权重'],
|
||||
['business_quality_weight', '10', '工作质量权重'],
|
||||
['business_efficiency_weight', '10', '工作效率权重'],
|
||||
['business_skill_weight', '10', '专业技能权重'],
|
||||
['business_innovation_weight', '5', '创新能力权重'],
|
||||
['business_problem_solving_weight', '5', '问题解决权重'],
|
||||
['business_customer_satisfaction_weight', '5', '客户满意度权重'],
|
||||
['business_teamwork_weight', '5', '团队协作权重'],
|
||||
['business_goal_achievement_weight', '5', '目标达成权重'],
|
||||
['comprehensive_responsibility_weight', '5', '责任心权重'],
|
||||
['comprehensive_initiative_weight', '4', '主动性权重'],
|
||||
['comprehensive_learning_weight', '4', '学习能力权重'],
|
||||
['comprehensive_communication_weight', '4', '沟通能力权重'],
|
||||
['comprehensive_execution_weight', '4', '执行力权重'],
|
||||
['comprehensive_discipline_weight', '4', '纪律性权重'],
|
||||
['comprehensive_team_spirit_weight', '5', '团队精神权重'],
|
||||
['comprehensive_attendance_weight', '10', '考勤权重'],
|
||||
['reward_excellent', '500', '优秀奖励金额'],
|
||||
['punish_qualified_high', '0', '合格(80-89)扣款'],
|
||||
['punish_qualified_low', '100', '合格(70-79)扣款'],
|
||||
['punish_need_motivation', '200', '需激励扣款'],
|
||||
['punish_unqualified', '500', '不合格扣款'],
|
||||
['attendance_leave_deduct', '5', '事假每天扣分'],
|
||||
['attendance_late_deduct', '2', '迟到每次扣分'],
|
||||
['attendance_lack_card_deduct', '2', '缺卡每次扣分'],
|
||||
['attendance_base_score', '10', '考勤基础分'],
|
||||
];
|
||||
|
||||
async function run() {
|
||||
for (const [key, value, desc] of defaults) {
|
||||
await pool.query(
|
||||
`INSERT INTO performance_rules (rule_key, rule_value, description)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE description = VALUES(description)`,
|
||||
[key, value, desc]
|
||||
);
|
||||
}
|
||||
const [rows] = await pool.query<any[]>('SELECT COUNT(*) as cnt FROM performance_rules');
|
||||
console.log('规则总数:', rows[0].cnt);
|
||||
await pool.end();
|
||||
console.log('完成');
|
||||
}
|
||||
run().catch(console.error);
|
||||
74
backend/init-db-v2.ts
Normal file
74
backend/init-db-v2.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function initDatabase() {
|
||||
try {
|
||||
console.log('开始初始化数据库...\n');
|
||||
|
||||
// 连接到 MySQL
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
multipleStatements: true, // 允许执行多条语句
|
||||
});
|
||||
|
||||
console.log('✓ 已连接到 MySQL 服务器');
|
||||
|
||||
// 创建数据库
|
||||
await connection.query(`CREATE DATABASE IF NOT EXISTS ${process.env.DB_NAME || 'employee_performance'} DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci`);
|
||||
console.log('✓ 数据库已创建');
|
||||
|
||||
// 切换到目标数据库
|
||||
await connection.query(`USE ${process.env.DB_NAME || 'employee_performance'}`);
|
||||
|
||||
// 读取并执行初始化脚本
|
||||
const initSql = fs.readFileSync(path.join(__dirname, 'src/db/init.sql'), 'utf-8');
|
||||
|
||||
// 移除 CREATE DATABASE 和 USE 语句
|
||||
const cleanedSql = initSql
|
||||
.split('\n')
|
||||
.filter(line => !line.trim().startsWith('CREATE DATABASE') && !line.trim().startsWith('USE '))
|
||||
.join('\n');
|
||||
|
||||
await connection.query(cleanedSql);
|
||||
console.log('✓ 表结构已创建');
|
||||
|
||||
// 读取并执行种子数据脚本
|
||||
const seedSql = fs.readFileSync(path.join(__dirname, 'src/db/seed.sql'), 'utf-8');
|
||||
const cleanedSeedSql = seedSql
|
||||
.split('\n')
|
||||
.filter(line => !line.trim().startsWith('USE '))
|
||||
.join('\n');
|
||||
|
||||
await connection.query(cleanedSeedSql);
|
||||
console.log('✓ 测试数据已插入');
|
||||
|
||||
// 验证表是否创建成功
|
||||
const [tables] = await connection.query('SHOW TABLES');
|
||||
console.log('\n创建的表:', tables);
|
||||
|
||||
await connection.end();
|
||||
|
||||
console.log('\n✅ 数据库初始化完成!');
|
||||
console.log('\n测试账号信息:');
|
||||
console.log('总经理: gm001 / 123456');
|
||||
console.log('技术部经理: mgr001 / 123456');
|
||||
console.log('销售部经理: mgr002 / 123456');
|
||||
console.log('技术部员工: emp001, emp002, emp003 / 123456');
|
||||
console.log('销售部员工: emp004, emp005 / 123456');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ 数据库初始化失败:', error.message);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
74
backend/init-db.ts
Normal file
74
backend/init-db.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function initDatabase() {
|
||||
try {
|
||||
console.log('开始初始化数据库...\n');
|
||||
|
||||
// 先连接到 MySQL(不指定数据库)
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
});
|
||||
|
||||
console.log('✓ 已连接到 MySQL 服务器');
|
||||
|
||||
// 创建数据库
|
||||
await connection.query(`CREATE DATABASE IF NOT EXISTS ${process.env.DB_NAME || 'employee_performance'} DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci`);
|
||||
console.log('✓ 数据库已创建');
|
||||
|
||||
// 切换到目标数据库
|
||||
await connection.query(`USE ${process.env.DB_NAME || 'employee_performance'}`);
|
||||
|
||||
// 读取并执行初始化脚本
|
||||
const initSql = fs.readFileSync(path.join(__dirname, 'src/db/init.sql'), 'utf-8');
|
||||
const statements = initSql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !s.startsWith('--') && !s.startsWith('CREATE DATABASE') && !s.startsWith('USE'));
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.trim()) {
|
||||
await connection.query(statement);
|
||||
}
|
||||
}
|
||||
console.log('✓ 表结构已创建');
|
||||
|
||||
// 读取并执行种子数据脚本
|
||||
const seedSql = fs.readFileSync(path.join(__dirname, 'src/db/seed.sql'), 'utf-8');
|
||||
const seedStatements = seedSql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !s.startsWith('--') && !s.startsWith('USE'));
|
||||
|
||||
for (const statement of seedStatements) {
|
||||
if (statement.trim()) {
|
||||
await connection.query(statement);
|
||||
}
|
||||
}
|
||||
console.log('✓ 测试数据已插入');
|
||||
|
||||
await connection.end();
|
||||
|
||||
console.log('\n✅ 数据库初始化完成!');
|
||||
console.log('\n测试账号信息:');
|
||||
console.log('总经理: gm001 / 123456');
|
||||
console.log('技术部经理: mgr001 / 123456');
|
||||
console.log('销售部经理: mgr002 / 123456');
|
||||
console.log('技术部员工: emp001, emp002, emp003 / 123456');
|
||||
console.log('销售部员工: emp004, emp005 / 123456');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ 数据库初始化失败:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
95
backend/insert-mock-ai-result.ts
Normal file
95
backend/insert-mock-ai-result.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function insertMockAIResult() {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
// 为每个已提交的绩效记录创建模拟AI评分
|
||||
const [perfRecords] = await conn.query(`
|
||||
SELECT perf_id FROM performance_month WHERE status = 'submitted'
|
||||
`);
|
||||
|
||||
for (const record of perfRecords as any[]) {
|
||||
const perfId = record.perf_id;
|
||||
|
||||
// 模拟AI评分详情
|
||||
const aiScoreDetail = [
|
||||
{ itemName: '工作目标完成情况', weight: 15, aiScore: 85, scoreExplanation: '工作目标基本完成,但部分细节需要改进' },
|
||||
{ itemName: '工作质量', weight: 10, aiScore: 88, scoreExplanation: '工作质量良好,符合标准' },
|
||||
{ itemName: '工作效率', weight: 10, aiScore: 82, scoreExplanation: '效率有待提升' },
|
||||
{ itemName: '业务能力', weight: 10, aiScore: 86, scoreExplanation: '业务能力扎实' },
|
||||
{ itemName: '创新能力', weight: 5, aiScore: 80, scoreExplanation: '有一定创新意识' },
|
||||
{ itemName: '问题解决能力', weight: 5, aiScore: 84, scoreExplanation: '能够独立解决问题' },
|
||||
{ itemName: '项目推进能力', weight: 5, aiScore: 83, scoreExplanation: '项目推进较为顺利' },
|
||||
{ itemName: '客户服务', weight: 5, aiScore: 87, scoreExplanation: '客户反馈良好' },
|
||||
{ itemName: '成本控制', weight: 5, aiScore: 85, scoreExplanation: '成本控制意识较强' },
|
||||
{ itemName: '团队协作', weight: 5, aiScore: 89, scoreExplanation: '团队协作能力突出' },
|
||||
{ itemName: '沟通能力', weight: 5, aiScore: 86, scoreExplanation: '沟通顺畅' },
|
||||
{ itemName: '学习成长', weight: 5, aiScore: 88, scoreExplanation: '学习态度积极' },
|
||||
{ itemName: '责任心', weight: 5, aiScore: 90, scoreExplanation: '责任心强' },
|
||||
{ itemName: '执行力', weight: 5, aiScore: 87, scoreExplanation: '执行力较好' },
|
||||
{ itemName: '职业素养', weight: 5, aiScore: 89, scoreExplanation: '职业素养良好' },
|
||||
{ itemName: '工作态度', weight: 5, aiScore: 91, scoreExplanation: '工作态度端正' },
|
||||
];
|
||||
|
||||
const aiTotalScore = 85.5;
|
||||
|
||||
const problems = [
|
||||
'工作效率有待提升,部分任务完成时间较长',
|
||||
'创新能力需要加强,建议多尝试新方法',
|
||||
'项目文档记录不够完善,需要改进'
|
||||
];
|
||||
|
||||
const suggestions = [
|
||||
'建议制定更详细的工作计划,提高时间管理能力',
|
||||
'多参加技术分享和培训,拓展知识面',
|
||||
'加强项目文档编写,形成良好的工作习惯',
|
||||
'主动承担更多挑战性任务,提升综合能力'
|
||||
];
|
||||
|
||||
// 插入AI评分结果
|
||||
await conn.query(`
|
||||
INSERT INTO ai_result (perf_id, ai_score_json, ai_total_score, problems, suggestions, api_response)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ai_score_json = VALUES(ai_score_json),
|
||||
ai_total_score = VALUES(ai_total_score),
|
||||
problems = VALUES(problems),
|
||||
suggestions = VALUES(suggestions)
|
||||
`, [
|
||||
perfId,
|
||||
JSON.stringify(aiScoreDetail),
|
||||
aiTotalScore,
|
||||
JSON.stringify(problems),
|
||||
JSON.stringify(suggestions),
|
||||
'模拟AI评分结果'
|
||||
]);
|
||||
|
||||
// 更新performance_month表的ai_score
|
||||
await conn.query(`
|
||||
UPDATE performance_month SET ai_score = ? WHERE perf_id = ?
|
||||
`, [aiTotalScore, perfId]);
|
||||
|
||||
console.log(`✓ 已为绩效记录 ${perfId} 创建模拟AI评分`);
|
||||
}
|
||||
|
||||
console.log('\n✅ 模拟AI评分数据插入完成!');
|
||||
|
||||
await conn.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
insertMockAIResult();
|
||||
7
backend/jest.config.js
Normal file
7
backend/jest.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/*.test.ts'],
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/index.ts'],
|
||||
};
|
||||
12
backend/list-accounts.ts
Normal file
12
backend/list-accounts.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
|
||||
async function run() {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
"SELECT username, name, role, department, position FROM user WHERE role IN ('manager','generalManager') ORDER BY role"
|
||||
);
|
||||
console.table(rows);
|
||||
await pool.end();
|
||||
}
|
||||
run().catch(console.error);
|
||||
6469
backend/package-lock.json
generated
Normal file
6469
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
backend/package.json
Normal file
39
backend/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "employee-performance-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "员工月度绩效考核系统后端",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"db:seed": "ts-node src/db/seed.ts",
|
||||
"db:test": "ts-node test-db.ts",
|
||||
"db:init": "ts-node init-db.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node": "^20.10.0",
|
||||
"fast-check": "^3.15.0",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
}
|
||||
18
backend/src/config/database.ts
Normal file
18
backend/src/config/database.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
timezone: '+08:00',
|
||||
});
|
||||
|
||||
export default pool;
|
||||
2
backend/src/config/jwt.ts
Normal file
2
backend/src/config/jwt.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const JWT_SECRET = process.env.JWT_SECRET || 'change_this_secret_in_production';
|
||||
export const JWT_EXPIRES_IN = '24h';
|
||||
93
backend/src/dao/AIResultDAO.ts
Normal file
93
backend/src/dao/AIResultDAO.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import pool from '../config/database';
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
|
||||
export interface AIScoreItem {
|
||||
itemName: string;
|
||||
weight: number;
|
||||
aiScore: number;
|
||||
scoreExplanation: string;
|
||||
}
|
||||
|
||||
export interface AIResultRow {
|
||||
ai_id: number;
|
||||
perf_id: number;
|
||||
ai_score_json: string;
|
||||
ai_total_score: number;
|
||||
problems: string | null;
|
||||
suggestions: string | null;
|
||||
api_response: string | null;
|
||||
create_time: Date;
|
||||
}
|
||||
|
||||
export interface SaveAIResultData {
|
||||
perfId: number;
|
||||
aiScoreDetail: AIScoreItem[];
|
||||
aiTotalScore: number;
|
||||
aiProblems: string[];
|
||||
aiSuggestions: string[];
|
||||
apiResponse?: string;
|
||||
}
|
||||
|
||||
export interface AIResult {
|
||||
aiId: number;
|
||||
perfId: number;
|
||||
aiScoreDetail: AIScoreItem[];
|
||||
aiTotalScore: number;
|
||||
aiProblems: string[];
|
||||
aiSuggestions: string[];
|
||||
createTime: Date;
|
||||
}
|
||||
|
||||
/** Save AI evaluation result. Replaces any existing result for the same perf_id. */
|
||||
export async function save(data: SaveAIResultData): Promise<number> {
|
||||
const [result] = await pool.query<ResultSetHeader>(
|
||||
`INSERT INTO ai_result (perf_id, ai_score_json, ai_total_score, problems, suggestions, api_response)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ai_score_json = VALUES(ai_score_json),
|
||||
ai_total_score = VALUES(ai_total_score),
|
||||
problems = VALUES(problems),
|
||||
suggestions = VALUES(suggestions),
|
||||
api_response = VALUES(api_response)`,
|
||||
[
|
||||
data.perfId,
|
||||
JSON.stringify(data.aiScoreDetail),
|
||||
data.aiTotalScore,
|
||||
JSON.stringify(data.aiProblems),
|
||||
JSON.stringify(data.aiSuggestions),
|
||||
data.apiResponse ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.insertId !== 0) {
|
||||
return result.insertId;
|
||||
}
|
||||
|
||||
// On UPDATE, fetch the existing ai_id
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT ai_id FROM ai_result WHERE perf_id = ? LIMIT 1',
|
||||
[data.perfId]
|
||||
);
|
||||
return rows[0].ai_id;
|
||||
}
|
||||
|
||||
/** Retrieve the AI result for a given performance record. */
|
||||
export async function findByPerfId(perfId: number): Promise<AIResult | null> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT * FROM ai_result WHERE perf_id = ? LIMIT 1',
|
||||
[perfId]
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row: AIResultRow = rows[0];
|
||||
return {
|
||||
aiId: row.ai_id,
|
||||
perfId: row.perf_id,
|
||||
aiScoreDetail: JSON.parse(row.ai_score_json) as AIScoreItem[],
|
||||
aiTotalScore: Number(row.ai_total_score),
|
||||
aiProblems: row.problems ? (JSON.parse(row.problems) as string[]) : [],
|
||||
aiSuggestions: row.suggestions ? (JSON.parse(row.suggestions) as string[]) : [],
|
||||
createTime: row.create_time,
|
||||
};
|
||||
}
|
||||
53
backend/src/dao/ConfigDAO.ts
Normal file
53
backend/src/dao/ConfigDAO.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import pool from '../config/database';
|
||||
|
||||
export interface RuleRow {
|
||||
rule_id: number;
|
||||
rule_key: string;
|
||||
rule_value: string; // JSON string
|
||||
description: string | null;
|
||||
effective_cycle: string | null;
|
||||
updated_by: number | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** Fetch all rules */
|
||||
export async function findAllRules(): Promise<RuleRow[]> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT rule_id, rule_key, rule_value, description, effective_cycle, updated_by, created_at, updated_at FROM performance_rules ORDER BY rule_key'
|
||||
);
|
||||
return rows as RuleRow[];
|
||||
}
|
||||
|
||||
/** Fetch a single rule by key */
|
||||
export async function findRuleByKey(ruleKey: string): Promise<RuleRow | null> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT rule_id, rule_key, rule_value, description, effective_cycle, updated_by, created_at, updated_at FROM performance_rules WHERE rule_key = ? LIMIT 1',
|
||||
[ruleKey]
|
||||
);
|
||||
return rows.length > 0 ? (rows[0] as RuleRow) : null;
|
||||
}
|
||||
|
||||
export interface UpsertRuleData {
|
||||
ruleKey: string;
|
||||
ruleValue: unknown; // will be JSON-serialised
|
||||
description?: string;
|
||||
effectiveCycle?: string;
|
||||
updatedBy: number;
|
||||
}
|
||||
|
||||
/** Insert or update a rule (upsert by rule_key) */
|
||||
export async function upsertRule(data: UpsertRuleData): Promise<void> {
|
||||
const valueJson = JSON.stringify(data.ruleValue);
|
||||
await pool.query(
|
||||
`INSERT INTO performance_rules (rule_key, rule_value, description, effective_cycle, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
rule_value = VALUES(rule_value),
|
||||
description = VALUES(description),
|
||||
effective_cycle = VALUES(effective_cycle),
|
||||
updated_by = VALUES(updated_by),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[data.ruleKey, valueJson, data.description ?? null, data.effectiveCycle ?? null, data.updatedBy]
|
||||
);
|
||||
}
|
||||
319
backend/src/dao/PerformanceDAO.ts
Normal file
319
backend/src/dao/PerformanceDAO.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import pool from '../config/database';
|
||||
import { ResultSetHeader } from 'mysql2';
|
||||
|
||||
export type PerformanceStatus = 'draft' | 'submitted' | 'under_review' | 'completed' | 'rejected';
|
||||
export type PerformanceLevel = 'excellent' | 'qualified' | 'need_motivation' | 'unqualified';
|
||||
|
||||
export interface PerformanceRow {
|
||||
perf_id: number;
|
||||
user_id: number;
|
||||
month: string;
|
||||
status: PerformanceStatus;
|
||||
self_score: number | null;
|
||||
ai_score: number | null;
|
||||
manager_score: number | null;
|
||||
total_score: number | null;
|
||||
level: PerformanceLevel | null;
|
||||
reward_punish: string | null;
|
||||
work_summary: string | null;
|
||||
submit_time: Date | null;
|
||||
review_time: Date | null;
|
||||
review_opinion: string | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface PerfItemRow {
|
||||
item_id?: number;
|
||||
perf_id: number;
|
||||
item_name: string;
|
||||
item_category: 'business' | 'comprehensive';
|
||||
weight: number;
|
||||
user_content: string | null;
|
||||
self_score: number | null;
|
||||
ai_score: number | null;
|
||||
ai_explanation: string | null;
|
||||
manager_score: number | null;
|
||||
manager_explanation: string | null;
|
||||
evidence_url: string | null;
|
||||
}
|
||||
|
||||
export interface AttendanceRow {
|
||||
attendance_id?: number;
|
||||
perf_id: number;
|
||||
leave_days: number;
|
||||
late_times: number;
|
||||
absent_days: number;
|
||||
lack_card_times: number;
|
||||
attendance_score: number | null;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface UpsertPerformanceData {
|
||||
userId: number;
|
||||
month: string;
|
||||
status: PerformanceStatus;
|
||||
selfScore?: number;
|
||||
workSummary?: string;
|
||||
items?: Omit<PerfItemRow, 'perf_id' | 'item_id'>[];
|
||||
attendance?: Omit<AttendanceRow, 'perf_id' | 'attendance_id'>;
|
||||
}
|
||||
|
||||
export interface PerformanceFilter {
|
||||
month?: string;
|
||||
department?: string;
|
||||
employeeName?: string;
|
||||
status?: PerformanceStatus;
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ReviewData {
|
||||
managerScore: number;
|
||||
reviewOpinion: string;
|
||||
totalScore: number;
|
||||
level: PerformanceLevel;
|
||||
rewardPunish: string;
|
||||
itemScores: { itemName: string; managerScore: number; managerExplanation: string }[];
|
||||
}
|
||||
|
||||
export interface PerformanceListResult {
|
||||
total: number;
|
||||
records: PerformanceRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a performance record along with its items and attendance.
|
||||
* Uses INSERT ... ON DUPLICATE KEY UPDATE to handle the unique constraint on (user_id, month).
|
||||
*/
|
||||
export async function upsert(data: UpsertPerformanceData): Promise<number> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// Upsert performance_month
|
||||
const submitTime = data.status === 'submitted' ? new Date() : null;
|
||||
const [result] = await conn.query<ResultSetHeader>(
|
||||
`INSERT INTO performance_month (user_id, month, status, self_score, work_summary, submit_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
self_score = VALUES(self_score),
|
||||
work_summary = VALUES(work_summary),
|
||||
submit_time = COALESCE(VALUES(submit_time), submit_time)`,
|
||||
[data.userId, data.month, data.status, data.selfScore ?? null, data.workSummary ?? null, submitTime]
|
||||
);
|
||||
|
||||
// Resolve perf_id (insertId is 0 on UPDATE, so fetch it)
|
||||
let perfId: number = result.insertId;
|
||||
if (perfId === 0) {
|
||||
const [rows] = await conn.query<any[]>(
|
||||
'SELECT perf_id FROM performance_month WHERE user_id = ? AND month = ?',
|
||||
[data.userId, data.month]
|
||||
);
|
||||
perfId = rows[0].perf_id;
|
||||
}
|
||||
|
||||
// Upsert perf_items
|
||||
if (data.items && data.items.length > 0) {
|
||||
// Delete existing items and re-insert for simplicity
|
||||
await conn.query('DELETE FROM perf_item WHERE perf_id = ?', [perfId]);
|
||||
for (const item of data.items) {
|
||||
await conn.query(
|
||||
`INSERT INTO perf_item (perf_id, item_name, item_category, weight, user_content, self_score, evidence_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[perfId, item.item_name, item.item_category, item.weight, item.user_content ?? null, item.self_score ?? null, item.evidence_url ?? null]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert attendance
|
||||
if (data.attendance) {
|
||||
const att = data.attendance;
|
||||
await conn.query(
|
||||
`INSERT INTO attendance (perf_id, leave_days, late_times, absent_days, lack_card_times, attendance_score, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
leave_days = VALUES(leave_days),
|
||||
late_times = VALUES(late_times),
|
||||
absent_days = VALUES(absent_days),
|
||||
lack_card_times = VALUES(lack_card_times),
|
||||
attendance_score = VALUES(attendance_score),
|
||||
remark = VALUES(remark)`,
|
||||
[perfId, att.leave_days, att.late_times, att.absent_days, att.lack_card_times, att.attendance_score ?? null, att.remark ?? null]
|
||||
);
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
return perfId;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Query a performance record for a specific user and month, including items and attendance. */
|
||||
export async function findByUserAndMonth(userId: number, month: string): Promise<PerformanceRow | null> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT * FROM performance_month WHERE user_id = ? AND month = ? LIMIT 1',
|
||||
[userId, month]
|
||||
);
|
||||
return rows.length > 0 ? (rows[0] as PerformanceRow) : null;
|
||||
}
|
||||
|
||||
/** Query subordinates' performance records with optional filters and pagination. */
|
||||
export async function findByManagerId(
|
||||
managerId: number,
|
||||
filters: PerformanceFilter,
|
||||
page: PageInfo
|
||||
): Promise<PerformanceListResult> {
|
||||
const conditions: string[] = ['u.manager_id = ?'];
|
||||
const params: any[] = [managerId];
|
||||
|
||||
if (filters.month) {
|
||||
conditions.push('pm.month = ?');
|
||||
params.push(filters.month);
|
||||
}
|
||||
if (filters.department) {
|
||||
conditions.push('u.department = ?');
|
||||
params.push(filters.department);
|
||||
}
|
||||
if (filters.employeeName) {
|
||||
conditions.push('u.name LIKE ?');
|
||||
params.push(`%${filters.employeeName}%`);
|
||||
}
|
||||
if (filters.status) {
|
||||
conditions.push('pm.status = ?');
|
||||
params.push(filters.status);
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
const offset = (page.page - 1) * page.pageSize;
|
||||
|
||||
const [countRows] = await pool.query<any[]>(
|
||||
`SELECT COUNT(*) AS total
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total: number = countRows[0].total;
|
||||
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.*
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE ${where}
|
||||
ORDER BY pm.month DESC, pm.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, page.pageSize, offset]
|
||||
);
|
||||
|
||||
return { total, records: rows as PerformanceRow[] };
|
||||
}
|
||||
|
||||
/** Update only the status of a performance record. */
|
||||
export async function updateStatus(perfId: number, status: PerformanceStatus): Promise<void> {
|
||||
await pool.query(
|
||||
'UPDATE performance_month SET status = ? WHERE perf_id = ?',
|
||||
[status, perfId]
|
||||
);
|
||||
}
|
||||
|
||||
/** Query performance records for a specific employee with optional month filter and pagination. */
|
||||
export async function findByUserId(
|
||||
userId: number,
|
||||
month: string | undefined,
|
||||
page: PageInfo
|
||||
): Promise<PerformanceListResult> {
|
||||
const conditions: string[] = ['pm.user_id = ?'];
|
||||
const params: any[] = [userId];
|
||||
|
||||
if (month) {
|
||||
conditions.push('pm.month = ?');
|
||||
params.push(month);
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
const offset = (page.page - 1) * page.pageSize;
|
||||
|
||||
const [countRows] = await pool.query<any[]>(
|
||||
`SELECT COUNT(*) AS total FROM performance_month pm WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total: number = countRows[0].total;
|
||||
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.* FROM performance_month pm WHERE ${where}
|
||||
ORDER BY pm.month DESC, pm.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, page.pageSize, offset]
|
||||
);
|
||||
|
||||
return { total, records: rows as PerformanceRow[] };
|
||||
}
|
||||
|
||||
/** Query full detail of a performance record including items and attendance. */
|
||||
export async function findDetailByPerfId(perfId: number): Promise<{
|
||||
performance: PerformanceRow;
|
||||
items: PerfItemRow[];
|
||||
attendance: AttendanceRow | null;
|
||||
} | null> {
|
||||
const [perfRows] = await pool.query<any[]>(
|
||||
'SELECT * FROM performance_month WHERE perf_id = ? LIMIT 1',
|
||||
[perfId]
|
||||
);
|
||||
if (perfRows.length === 0) return null;
|
||||
|
||||
const [itemRows] = await pool.query<any[]>(
|
||||
'SELECT * FROM perf_item WHERE perf_id = ?',
|
||||
[perfId]
|
||||
);
|
||||
|
||||
const [attRows] = await pool.query<any[]>(
|
||||
'SELECT * FROM attendance WHERE perf_id = ? LIMIT 1',
|
||||
[perfId]
|
||||
);
|
||||
|
||||
return {
|
||||
performance: perfRows[0] as PerformanceRow,
|
||||
items: itemRows as PerfItemRow[],
|
||||
attendance: attRows.length > 0 ? (attRows[0] as AttendanceRow) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Update manager review results and archive the performance record. */
|
||||
export async function updateReview(perfId: number, reviewData: ReviewData): Promise<void> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
await conn.query(
|
||||
`UPDATE performance_month
|
||||
SET manager_score = ?, review_opinion = ?, total_score = ?, level = ?,
|
||||
reward_punish = ?, status = 'completed', review_time = NOW()
|
||||
WHERE perf_id = ?`,
|
||||
[reviewData.managerScore, reviewData.reviewOpinion, reviewData.totalScore, reviewData.level, reviewData.rewardPunish, perfId]
|
||||
);
|
||||
|
||||
for (const item of reviewData.itemScores) {
|
||||
await conn.query(
|
||||
`UPDATE perf_item SET manager_score = ?, manager_explanation = ?
|
||||
WHERE perf_id = ? AND item_name = ?`,
|
||||
[item.managerScore, item.managerExplanation, perfId, item.itemName]
|
||||
);
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
30
backend/src/dao/UserDAO.ts
Normal file
30
backend/src/dao/UserDAO.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import pool from '../config/database';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
export interface UserRow {
|
||||
user_id: number;
|
||||
username: string;
|
||||
password: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
department: string;
|
||||
position: string;
|
||||
manager_id: number | null;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export async function findByUsername(username: string): Promise<UserRow | null> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT user_id, username, password, name, role, department, position, manager_id, status FROM user WHERE username = ? LIMIT 1',
|
||||
[username]
|
||||
);
|
||||
return rows.length > 0 ? (rows[0] as UserRow) : null;
|
||||
}
|
||||
|
||||
export async function findSubordinates(managerId: number): Promise<UserRow[]> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT user_id, username, name, role, department, position, manager_id, status FROM user WHERE manager_id = ? AND status = ?',
|
||||
[managerId, 'active']
|
||||
);
|
||||
return rows as UserRow[];
|
||||
}
|
||||
127
backend/src/db/init.sql
Normal file
127
backend/src/db/init.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- 员工月度绩效考核系统数据库初始化脚本
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS employee_performance
|
||||
DEFAULT CHARACTER SET utf8mb4
|
||||
DEFAULT COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE employee_performance;
|
||||
|
||||
-- 1. 用户表
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
user_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
|
||||
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(工号)',
|
||||
password VARCHAR(255) NOT NULL COMMENT '密码(bcrypt加密)',
|
||||
name VARCHAR(50) NOT NULL COMMENT '姓名',
|
||||
role ENUM('employee', 'manager', 'generalManager') NOT NULL COMMENT '角色',
|
||||
department VARCHAR(50) NOT NULL COMMENT '部门',
|
||||
position VARCHAR(50) NOT NULL COMMENT '岗位',
|
||||
manager_id INT NULL COMMENT '直属管理层ID',
|
||||
status ENUM('active', 'inactive') DEFAULT 'active' COMMENT '状态',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_role (role),
|
||||
INDEX idx_manager (manager_id),
|
||||
FOREIGN KEY (manager_id) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||
|
||||
-- 2. 绩效主表
|
||||
CREATE TABLE IF NOT EXISTS performance_month (
|
||||
perf_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '绩效记录ID',
|
||||
user_id INT NOT NULL COMMENT '员工ID',
|
||||
month VARCHAR(7) NOT NULL COMMENT '考核月份(YYYY-MM)',
|
||||
status ENUM('draft', 'submitted', 'under_review', 'completed', 'rejected') NOT NULL DEFAULT 'draft' COMMENT '状态',
|
||||
self_score DECIMAL(5,2) NULL COMMENT '员工自评总分',
|
||||
ai_score DECIMAL(5,2) NULL COMMENT 'AI评分总分',
|
||||
manager_score DECIMAL(5,2) NULL COMMENT '管理层审核总分',
|
||||
total_score DECIMAL(5,2) NULL COMMENT '最终总分',
|
||||
level ENUM('excellent', 'qualified', 'need_motivation', 'unqualified') NULL COMMENT '绩效等级',
|
||||
reward_punish VARCHAR(255) NULL COMMENT '奖惩说明',
|
||||
work_summary TEXT NULL COMMENT '工作汇总',
|
||||
submit_time TIMESTAMP NULL COMMENT '提交时间',
|
||||
review_time TIMESTAMP NULL COMMENT '审核时间',
|
||||
review_opinion TEXT NULL COMMENT '审核意见',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_month (user_id, month),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_month (month),
|
||||
FOREIGN KEY (user_id) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='绩效主表';
|
||||
|
||||
-- 3. 绩效项明细表
|
||||
CREATE TABLE IF NOT EXISTS perf_item (
|
||||
item_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '绩效项ID',
|
||||
perf_id INT NOT NULL COMMENT '绩效记录ID',
|
||||
item_name VARCHAR(100) NOT NULL COMMENT '考核项名称',
|
||||
item_category ENUM('business', 'comprehensive') NOT NULL COMMENT '考核项类别',
|
||||
weight INT NOT NULL COMMENT '权重(分数)',
|
||||
user_content TEXT NULL COMMENT '员工填写内容',
|
||||
self_score DECIMAL(5,2) NULL COMMENT '员工自评分',
|
||||
ai_score DECIMAL(5,2) NULL COMMENT 'AI评分',
|
||||
ai_explanation TEXT NULL COMMENT 'AI评分说明',
|
||||
manager_score DECIMAL(5,2) NULL COMMENT '管理层评分',
|
||||
manager_explanation TEXT NULL COMMENT '管理层评分说明',
|
||||
evidence_url VARCHAR(500) NULL COMMENT '佐证材料URL',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_perf (perf_id),
|
||||
FOREIGN KEY (perf_id) REFERENCES performance_month(perf_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='绩效项明细表';
|
||||
|
||||
-- 4. 考勤表
|
||||
CREATE TABLE IF NOT EXISTS attendance (
|
||||
attendance_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '考勤ID',
|
||||
perf_id INT NOT NULL COMMENT '绩效记录ID',
|
||||
leave_days INT DEFAULT 0 COMMENT '事假天数',
|
||||
late_times INT DEFAULT 0 COMMENT '迟到次数',
|
||||
absent_days INT DEFAULT 0 COMMENT '旷工天数',
|
||||
lack_card_times INT DEFAULT 0 COMMENT '缺卡次数',
|
||||
attendance_score DECIMAL(5,2) NULL COMMENT '考勤得分',
|
||||
remark TEXT NULL COMMENT '备注',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_perf (perf_id),
|
||||
FOREIGN KEY (perf_id) REFERENCES performance_month(perf_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤表';
|
||||
|
||||
-- 5. AI 结果表
|
||||
CREATE TABLE IF NOT EXISTS ai_result (
|
||||
ai_id INT PRIMARY KEY AUTO_INCREMENT COMMENT 'AI结果ID',
|
||||
perf_id INT NOT NULL COMMENT '绩效记录ID',
|
||||
ai_score_json TEXT NOT NULL COMMENT 'AI评分详情(JSON格式)',
|
||||
ai_total_score DECIMAL(5,2) NOT NULL COMMENT 'AI总分',
|
||||
problems TEXT NULL COMMENT 'AI总结的问题(JSON数组)',
|
||||
suggestions TEXT NULL COMMENT 'AI改进建议(JSON数组)',
|
||||
api_response TEXT NULL COMMENT 'FastGPT原始响应',
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '生成时间',
|
||||
UNIQUE KEY uk_perf (perf_id),
|
||||
FOREIGN KEY (perf_id) REFERENCES performance_month(perf_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI结果表';
|
||||
|
||||
-- 6. 考核规则配置表
|
||||
CREATE TABLE IF NOT EXISTS performance_rules (
|
||||
rule_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '规则ID',
|
||||
rule_key VARCHAR(100) NOT NULL UNIQUE COMMENT '规则键名',
|
||||
rule_value TEXT NOT NULL COMMENT '规则值(JSON格式)',
|
||||
description VARCHAR(255) NULL COMMENT '规则描述',
|
||||
effective_cycle VARCHAR(7) NULL COMMENT '生效周期(YYYY-MM,NULL表示立即生效)',
|
||||
updated_by INT NULL COMMENT '最后修改人ID',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (updated_by) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考核规则配置表';
|
||||
|
||||
-- 7. 操作日志表
|
||||
CREATE TABLE IF NOT EXISTS operation_log (
|
||||
log_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '日志ID',
|
||||
user_id INT NOT NULL COMMENT '操作人ID',
|
||||
operation_type VARCHAR(50) NOT NULL COMMENT '操作类型',
|
||||
target_type VARCHAR(50) NULL COMMENT '目标类型',
|
||||
target_id INT NULL COMMENT '目标ID',
|
||||
operation_detail TEXT NULL COMMENT '操作详情',
|
||||
ip_address VARCHAR(50) NULL COMMENT 'IP地址',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_created (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES user(user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
|
||||
43
backend/src/db/seed.sql
Normal file
43
backend/src/db/seed.sql
Normal file
@@ -0,0 +1,43 @@
|
||||
-- 测试数据插入脚本
|
||||
USE employee_performance;
|
||||
|
||||
-- 插入测试用户(密码都是 123456,已用 bcrypt 加密)
|
||||
-- bcrypt hash for '123456': $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
|
||||
|
||||
-- 1. 总经理
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES ('gm001', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '张总', 'generalManager', '管理层', '总经理', NULL, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 2. 部门经理(技术部)
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES ('mgr001', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '李经理', 'manager', '技术部', '技术经理', 1, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 3. 部门经理(销售部)
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES ('mgr002', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '王经理', 'manager', '销售部', '销售经理', 1, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 4. 员工(技术部)
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES
|
||||
('emp001', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '张三', 'employee', '技术部', '前端工程师', 2, 'active'),
|
||||
('emp002', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '李四', 'employee', '技术部', '后端工程师', 2, 'active'),
|
||||
('emp003', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '王五', 'employee', '技术部', '测试工程师', 2, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 5. 员工(销售部)
|
||||
INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES
|
||||
('emp004', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '赵六', 'employee', '销售部', '销售专员', 3, 'active'),
|
||||
('emp005', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy', '孙七', 'employee', '销售部', '销售专员', 3, 'active')
|
||||
ON DUPLICATE KEY UPDATE username=username;
|
||||
|
||||
-- 插入默认考核规则配置
|
||||
INSERT INTO performance_rules (rule_key, rule_value, description)
|
||||
VALUES
|
||||
('item_weights', '{"business": 70, "comprehensive": 30}', '考核项权重配置'),
|
||||
('reward_punish', '{"excellent": "按公司规定给予奖励", "qualified_80_89": "扣除当月绩效工资100元", "qualified_70_79": "扣除当月绩效工资200元", "need_motivation": "扣除当月绩效工资300元", "unqualified": "扣除当月绩效工资600元"}', '奖惩金额配置')
|
||||
ON DUPLICATE KEY UPDATE rule_key=rule_key;
|
||||
|
||||
40
backend/src/db/seed.ts
Normal file
40
backend/src/db/seed.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import pool from '../config/database';
|
||||
|
||||
async function runSeed() {
|
||||
try {
|
||||
console.log('开始执行数据库种子数据插入...');
|
||||
|
||||
const sqlFile = path.join(__dirname, 'seed.sql');
|
||||
const sql = fs.readFileSync(sqlFile, 'utf-8');
|
||||
|
||||
// 分割SQL语句(按分号分割,但要注意处理注释)
|
||||
const statements = sql
|
||||
.split(';')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !s.startsWith('--'));
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.trim()) {
|
||||
await pool.query(statement);
|
||||
console.log('✓ 执行成功:', statement.substring(0, 50) + '...');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ 数据库种子数据插入完成!');
|
||||
console.log('\n测试账号信息:');
|
||||
console.log('总经理: gm001 / 123456');
|
||||
console.log('技术部经理: mgr001 / 123456');
|
||||
console.log('销售部经理: mgr002 / 123456');
|
||||
console.log('技术部员工: emp001, emp002, emp003 / 123456');
|
||||
console.log('销售部员工: emp004, emp005 / 123456');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ 数据库种子数据插入失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runSeed();
|
||||
32
backend/src/index.ts
Normal file
32
backend/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import authRouter from './routes/auth';
|
||||
import performanceRouter from './routes/performance';
|
||||
import statisticsRouter from './routes/statistics';
|
||||
import configRouter from './routes/config';
|
||||
import employeeRouter from './routes/employee';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.use('/api/user', authRouter);
|
||||
app.use('/api/performance', performanceRouter);
|
||||
app.use('/api/statistics', statisticsRouter);
|
||||
app.use('/api/config', configRouter);
|
||||
app.use('/api/employee', employeeRouter);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
77
backend/src/middlewares/__tests__/authenticate.test.ts
Normal file
77
backend/src/middlewares/__tests__/authenticate.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticate } from '../authenticate';
|
||||
import { JWT_SECRET } from '../../config/jwt';
|
||||
import { UserInfo } from '../../types';
|
||||
|
||||
const mockUser: UserInfo = {
|
||||
userId: 1,
|
||||
name: '张三',
|
||||
role: 'employee',
|
||||
department: '研发部',
|
||||
position: '工程师',
|
||||
};
|
||||
|
||||
function makeReq(authHeader?: string): Partial<Request> {
|
||||
return { headers: authHeader ? { authorization: authHeader } : {} } as any;
|
||||
}
|
||||
|
||||
function makeRes(): { res: Partial<Response>; getStatus: () => number; getBody: () => any } {
|
||||
let statusCode = 200;
|
||||
let body: any = null;
|
||||
const res: Partial<Response> = {
|
||||
status(code: number) { statusCode = code; return this as Response; },
|
||||
json(data: any) { body = data; return this as Response; },
|
||||
};
|
||||
return { res, getStatus: () => statusCode, getBody: () => body };
|
||||
}
|
||||
|
||||
describe('authenticate middleware', () => {
|
||||
it('valid token sets req.user and calls next', () => {
|
||||
const token = jwt.sign(mockUser, JWT_SECRET, { expiresIn: '1h' });
|
||||
const req = makeReq(`Bearer ${token}`) as Request;
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authenticate(req, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user?.userId).toBe(1);
|
||||
expect(getStatus()).toBe(200);
|
||||
});
|
||||
|
||||
it('missing token returns 401', () => {
|
||||
const req = makeReq() as Request;
|
||||
const { res, getStatus, getBody } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authenticate(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(401);
|
||||
expect(getBody().code).toBe(401);
|
||||
});
|
||||
|
||||
it('invalid token returns 401', () => {
|
||||
const req = makeReq('Bearer invalidtoken') as Request;
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authenticate(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(401);
|
||||
});
|
||||
|
||||
it('expired token returns 401', () => {
|
||||
const token = jwt.sign(mockUser, JWT_SECRET, { expiresIn: '-1s' });
|
||||
const req = makeReq(`Bearer ${token}`) as Request;
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authenticate(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(401);
|
||||
});
|
||||
});
|
||||
158
backend/src/middlewares/__tests__/authorize.property.test.ts
Normal file
158
backend/src/middlewares/__tests__/authorize.property.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as fc from 'fast-check';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { authenticate } from '../authenticate';
|
||||
import { authorize } from '../authorize';
|
||||
import { JWT_SECRET } from '../../config/jwt';
|
||||
import { UserInfo, UserRole } from '../../types';
|
||||
|
||||
// Feature: employee-performance-system, Property 2: 权限隔离不变量
|
||||
// For any employee token, accessing another employee's data always returns 403.
|
||||
|
||||
const ROLES: UserRole[] = ['employee', 'manager', 'generalManager'];
|
||||
|
||||
function makeRes(): { res: Partial<Response>; getStatus: () => number; getBody: () => any } {
|
||||
let statusCode = 200;
|
||||
let body: any = null;
|
||||
const res: Partial<Response> = {
|
||||
status(code: number) { statusCode = code; return this as Response; },
|
||||
json(data: any) { body = data; return this as Response; },
|
||||
};
|
||||
return { res, getStatus: () => statusCode, getBody: () => body };
|
||||
}
|
||||
|
||||
function makeUserInfo(overrides: Partial<UserInfo> = {}): UserInfo {
|
||||
return {
|
||||
userId: 1,
|
||||
name: '测试用户',
|
||||
role: 'employee',
|
||||
department: '研发部',
|
||||
position: '工程师',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Property 2: 权限隔离不变量', () => {
|
||||
it('employee token is always rejected when accessing manager-only routes', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
department: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
position: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
}),
|
||||
({ userId, name, department, position }) => {
|
||||
const employeeInfo = makeUserInfo({ userId, name, department, position, role: 'employee' });
|
||||
const token = jwt.sign(employeeInfo, JWT_SECRET, { expiresIn: '1h' });
|
||||
|
||||
const req = { headers: { authorization: `Bearer ${token}` } } as Request;
|
||||
const { res: authRes } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
// First pass through authenticate
|
||||
authenticate(req, authRes as Response, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user?.role).toBe('employee');
|
||||
|
||||
// Then attempt to access manager-only route
|
||||
const { res: authzRes, getStatus, getBody } = makeRes();
|
||||
const authzNext = jest.fn() as NextFunction;
|
||||
authorize('manager', 'generalManager')(req, authzRes as Response, authzNext);
|
||||
|
||||
expect(authzNext).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(403);
|
||||
expect(getBody().code).toBe(403);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('employee token is always rejected when accessing generalManager-only routes', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
}),
|
||||
({ userId }) => {
|
||||
const employeeInfo = makeUserInfo({ userId, role: 'employee' });
|
||||
const token = jwt.sign(employeeInfo, JWT_SECRET, { expiresIn: '1h' });
|
||||
|
||||
const req = { headers: { authorization: `Bearer ${token}` } } as Request;
|
||||
const { res: authRes } = makeRes();
|
||||
const authNext = jest.fn() as NextFunction;
|
||||
authenticate(req, authRes as Response, authNext);
|
||||
|
||||
const { res, getStatus, getBody } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
authorize('generalManager')(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(403);
|
||||
expect(getBody().code).toBe(403);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a role is always allowed access to its own permitted routes and denied from others', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
targetRole: fc.constantFrom<UserRole>(...ROLES),
|
||||
}).filter(({ role, targetRole }) => role !== targetRole),
|
||||
({ userId, role, targetRole }) => {
|
||||
const userInfo = makeUserInfo({ userId, role });
|
||||
const token = jwt.sign(userInfo, JWT_SECRET, { expiresIn: '1h' });
|
||||
|
||||
const req = { headers: { authorization: `Bearer ${token}` } } as Request;
|
||||
const { res: authRes } = makeRes();
|
||||
const authNext = jest.fn() as NextFunction;
|
||||
authenticate(req, authRes as Response, authNext);
|
||||
|
||||
// Accessing a route that only allows targetRole (which differs from user's role)
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
authorize(targetRole)(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(403);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('any role is always allowed when its role is in the permitted list', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
}),
|
||||
({ userId, role }) => {
|
||||
const userInfo = makeUserInfo({ userId, role });
|
||||
const token = jwt.sign(userInfo, JWT_SECRET, { expiresIn: '1h' });
|
||||
|
||||
const req = { headers: { authorization: `Bearer ${token}` } } as Request;
|
||||
const { res: authRes } = makeRes();
|
||||
const authNext = jest.fn() as NextFunction;
|
||||
authenticate(req, authRes as Response, authNext);
|
||||
|
||||
// Route allows all roles
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
authorize(...ROLES)(req, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(200); // unchanged — no error set
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
65
backend/src/middlewares/__tests__/authorize.test.ts
Normal file
65
backend/src/middlewares/__tests__/authorize.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { authorize } from '../authorize';
|
||||
import { UserInfo } from '../../types';
|
||||
|
||||
function makeReq(user?: UserInfo): Partial<Request> {
|
||||
return { user } as any;
|
||||
}
|
||||
|
||||
function makeRes(): { res: Partial<Response>; getStatus: () => number; getBody: () => any } {
|
||||
let statusCode = 200;
|
||||
let body: any = null;
|
||||
const res: Partial<Response> = {
|
||||
status(code: number) { statusCode = code; return this as Response; },
|
||||
json(data: any) { body = data; return this as Response; },
|
||||
};
|
||||
return { res, getStatus: () => statusCode, getBody: () => body };
|
||||
}
|
||||
|
||||
const employee: UserInfo = { userId: 1, name: '张三', role: 'employee', department: '研发部', position: '工程师' };
|
||||
const manager: UserInfo = { userId: 2, name: '李四', role: 'manager', department: '研发部', position: '经理' };
|
||||
|
||||
describe('authorize middleware', () => {
|
||||
it('allowed role calls next', () => {
|
||||
const req = makeReq(employee) as Request;
|
||||
const { res } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authorize('employee')(req, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disallowed role returns 403', () => {
|
||||
const req = makeReq(employee) as Request;
|
||||
const { res, getStatus, getBody } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authorize('manager')(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(403);
|
||||
expect(getBody().code).toBe(403);
|
||||
});
|
||||
|
||||
it('multiple allowed roles — matching role calls next', () => {
|
||||
const req = makeReq(manager) as Request;
|
||||
const { res } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authorize('manager', 'generalManager')(req, res as Response, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('no user on request returns 401', () => {
|
||||
const req = makeReq(undefined) as Request;
|
||||
const { res, getStatus } = makeRes();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
authorize('employee')(req, res as Response, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(getStatus()).toBe(401);
|
||||
});
|
||||
});
|
||||
24
backend/src/middlewares/authenticate.ts
Normal file
24
backend/src/middlewares/authenticate.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from '../config/jwt';
|
||||
import { UserInfo } from '../types';
|
||||
|
||||
export function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.startsWith('Bearer ')
|
||||
? authHeader.slice(7)
|
||||
: null;
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ code: 401, message: '未提供访问令牌' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as UserInfo;
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ code: 401, message: '访问令牌无效或已过期' });
|
||||
}
|
||||
}
|
||||
20
backend/src/middlewares/authorize.ts
Normal file
20
backend/src/middlewares/authorize.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { UserRole } from '../types';
|
||||
|
||||
export function authorize(...allowedRoles: UserRole[]) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const user = req.user;
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ code: 401, message: '未认证' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(user.role)) {
|
||||
res.status(403).json({ code: 403, message: '权限不足' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
368
backend/src/routes/__tests__/performance.property.test.ts
Normal file
368
backend/src/routes/__tests__/performance.property.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import * as fc from 'fast-check';
|
||||
import * as PerformanceDAO from '../../dao/PerformanceDAO';
|
||||
import * as AIResultDAO from '../../dao/AIResultDAO';
|
||||
import { UpsertPerformanceData, PerformanceRow, PerformanceStatus } from '../../dao/PerformanceDAO';
|
||||
|
||||
jest.mock('../../dao/PerformanceDAO');
|
||||
jest.mock('../../dao/AIResultDAO');
|
||||
|
||||
const mockUpsert = PerformanceDAO.upsert as jest.MockedFunction<typeof PerformanceDAO.upsert>;
|
||||
const mockFindByUserAndMonth = PerformanceDAO.findByUserAndMonth as jest.MockedFunction<typeof PerformanceDAO.findByUserAndMonth>;
|
||||
const mockFindByUserId = PerformanceDAO.findByUserId as jest.MockedFunction<typeof PerformanceDAO.findByUserId>;
|
||||
const mockFindDetailByPerfId = PerformanceDAO.findDetailByPerfId as jest.MockedFunction<typeof PerformanceDAO.findDetailByPerfId>;
|
||||
const mockFindAIByPerfId = AIResultDAO.findByPerfId as jest.MockedFunction<typeof AIResultDAO.findByPerfId>;
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Arbitraries
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const monthArb = fc.tuple(
|
||||
fc.integer({ min: 2020, max: 2030 }),
|
||||
fc.integer({ min: 1, max: 12 })
|
||||
).map(([y, m]) => `${y}-${String(m).padStart(2, '0')}`);
|
||||
|
||||
const perfItemArb = fc.record({
|
||||
item_name: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
item_category: fc.constantFrom<'business' | 'comprehensive'>('business', 'comprehensive'),
|
||||
weight: fc.integer({ min: 1, max: 20 }),
|
||||
user_content: fc.string({ minLength: 1, maxLength: 200 }),
|
||||
self_score: fc.integer({ min: 0, max: 100 }),
|
||||
ai_score: fc.constant(null),
|
||||
ai_explanation: fc.constant(null),
|
||||
manager_score: fc.constant(null),
|
||||
manager_explanation: fc.constant(null),
|
||||
evidence_url: fc.constant(null),
|
||||
});
|
||||
|
||||
const attendanceArb = fc.record({
|
||||
leave_days: fc.integer({ min: 0, max: 5 }),
|
||||
late_times: fc.integer({ min: 0, max: 5 }),
|
||||
absent_days: fc.integer({ min: 0, max: 3 }),
|
||||
lack_card_times: fc.integer({ min: 0, max: 5 }),
|
||||
attendance_score: fc.constant(null),
|
||||
remark: fc.constant(null),
|
||||
});
|
||||
|
||||
const draftDataArb = fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
selfScore: fc.integer({ min: 0, max: 100 }),
|
||||
workSummary: fc.string({ minLength: 1, maxLength: 500 }),
|
||||
items: fc.array(perfItemArb, { minLength: 1, maxLength: 5 }),
|
||||
attendance: attendanceArb,
|
||||
});
|
||||
|
||||
function makePerformanceRow(overrides: Partial<PerformanceRow> & { user_id: number; month: string; perf_id: number }): PerformanceRow {
|
||||
return {
|
||||
perf_id: overrides.perf_id,
|
||||
user_id: overrides.user_id,
|
||||
month: overrides.month,
|
||||
status: overrides.status ?? 'draft',
|
||||
self_score: overrides.self_score ?? null,
|
||||
ai_score: null,
|
||||
manager_score: null,
|
||||
total_score: null,
|
||||
level: null,
|
||||
reward_punish: null,
|
||||
work_summary: overrides.work_summary ?? null,
|
||||
submit_time: null,
|
||||
review_time: null,
|
||||
review_opinion: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 3: 草稿暂存往返一致性
|
||||
// For any performance draft data, saving as draft then reading back should
|
||||
// return data consistent with what was saved.
|
||||
// Validates: Requirements 2.5
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 3: 草稿暂存往返一致性', () => {
|
||||
it('draft saved then read back returns consistent userId, month, status, selfScore, and workSummary', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
draftDataArb,
|
||||
async ({ userId, month, selfScore, workSummary, items, attendance }) => {
|
||||
const perfId = Math.floor(Math.random() * 9000) + 1;
|
||||
|
||||
// Simulate upsert returning a perfId
|
||||
mockUpsert.mockResolvedValue(perfId);
|
||||
|
||||
// Simulate reading back the saved draft
|
||||
const savedRow = makePerformanceRow({
|
||||
perf_id: perfId,
|
||||
user_id: userId,
|
||||
month,
|
||||
status: 'draft',
|
||||
self_score: selfScore,
|
||||
work_summary: workSummary,
|
||||
});
|
||||
mockFindByUserAndMonth.mockResolvedValue(savedRow);
|
||||
|
||||
// Step 1: Save draft
|
||||
const upsertData: UpsertPerformanceData = {
|
||||
userId,
|
||||
month,
|
||||
status: 'draft',
|
||||
selfScore,
|
||||
workSummary,
|
||||
items,
|
||||
attendance,
|
||||
};
|
||||
const returnedPerfId = await PerformanceDAO.upsert(upsertData);
|
||||
|
||||
// Step 2: Read back
|
||||
const readBack = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
// Round-trip consistency checks
|
||||
expect(readBack).not.toBeNull();
|
||||
expect(readBack!.user_id).toBe(userId);
|
||||
expect(readBack!.month).toBe(month);
|
||||
expect(readBack!.status).toBe('draft');
|
||||
expect(readBack!.self_score).toBe(selfScore);
|
||||
expect(readBack!.work_summary).toBe(workSummary);
|
||||
expect(readBack!.perf_id).toBe(returnedPerfId);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('draft status is preserved (not auto-promoted to submitted)', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
}),
|
||||
async ({ userId, month }) => {
|
||||
const perfId = 42;
|
||||
mockUpsert.mockResolvedValue(perfId);
|
||||
mockFindByUserAndMonth.mockResolvedValue(
|
||||
makePerformanceRow({ perf_id: perfId, user_id: userId, month, status: 'draft' })
|
||||
);
|
||||
|
||||
await PerformanceDAO.upsert({ userId, month, status: 'draft' });
|
||||
const readBack = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
expect(readBack!.status).toBe('draft');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 4: 提交幂等性
|
||||
// For any already-submitted performance record, attempting to submit again for
|
||||
// the same user and month must be rejected — the record status stays unchanged.
|
||||
// Validates: Requirements 2.6
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 4: 提交幂等性', () => {
|
||||
const alreadySubmittedStatuses: PerformanceStatus[] = ['submitted', 'under_review', 'completed'];
|
||||
|
||||
it('re-submitting an already-submitted record is rejected for all terminal statuses', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
existingStatus: fc.constantFrom<PerformanceStatus>(...alreadySubmittedStatuses),
|
||||
}),
|
||||
async ({ userId, month, existingStatus }) => {
|
||||
const perfId = 99;
|
||||
|
||||
// Existing record is already in a submitted/terminal state
|
||||
mockFindByUserAndMonth.mockResolvedValue(
|
||||
makePerformanceRow({ perf_id: perfId, user_id: userId, month, status: existingStatus })
|
||||
);
|
||||
|
||||
// The route checks findByUserAndMonth before calling upsert
|
||||
const existing = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
// Idempotency guard: if already submitted/under_review/completed, reject
|
||||
const shouldReject =
|
||||
existing !== null &&
|
||||
(existing.status === 'submitted' ||
|
||||
existing.status === 'under_review' ||
|
||||
existing.status === 'completed');
|
||||
|
||||
expect(shouldReject).toBe(true);
|
||||
|
||||
// upsert must NOT have been called (the route returns 400 before calling upsert)
|
||||
expect(mockUpsert).not.toHaveBeenCalled();
|
||||
|
||||
// The existing record status must remain unchanged
|
||||
expect(existing!.status).toBe(existingStatus);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('submitting when no prior record exists is always allowed', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
}),
|
||||
async ({ userId, month }) => {
|
||||
// No existing record
|
||||
mockFindByUserAndMonth.mockResolvedValue(null);
|
||||
mockUpsert.mockResolvedValue(1);
|
||||
|
||||
const existing = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
// No existing record → submission is allowed
|
||||
const shouldReject =
|
||||
existing !== null &&
|
||||
(existing.status === 'submitted' ||
|
||||
existing.status === 'under_review' ||
|
||||
existing.status === 'completed');
|
||||
|
||||
expect(shouldReject).toBe(false);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a draft record does not block a new submission', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
}),
|
||||
async ({ userId, month }) => {
|
||||
// Existing record is only a draft
|
||||
mockFindByUserAndMonth.mockResolvedValue(
|
||||
makePerformanceRow({ perf_id: 1, user_id: userId, month, status: 'draft' })
|
||||
);
|
||||
|
||||
const existing = await PerformanceDAO.findByUserAndMonth(userId, month);
|
||||
|
||||
const shouldReject =
|
||||
existing !== null &&
|
||||
(existing.status === 'submitted' ||
|
||||
existing.status === 'under_review' ||
|
||||
existing.status === 'completed');
|
||||
|
||||
// Draft should NOT block submission
|
||||
expect(shouldReject).toBe(false);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 7: 绩效记录查询完整性
|
||||
// For any submitted or completed performance record, querying the employee's
|
||||
// history must include that record (insert/query round-trip).
|
||||
// Validates: Requirements 6.1
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 7: 绩效记录查询完整性', () => {
|
||||
const queryableStatuses: PerformanceStatus[] = ['submitted', 'completed', 'under_review'];
|
||||
|
||||
it('a submitted record always appears in the employee query result', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
status: fc.constantFrom<PerformanceStatus>(...queryableStatuses),
|
||||
selfScore: fc.integer({ min: 0, max: 100 }),
|
||||
}),
|
||||
async ({ userId, month, status, selfScore }) => {
|
||||
const perfId = Math.floor(Math.random() * 9000) + 1;
|
||||
|
||||
// Simulate the record existing after submission
|
||||
const submittedRow = makePerformanceRow({
|
||||
perf_id: perfId,
|
||||
user_id: userId,
|
||||
month,
|
||||
status,
|
||||
self_score: selfScore,
|
||||
});
|
||||
|
||||
mockFindByUserId.mockResolvedValue({ total: 1, records: [submittedRow] });
|
||||
|
||||
// Query the employee's performance list
|
||||
const result = await PerformanceDAO.findByUserId(userId, undefined, { page: 1, pageSize: 10 });
|
||||
|
||||
// The submitted record must appear in the results
|
||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
||||
const found = result.records.find((r) => r.perf_id === perfId);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.user_id).toBe(userId);
|
||||
expect(found!.month).toBe(month);
|
||||
expect(found!.status).toBe(status);
|
||||
expect(found!.self_score).toBe(selfScore);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('querying by month filter returns only records matching that month', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
month: monthArb,
|
||||
}),
|
||||
async ({ userId, month }) => {
|
||||
const perfId = Math.floor(Math.random() * 9000) + 1;
|
||||
const row = makePerformanceRow({ perf_id: perfId, user_id: userId, month, status: 'submitted' });
|
||||
|
||||
mockFindByUserId.mockResolvedValue({ total: 1, records: [row] });
|
||||
|
||||
const result = await PerformanceDAO.findByUserId(userId, month, { page: 1, pageSize: 10 });
|
||||
|
||||
// All returned records must match the queried month
|
||||
for (const rec of result.records) {
|
||||
expect(rec.month).toBe(month);
|
||||
}
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('total count is consistent with the number of returned records', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
recordCount: fc.integer({ min: 0, max: 10 }),
|
||||
}),
|
||||
async ({ userId, recordCount }) => {
|
||||
const records = Array.from({ length: recordCount }, (_, i) =>
|
||||
makePerformanceRow({
|
||||
perf_id: i + 1,
|
||||
user_id: userId,
|
||||
month: `2024-${String((i % 12) + 1).padStart(2, '0')}`,
|
||||
status: 'submitted',
|
||||
})
|
||||
);
|
||||
|
||||
mockFindByUserId.mockResolvedValue({ total: recordCount, records });
|
||||
|
||||
const result = await PerformanceDAO.findByUserId(userId, undefined, { page: 1, pageSize: 20 });
|
||||
|
||||
expect(result.total).toBe(recordCount);
|
||||
expect(result.records.length).toBe(recordCount);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
27
backend/src/routes/auth.ts
Normal file
27
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { login } from '../services/AuthService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/user/login
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
console.log('收到登录请求:', req.body);
|
||||
const { username, password, role } = req.body;
|
||||
|
||||
if (!username || !password || !role) {
|
||||
console.log('参数验证失败');
|
||||
return res.status(400).json({ code: 400, message: '用户名、密码和角色不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('调用登录服务...');
|
||||
const result = await login(username, password, role);
|
||||
console.log('登录成功:', result.userInfo);
|
||||
return res.json({ code: 200, message: '登录成功', data: result });
|
||||
} catch (err: any) {
|
||||
console.error('登录失败:', err.message);
|
||||
return res.status(401).json({ code: 401, message: err.message || '登录失败' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
125
backend/src/routes/config.ts
Normal file
125
backend/src/routes/config.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate } from '../middlewares/authenticate';
|
||||
import { authorize } from '../middlewares/authorize';
|
||||
import { getAllRules, getRuleByKey, updateRule, updateRules } from '../services/ConfigService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// ─── GET /api/config/rules ────────────────────────────────────────────────────
|
||||
// Returns all current performance evaluation rules.
|
||||
// Requirements: 8.5
|
||||
router.get(
|
||||
'/rules',
|
||||
authorize('generalManager'),
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const rules = await getAllRules();
|
||||
return res.json({ code: 200, message: '查询成功', data: rules });
|
||||
} catch (err) {
|
||||
console.error('[config/rules GET]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── GET /api/config/rules/:ruleKey ──────────────────────────────────────────
|
||||
// Returns a single rule by its key.
|
||||
// Requirements: 8.5
|
||||
router.get(
|
||||
'/rules/:ruleKey',
|
||||
authorize('generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const { ruleKey } = req.params;
|
||||
try {
|
||||
const rule = await getRuleByKey(ruleKey);
|
||||
if (!rule) {
|
||||
return res.status(404).json({ code: 404, message: '规则不存在' });
|
||||
}
|
||||
return res.json({ code: 200, message: '查询成功', data: rule });
|
||||
} catch (err) {
|
||||
console.error('[config/rules/:ruleKey GET]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── PUT /api/config/rules ────────────────────────────────────────────────────
|
||||
// Batch update multiple rules. Logs each change and applies to subsequent cycles.
|
||||
// Body: { rules: Array<{ ruleKey, ruleValue, description?, effectiveCycle? }> }
|
||||
// Requirements: 8.5, 8.6
|
||||
router.put(
|
||||
'/rules',
|
||||
authorize('generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { rules } = req.body as { rules?: unknown[] };
|
||||
|
||||
if (!Array.isArray(rules) || rules.length === 0) {
|
||||
return res.status(400).json({ code: 400, message: 'rules 数组不能为空' });
|
||||
}
|
||||
|
||||
// Validate each rule entry
|
||||
for (const item of rules) {
|
||||
const r = item as Record<string, unknown>;
|
||||
if (!r.ruleKey || typeof r.ruleKey !== 'string') {
|
||||
return res.status(400).json({ code: 400, message: '每条规则必须包含有效的 ruleKey' });
|
||||
}
|
||||
if (r.ruleValue === undefined) {
|
||||
return res.status(400).json({ code: 400, message: `规则 ${r.ruleKey} 缺少 ruleValue` });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateRules(
|
||||
(rules as Array<Record<string, unknown>>).map((r) => ({
|
||||
ruleKey: r.ruleKey as string,
|
||||
ruleValue: r.ruleValue,
|
||||
description: r.description as string | undefined,
|
||||
effectiveCycle: r.effectiveCycle as string | undefined,
|
||||
})),
|
||||
user.userId
|
||||
);
|
||||
return res.json({ code: 200, message: '规则更新成功', data: updated });
|
||||
} catch (err) {
|
||||
console.error('[config/rules PUT]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── PUT /api/config/rules/:ruleKey ──────────────────────────────────────────
|
||||
// Update a single rule by key. Logs the change and applies to subsequent cycles.
|
||||
// Body: { ruleValue, description?, effectiveCycle? }
|
||||
// Requirements: 8.5, 8.6
|
||||
router.put(
|
||||
'/rules/:ruleKey',
|
||||
authorize('generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { ruleKey } = req.params;
|
||||
const { ruleValue, description, effectiveCycle } = req.body as {
|
||||
ruleValue?: unknown;
|
||||
description?: string;
|
||||
effectiveCycle?: string;
|
||||
};
|
||||
|
||||
if (ruleValue === undefined) {
|
||||
return res.status(400).json({ code: 400, message: 'ruleValue 不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await updateRule(
|
||||
{ ruleKey, ruleValue, description, effectiveCycle },
|
||||
user.userId
|
||||
);
|
||||
return res.json({ code: 200, message: '规则更新成功', data: updated });
|
||||
} catch (err) {
|
||||
console.error('[config/rules/:ruleKey PUT]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
129
backend/src/routes/employee.ts
Normal file
129
backend/src/routes/employee.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate } from '../middlewares/authenticate';
|
||||
import { authorize } from '../middlewares/authorize';
|
||||
import pool from '../config/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate);
|
||||
|
||||
// GET /api/employee/list — 获取下属员工列表(管理层)
|
||||
router.get('/list', authorize('manager', 'generalManager'), async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
try {
|
||||
let rows: any[];
|
||||
if (user.role === 'generalManager') {
|
||||
[rows] = await pool.query<any[]>(
|
||||
`SELECT user_id, username, name, department, position, status, created_at
|
||||
FROM user WHERE role = 'employee' ORDER BY department, name`
|
||||
);
|
||||
} else {
|
||||
[rows] = await pool.query<any[]>(
|
||||
`SELECT user_id, username, name, department, position, status, created_at
|
||||
FROM user WHERE role = 'employee' AND manager_id = ? ORDER BY name`,
|
||||
[user.userId]
|
||||
);
|
||||
}
|
||||
return res.json({ code: 200, message: '查询成功', data: rows });
|
||||
} catch (err) {
|
||||
console.error('[employee/list]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/employee/create — 新建员工账号
|
||||
router.post('/create', authorize('manager', 'generalManager'), async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { username, password, name, department, position } = req.body;
|
||||
|
||||
if (!username || !password || !name || !department || !position) {
|
||||
return res.status(400).json({ code: 400, message: '用户名、密码、姓名、部门、岗位均为必填' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查用户名是否已存在
|
||||
const [existing] = await pool.query<any[]>('SELECT user_id FROM user WHERE username = ?', [username]);
|
||||
if (existing.length > 0) {
|
||||
return res.status(400).json({ code: 400, message: '用户名已存在' });
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const managerId = user.role === 'manager' ? user.userId : null;
|
||||
|
||||
const [result] = await pool.query<any>(
|
||||
`INSERT INTO user (username, password, name, role, department, position, manager_id, status)
|
||||
VALUES (?, ?, ?, 'employee', ?, ?, ?, 'active')`,
|
||||
[username, hashedPassword, name, department, position, managerId]
|
||||
);
|
||||
|
||||
return res.json({ code: 200, message: '员工账号创建成功', data: { userId: result.insertId } });
|
||||
} catch (err) {
|
||||
console.error('[employee/create]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/employee/:userId — 删除员工账号
|
||||
router.delete('/:userId', authorize('manager', 'generalManager'), async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const targetId = parseInt(req.params.userId, 10);
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT user_id, manager_id, role FROM user WHERE user_id = ?', [targetId]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '员工不存在' });
|
||||
}
|
||||
if (rows[0].role !== 'employee') {
|
||||
return res.status(400).json({ code: 400, message: '只能删除员工账号' });
|
||||
}
|
||||
// 管理层只能删除自己的下属
|
||||
if (user.role === 'manager' && rows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM user WHERE user_id = ?', [targetId]);
|
||||
return res.json({ code: 200, message: '员工账号已删除' });
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ER_ROW_IS_REFERENCED_2') {
|
||||
return res.status(400).json({ code: 400, message: '该员工有绩效记录,无法直接删除,请先归档相关数据' });
|
||||
}
|
||||
console.error('[employee/delete]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/employee/:userId/status — 禁用/启用员工账号
|
||||
router.patch('/:userId/status', authorize('manager', 'generalManager'), async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const targetId = parseInt(req.params.userId, 10);
|
||||
const { status } = req.body; // 'active' | 'inactive'
|
||||
|
||||
if (status !== 'active' && status !== 'inactive') {
|
||||
return res.status(400).json({ code: 400, message: 'status 必须为 active 或 inactive' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
'SELECT user_id, manager_id, role FROM user WHERE user_id = ?', [targetId]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ code: 404, message: '员工不存在' });
|
||||
}
|
||||
if (rows[0].role !== 'employee') {
|
||||
return res.status(400).json({ code: 400, message: '只能操作员工账号' });
|
||||
}
|
||||
if (user.role === 'manager' && rows[0].manager_id !== user.userId) {
|
||||
return res.status(403).json({ code: 403, message: '权限不足,该员工不是您的下属' });
|
||||
}
|
||||
|
||||
await pool.query('UPDATE user SET status = ? WHERE user_id = ?', [status, targetId]);
|
||||
return res.json({ code: 200, message: status === 'inactive' ? '员工账号已禁用' : '员工账号已启用' });
|
||||
} catch (err) {
|
||||
console.error('[employee/status]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
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;
|
||||
58
backend/src/routes/statistics.ts
Normal file
58
backend/src/routes/statistics.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { authenticate } from '../middlewares/authenticate';
|
||||
import { authorize } from '../middlewares/authorize';
|
||||
import { getTeamStatistics, getCompanyStatistics } from '../services/StatisticsService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// ─── 10.1 GET /api/statistics/team ───────────────────────────────────────────
|
||||
// Returns team statistics for the authenticated manager's subordinates.
|
||||
// Includes average score, count and rate per performance level.
|
||||
// Requirements: 7.4, 7.5
|
||||
router.get(
|
||||
'/team',
|
||||
authorize('manager', 'generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const user = req.user!;
|
||||
const { month } = req.query as Record<string, string>;
|
||||
|
||||
if (!month) {
|
||||
return res.status(400).json({ code: 400, message: '月份参数不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await getTeamStatistics(user.userId, month);
|
||||
return res.json({ code: 200, message: '查询成功', data: stats });
|
||||
} catch (err) {
|
||||
console.error('[statistics/team]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─── 10.2 GET /api/statistics/company ────────────────────────────────────────
|
||||
// Returns company-wide statistics broken down by department, position, and level.
|
||||
// Requirements: 8.1, 8.2
|
||||
router.get(
|
||||
'/company',
|
||||
authorize('generalManager'),
|
||||
async (req: Request, res: Response) => {
|
||||
const { month } = req.query as Record<string, string>;
|
||||
|
||||
if (!month) {
|
||||
return res.status(400).json({ code: 400, message: '月份参数不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await getCompanyStatistics(month);
|
||||
return res.json({ code: 200, message: '查询成功', data: stats });
|
||||
} catch (err) {
|
||||
console.error('[statistics/company]', err);
|
||||
return res.status(500).json({ code: 500, message: '服务器内部错误' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
377
backend/src/services/AIEvaluationService.ts
Normal file
377
backend/src/services/AIEvaluationService.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import axios from 'axios';
|
||||
import * as PerformanceDAO from '../dao/PerformanceDAO';
|
||||
import * as AIResultDAO from '../dao/AIResultDAO';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIScoreData {
|
||||
aiScoreDetail: AIResultDAO.AIScoreItem[];
|
||||
aiTotalScore: number;
|
||||
aiProblems: string[];
|
||||
aiSuggestions: string[];
|
||||
}
|
||||
|
||||
// ─── Prompt Builder ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the prompt sent to FastGPT.
|
||||
* Includes employee position, month, performance items, attendance, and scoring rules.
|
||||
* Requirements: 13.2
|
||||
*/
|
||||
export function buildPrompt(
|
||||
performance: PerformanceDAO.PerformanceRow,
|
||||
items: PerformanceDAO.PerfItemRow[],
|
||||
attendance: PerformanceDAO.AttendanceRow | null,
|
||||
position: string
|
||||
): string {
|
||||
const itemsText = items
|
||||
.map(
|
||||
(item, idx) =>
|
||||
`${idx + 1}. 【${item.item_name}】(${item.item_category === 'business' ? '业务素质' : '综合素质'},权重${item.weight}分)\n` +
|
||||
` 员工填写内容:${item.user_content ?? '(未填写)'}\n` +
|
||||
` 员工自评分:${item.self_score ?? '(未填写)'}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const attendanceText = attendance
|
||||
? `事假${attendance.leave_days}天,迟到${attendance.late_times}次,缺卡${attendance.lack_card_times}次,旷工${attendance.absent_days}天`
|
||||
: '无考勤数据';
|
||||
|
||||
return `你是一名专业的HR绩效评估专家,请根据以下员工绩效填报内容进行客观评分。
|
||||
|
||||
## 员工信息
|
||||
- 岗位:${position}
|
||||
- 考核月份:${performance.month}
|
||||
|
||||
## 考核项目及填报内容
|
||||
${itemsText}
|
||||
|
||||
## 考勤情况
|
||||
${attendanceText}
|
||||
|
||||
## 工作汇总
|
||||
${performance.work_summary ?? '(未填写)'}
|
||||
|
||||
## 评分规则
|
||||
- 业务素质考评占总分70%,综合素质考评占总分30%
|
||||
- 每个考核项按0-100分评分,最终加权计算总分
|
||||
- 请根据员工填写内容的质量、完整性和实际工作表现进行评分
|
||||
|
||||
## 输出要求
|
||||
请严格按照以下JSON格式输出,不要包含任何其他内容:
|
||||
{
|
||||
"ai_score_detail": [
|
||||
{
|
||||
"itemName": "考核项名称",
|
||||
"weight": 权重分值,
|
||||
"aiScore": 评分(0-100),
|
||||
"scoreExplanation": "评分说明"
|
||||
}
|
||||
],
|
||||
"ai_total_score": 加权总分(0-100),
|
||||
"ai_problems": ["问题1", "问题2", "问题3"],
|
||||
"ai_suggestions": ["建议1", "建议2", "建议3"]
|
||||
}
|
||||
|
||||
注意:
|
||||
- ai_problems 和 ai_suggestions 各需要3到5条
|
||||
- ai_total_score 为各项加权分数之和,业务素质项权重之和为70,综合素质项权重之和为30
|
||||
- 所有字段必须存在且格式正确`;
|
||||
}
|
||||
|
||||
// ─── Response Parser ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse and validate the AI JSON response.
|
||||
* Extracts ai_score_detail, ai_total_score, ai_problems, ai_suggestions.
|
||||
* Throws on invalid format, recording the raw response.
|
||||
* Requirements: 3.2, 3.3, 3.4, 13.3, 13.4
|
||||
*/
|
||||
export function parseAIResponse(rawResponse: string): AIScoreData {
|
||||
let parsed: any;
|
||||
|
||||
// Extract JSON from the response (model may wrap it in markdown code blocks)
|
||||
const jsonMatch = rawResponse.match(/```(?:json)?\s*([\s\S]*?)```/) ||
|
||||
rawResponse.match(/(\{[\s\S]*\})/);
|
||||
|
||||
const jsonStr = jsonMatch ? jsonMatch[1].trim() : rawResponse.trim();
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch (e) {
|
||||
throw new Error(`AI响应JSON解析失败,原始响应:${rawResponse}`);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!Array.isArray(parsed.ai_score_detail)) {
|
||||
throw new Error(`AI响应缺少 ai_score_detail 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
if (typeof parsed.ai_total_score !== 'number') {
|
||||
throw new Error(`AI响应缺少有效的 ai_total_score 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
if (!Array.isArray(parsed.ai_problems) || parsed.ai_problems.length < 1) {
|
||||
throw new Error(`AI响应缺少 ai_problems 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
if (!Array.isArray(parsed.ai_suggestions) || parsed.ai_suggestions.length < 1) {
|
||||
throw new Error(`AI响应缺少 ai_suggestions 字段,原始响应:${rawResponse}`);
|
||||
}
|
||||
|
||||
// Validate and map score detail items
|
||||
const aiScoreDetail: AIResultDAO.AIScoreItem[] = parsed.ai_score_detail.map((item: any, idx: number) => {
|
||||
if (typeof item.itemName !== 'string' || typeof item.weight !== 'number' ||
|
||||
typeof item.aiScore !== 'number' || typeof item.scoreExplanation !== 'string') {
|
||||
throw new Error(`AI响应 ai_score_detail[${idx}] 格式无效,原始响应:${rawResponse}`);
|
||||
}
|
||||
return {
|
||||
itemName: item.itemName,
|
||||
weight: item.weight,
|
||||
aiScore: item.aiScore,
|
||||
scoreExplanation: item.scoreExplanation,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
aiScoreDetail,
|
||||
aiTotalScore: parsed.ai_total_score,
|
||||
aiProblems: parsed.ai_problems as string[],
|
||||
aiSuggestions: parsed.ai_suggestions as string[],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── FastGPT API Call ─────────────────────────────────────────────────────────
|
||||
|
||||
const FASTGPT_API_URL = process.env.FASTGPT_API_URL || 'https://api.fastgpt.in/api/v1/chat/completions';
|
||||
const FASTGPT_API_KEY = process.env.FASTGPT_API_KEY || '';
|
||||
const AI_TIMEOUT_MS = 60_000; // 60秒超时
|
||||
|
||||
// 读取系统代理配置(支持 HTTP_PROXY / HTTPS_PROXY 环境变量)
|
||||
function getProxyConfig() {
|
||||
const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.https_proxy || process.env.http_proxy;
|
||||
if (proxyUrl) {
|
||||
try {
|
||||
const url = new URL(proxyUrl);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port, 10),
|
||||
protocol: url.protocol.replace(':', '') as 'http' | 'https',
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call FastGPT workflow API with structured variables.
|
||||
* FastGPT workflow expects: variables (position, month) + userChatInput (绩效内容)
|
||||
*/
|
||||
async function callFastGPT(
|
||||
position: string,
|
||||
month: string,
|
||||
contentText: string
|
||||
): Promise<AIScoreData> {
|
||||
console.log(`[AI] 调用 FastGPT API: ${FASTGPT_API_URL}`);
|
||||
|
||||
const proxyConfig = getProxyConfig();
|
||||
if (proxyConfig) {
|
||||
console.log(`[AI] 使用代理: ${proxyConfig.host}:${proxyConfig.port}`);
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
FASTGPT_API_URL,
|
||||
{
|
||||
messages: [{ role: 'user', content: contentText }],
|
||||
variables: {
|
||||
zslh34AG: position, // 员工岗位
|
||||
month: month, // 考核月份
|
||||
},
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${FASTGPT_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: AI_TIMEOUT_MS,
|
||||
proxy: proxyConfig,
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[AI] FastGPT 响应状态: ${response.status}`);
|
||||
|
||||
// FastGPT workflow 返回的最终结果在 choices[0].message.content 里
|
||||
// 内容是 system_rawResponse 对象(JSON字符串或对象)
|
||||
const content = response.data?.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error(`FastGPT API 返回内容为空,响应: ${JSON.stringify(response.data).substring(0, 300)}`);
|
||||
}
|
||||
|
||||
console.log(`[AI] FastGPT 原始返回:`, typeof content === 'string' ? content.substring(0, 300) : JSON.stringify(content).substring(0, 300));
|
||||
|
||||
// content 可能是带 markdown 代码块的字符串,或纯 JSON 字符串,或对象
|
||||
let parsed: any;
|
||||
if (typeof content === 'object') {
|
||||
parsed = content;
|
||||
} else {
|
||||
// 先尝试去掉 markdown 代码块 ```json ... ```
|
||||
let jsonStr = content.trim();
|
||||
const codeBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (codeBlockMatch) {
|
||||
jsonStr = codeBlockMatch[1].trim();
|
||||
} else {
|
||||
// 直接提取第一个完整 JSON 对象
|
||||
const objMatch = jsonStr.match(/\{[\s\S]*\}/);
|
||||
if (objMatch) {
|
||||
jsonStr = objMatch[0];
|
||||
}
|
||||
}
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch (e: any) {
|
||||
throw new Error(`FastGPT 返回内容无法解析为 JSON: ${e.message},内容片段: ${jsonStr.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验必要字段
|
||||
if (!Array.isArray(parsed.ai_score_detail)) {
|
||||
throw new Error(`响应缺少 ai_score_detail,内容: ${JSON.stringify(parsed).substring(0, 300)}`);
|
||||
}
|
||||
if (typeof parsed.ai_total_score !== 'number') {
|
||||
throw new Error(`响应缺少有效的 ai_total_score,内容: ${JSON.stringify(parsed).substring(0, 300)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
aiScoreDetail: parsed.ai_score_detail.map((item: any) => ({
|
||||
itemName: item.itemName,
|
||||
weight: item.weight,
|
||||
aiScore: item.aiScore,
|
||||
scoreExplanation: item.scoreExplanation,
|
||||
})),
|
||||
aiTotalScore: parsed.ai_total_score,
|
||||
aiProblems: Array.isArray(parsed.ai_problems) ? parsed.ai_problems : [],
|
||||
aiSuggestions: Array.isArray(parsed.ai_suggestions) ? parsed.ai_suggestions : [],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Core Evaluation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform a single AI evaluation attempt for the given performance record.
|
||||
*/
|
||||
async function evaluateOnce(
|
||||
perfId: number,
|
||||
performance: PerformanceDAO.PerformanceRow,
|
||||
items: PerformanceDAO.PerfItemRow[],
|
||||
attendance: PerformanceDAO.AttendanceRow | null,
|
||||
position: string
|
||||
): Promise<AIResultDAO.AIResult> {
|
||||
// 构建员工绩效内容文本(作为 userChatInput 传入 workflow)
|
||||
const itemsText = items
|
||||
.map(
|
||||
(item, idx) =>
|
||||
`${idx + 1}. 【${item.item_name}】(权重${item.weight}分)\n` +
|
||||
` 员工填写:${item.user_content ?? '(未填写)'}\n` +
|
||||
` 自评分:${item.self_score ?? '(未填写)'}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const attendanceText = attendance
|
||||
? `事假${attendance.leave_days}天,迟到${attendance.late_times}次,缺卡${attendance.lack_card_times}次,旷工${attendance.absent_days}天`
|
||||
: '无考勤数据';
|
||||
|
||||
const contentText = `考勤情况:${attendanceText}\n\n工作汇总:${performance.work_summary ?? '(未填写)'}\n\n考核项目:\n${itemsText}`;
|
||||
|
||||
const scoreData = await callFastGPT(position, performance.month, contentText);
|
||||
|
||||
const aiId = await AIResultDAO.save({
|
||||
perfId,
|
||||
aiScoreDetail: scoreData.aiScoreDetail,
|
||||
aiTotalScore: scoreData.aiTotalScore,
|
||||
aiProblems: scoreData.aiProblems,
|
||||
aiSuggestions: scoreData.aiSuggestions,
|
||||
apiResponse: JSON.stringify(scoreData),
|
||||
});
|
||||
|
||||
return {
|
||||
aiId,
|
||||
perfId,
|
||||
aiScoreDetail: scoreData.aiScoreDetail,
|
||||
aiTotalScore: scoreData.aiTotalScore,
|
||||
aiProblems: scoreData.aiProblems,
|
||||
aiSuggestions: scoreData.aiSuggestions,
|
||||
createTime: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Retry + Degradation ──────────────────────────────────────────────────────
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Evaluate performance with up to MAX_RETRIES attempts.
|
||||
* On all failures, logs the error and updates the performance status to 'ai_failed' (degradation).
|
||||
* Requirements: 3.5
|
||||
*/
|
||||
export async function evaluatePerformance(perfId: number): Promise<AIResultDAO.AIResult> {
|
||||
// Load full performance detail
|
||||
const detail = await PerformanceDAO.findDetailByPerfId(perfId);
|
||||
if (!detail) {
|
||||
throw new Error(`绩效记录不存在: perfId=${perfId}`);
|
||||
}
|
||||
|
||||
// Load employee position from user table
|
||||
const pool = (await import('../config/database')).default;
|
||||
const [userRows] = await pool.query<any[]>(
|
||||
'SELECT position FROM user WHERE user_id = ? LIMIT 1',
|
||||
[detail.performance.user_id]
|
||||
);
|
||||
const position: string = userRows[0]?.position ?? '未知岗位';
|
||||
|
||||
let lastError: Error = new Error('未知错误');
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await evaluateOnce(
|
||||
perfId,
|
||||
detail.performance,
|
||||
detail.items,
|
||||
detail.attendance,
|
||||
position
|
||||
);
|
||||
|
||||
// Update ai_score on performance_month
|
||||
await pool.query(
|
||||
'UPDATE performance_month SET ai_score = ? WHERE perf_id = ?',
|
||||
[result.aiTotalScore, perfId]
|
||||
);
|
||||
|
||||
// 回写 AI 评分到 perf_item 各考核项
|
||||
for (const scoreItem of result.aiScoreDetail) {
|
||||
await pool.query(
|
||||
`UPDATE perf_item SET ai_score = ?, ai_explanation = ?
|
||||
WHERE perf_id = ? AND item_name = ?`,
|
||||
[scoreItem.aiScore, scoreItem.scoreExplanation, perfId, scoreItem.itemName]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[AI] 评分完成 perfId=${perfId},总分=${result.aiTotalScore}(第${attempt}次尝试)`);
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
lastError = err;
|
||||
console.error(`[AI] 第${attempt}次评分失败 perfId=${perfId}:`, err.message);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
// Brief delay before retry (exponential backoff: 1s, 2s)
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted — degradation: log error, notify admin via console
|
||||
console.error(`[AI] 评分最终失败 perfId=${perfId},已重试${MAX_RETRIES}次。错误:${lastError.message}`);
|
||||
|
||||
// Note: We don't log to operation_log here because there's no user_id context
|
||||
// Admin can check console logs for AI evaluation failures
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
39
backend/src/services/AuthService.ts
Normal file
39
backend/src/services/AuthService.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { findByUsername } from '../dao/UserDAO';
|
||||
import { JWT_SECRET, JWT_EXPIRES_IN } from '../config/jwt';
|
||||
import { LoginResult, UserInfo, UserRole } from '../types';
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
role: string
|
||||
): Promise<LoginResult> {
|
||||
const user = await findByUsername(username);
|
||||
|
||||
if (!user || user.status !== 'active') {
|
||||
throw new Error('用户名或密码错误');
|
||||
}
|
||||
|
||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||
if (!passwordMatch) {
|
||||
throw new Error('用户名或密码错误');
|
||||
}
|
||||
|
||||
if (user.role !== role) {
|
||||
throw new Error('角色不匹配');
|
||||
}
|
||||
|
||||
const userInfo: UserInfo = {
|
||||
userId: user.user_id,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
department: user.department,
|
||||
position: user.position,
|
||||
managerId: user.manager_id,
|
||||
};
|
||||
|
||||
const token = jwt.sign(userInfo, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
|
||||
return { token, userInfo };
|
||||
}
|
||||
83
backend/src/services/CalculationService.ts
Normal file
83
backend/src/services/CalculationService.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import pool from '../config/database';
|
||||
import { PerformanceLevel } from '../dao/PerformanceDAO';
|
||||
|
||||
export interface AttendanceInput {
|
||||
leaveDays: number;
|
||||
lateTimes: number;
|
||||
lackCardTimes: number;
|
||||
}
|
||||
|
||||
export interface LevelAndReward {
|
||||
level: PerformanceLevel;
|
||||
rewardPunish: string;
|
||||
}
|
||||
|
||||
export interface ConsecutiveLowScoreResult {
|
||||
consecutiveMonths: number;
|
||||
warning: 'none' | 'written_warning' | 'dismissal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate attendance score.
|
||||
* Base: 10 points. Deduct 5 per leave day, 2 per late/lack-card occurrence. Minimum 0.
|
||||
* Requirements: 11.1, 11.2, 11.3, 11.5
|
||||
*/
|
||||
export function calculateAttendanceScore(input: AttendanceInput): number {
|
||||
const { leaveDays, lateTimes, lackCardTimes } = input;
|
||||
const deduction = leaveDays * 5 + lateTimes * 2 + lackCardTimes * 2;
|
||||
return Math.max(0, 10 - deduction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance level and reward/punishment description based on total score.
|
||||
* Requirements: 5.1, 5.2, 5.3, 5.4, 5.5
|
||||
*/
|
||||
export function calculateLevelAndReward(totalScore: number): LevelAndReward {
|
||||
if (totalScore >= 90) {
|
||||
return { level: 'excellent', rewardPunish: '优秀,按公司规定给予奖励' };
|
||||
} else if (totalScore >= 80) {
|
||||
return { level: 'qualified', rewardPunish: '合格,扣除当月绩效工资100元' };
|
||||
} else if (totalScore >= 70) {
|
||||
return { level: 'qualified', rewardPunish: '合格,扣除当月绩效工资200元' };
|
||||
} else if (totalScore >= 60) {
|
||||
return { level: 'need_motivation', rewardPunish: '需激励,扣除当月绩效工资300元' };
|
||||
} else {
|
||||
return { level: 'unqualified', rewardPunish: '不合格,扣除当月绩效工资600元' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check consecutive low score warning for an employee.
|
||||
* Queries the most recent completed performance records and counts consecutive months below 60.
|
||||
* Requirements: 5.6, 5.7
|
||||
*/
|
||||
export async function checkConsecutiveLowScore(
|
||||
userId: number,
|
||||
lookbackMonths: number = 3
|
||||
): Promise<ConsecutiveLowScoreResult> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT total_score FROM performance_month
|
||||
WHERE user_id = ? AND status = 'completed' AND total_score IS NOT NULL
|
||||
ORDER BY month DESC
|
||||
LIMIT ?`,
|
||||
[userId, lookbackMonths]
|
||||
);
|
||||
|
||||
let consecutiveMonths = 0;
|
||||
for (const row of rows) {
|
||||
if (row.total_score < 60) {
|
||||
consecutiveMonths++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let warning: ConsecutiveLowScoreResult['warning'] = 'none';
|
||||
if (consecutiveMonths >= 3) {
|
||||
warning = 'dismissal';
|
||||
} else if (consecutiveMonths >= 2) {
|
||||
warning = 'written_warning';
|
||||
}
|
||||
|
||||
return { consecutiveMonths, warning };
|
||||
}
|
||||
94
backend/src/services/ConfigService.ts
Normal file
94
backend/src/services/ConfigService.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import pool from '../config/database';
|
||||
import { findAllRules, findRuleByKey, upsertRule, RuleRow } from '../dao/ConfigDAO';
|
||||
|
||||
export interface RuleDTO {
|
||||
ruleKey: string;
|
||||
ruleValue: unknown;
|
||||
description?: string;
|
||||
effectiveCycle?: string;
|
||||
}
|
||||
|
||||
export interface RuleResponse {
|
||||
ruleId: number;
|
||||
ruleKey: string;
|
||||
ruleValue: unknown;
|
||||
description: string | null;
|
||||
effectiveCycle: string | null;
|
||||
updatedBy: number | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function toResponse(row: RuleRow): RuleResponse {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(row.rule_value);
|
||||
} catch {
|
||||
parsed = row.rule_value;
|
||||
}
|
||||
return {
|
||||
ruleId: row.rule_id,
|
||||
ruleKey: row.rule_key,
|
||||
ruleValue: parsed,
|
||||
description: row.description,
|
||||
effectiveCycle: row.effective_cycle,
|
||||
updatedBy: row.updated_by,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get all current rules */
|
||||
export async function getAllRules(): Promise<RuleResponse[]> {
|
||||
const rows = await findAllRules();
|
||||
return rows.map(toResponse);
|
||||
}
|
||||
|
||||
/** Get a single rule by key */
|
||||
export async function getRuleByKey(ruleKey: string): Promise<RuleResponse | null> {
|
||||
const row = await findRuleByKey(ruleKey);
|
||||
return row ? toResponse(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update (or create) a rule and record the change in operation_log.
|
||||
* Requirements: 8.5, 8.6
|
||||
*/
|
||||
export async function updateRule(dto: RuleDTO, operatorId: number): Promise<RuleResponse> {
|
||||
// Persist the rule
|
||||
await upsertRule({
|
||||
ruleKey: dto.ruleKey,
|
||||
ruleValue: dto.ruleValue,
|
||||
description: dto.description,
|
||||
effectiveCycle: dto.effectiveCycle,
|
||||
updatedBy: operatorId,
|
||||
});
|
||||
|
||||
// Record operation log so changes are traceable (Requirement 8.6)
|
||||
await pool.query(
|
||||
`INSERT INTO operation_log (user_id, operation_type, target_type, operation_detail)
|
||||
VALUES (?, 'update_rule', 'config', ?)`,
|
||||
[
|
||||
operatorId,
|
||||
JSON.stringify({
|
||||
ruleKey: dto.ruleKey,
|
||||
ruleValue: dto.ruleValue,
|
||||
effectiveCycle: dto.effectiveCycle ?? null,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
const updated = await findRuleByKey(dto.ruleKey);
|
||||
return toResponse(updated!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update multiple rules in a single operation.
|
||||
* Requirements: 8.5, 8.6
|
||||
*/
|
||||
export async function updateRules(rules: RuleDTO[], operatorId: number): Promise<RuleResponse[]> {
|
||||
const results: RuleResponse[] = [];
|
||||
for (const dto of rules) {
|
||||
const result = await updateRule(dto, operatorId);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
136
backend/src/services/ExportService.ts
Normal file
136
backend/src/services/ExportService.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import pool from '../config/database';
|
||||
|
||||
export interface ExportFilter {
|
||||
/** Export a single employee's history */
|
||||
userId?: number;
|
||||
/** Export all subordinates of a manager */
|
||||
managerId?: number;
|
||||
/** Restrict to a specific month (YYYY-MM) */
|
||||
month?: string;
|
||||
/** For GM: export all employees */
|
||||
allEmployees?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and return an Excel workbook buffer for performance records.
|
||||
* Supports:
|
||||
* - Single employee history (userId)
|
||||
* - Team export for a manager (managerId)
|
||||
* - Full company export (allEmployees = true)
|
||||
* Requirements: 7.3, 7.6, 8.4
|
||||
*/
|
||||
export async function exportPerformanceExcel(filter: ExportFilter): Promise<Buffer> {
|
||||
// Build query conditions
|
||||
const conditions: string[] = ["pm.status = 'completed'"];
|
||||
const params: any[] = [];
|
||||
|
||||
if (filter.userId) {
|
||||
conditions.push('pm.user_id = ?');
|
||||
params.push(filter.userId);
|
||||
} else if (filter.managerId) {
|
||||
conditions.push('u.manager_id = ?');
|
||||
params.push(filter.managerId);
|
||||
}
|
||||
// allEmployees: no extra condition needed
|
||||
|
||||
if (filter.month) {
|
||||
conditions.push('pm.month = ?');
|
||||
params.push(filter.month);
|
||||
}
|
||||
|
||||
const where = conditions.join(' AND ');
|
||||
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT
|
||||
pm.perf_id,
|
||||
pm.month,
|
||||
pm.status,
|
||||
pm.self_score,
|
||||
pm.ai_score,
|
||||
pm.manager_score,
|
||||
pm.total_score,
|
||||
pm.level,
|
||||
pm.reward_punish,
|
||||
pm.work_summary,
|
||||
pm.submit_time,
|
||||
pm.review_time,
|
||||
pm.review_opinion,
|
||||
u.name AS employee_name,
|
||||
u.department,
|
||||
u.position
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE ${where}
|
||||
ORDER BY u.department, u.name, pm.month DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = '员工绩效考核系统';
|
||||
workbook.created = new Date();
|
||||
|
||||
const sheet = workbook.addWorksheet('绩效数据');
|
||||
|
||||
// Header row
|
||||
sheet.columns = [
|
||||
{ header: '姓名', key: 'employee_name', width: 12 },
|
||||
{ header: '部门', key: 'department', width: 16 },
|
||||
{ header: '岗位', key: 'position', width: 16 },
|
||||
{ header: '考核月份', key: 'month', width: 12 },
|
||||
{ header: '自评分', key: 'self_score', width: 10 },
|
||||
{ header: 'AI评分', key: 'ai_score', width: 10 },
|
||||
{ header: '管理层评分', key: 'manager_score', width: 12 },
|
||||
{ header: '最终总分', key: 'total_score', width: 12 },
|
||||
{ header: '绩效等级', key: 'level', width: 14 },
|
||||
{ header: '奖惩说明', key: 'reward_punish', width: 30 },
|
||||
{ header: '工作汇总', key: 'work_summary', width: 40 },
|
||||
{ header: '审核意见', key: 'review_opinion', width: 40 },
|
||||
{ header: '提交时间', key: 'submit_time', width: 20 },
|
||||
{ header: '审核时间', key: 'review_time', width: 20 },
|
||||
];
|
||||
|
||||
// Style header row
|
||||
const headerRow = sheet.getRow(1);
|
||||
headerRow.font = { bold: true };
|
||||
headerRow.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFD9E1F2' },
|
||||
};
|
||||
headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
|
||||
const levelLabels: Record<string, string> = {
|
||||
excellent: '优秀',
|
||||
qualified: '合格',
|
||||
need_motivation: '需激励',
|
||||
unqualified: '不合格',
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
sheet.addRow({
|
||||
employee_name: row.employee_name,
|
||||
department: row.department,
|
||||
position: row.position,
|
||||
month: row.month,
|
||||
self_score: row.self_score ?? '',
|
||||
ai_score: row.ai_score ?? '',
|
||||
manager_score: row.manager_score ?? '',
|
||||
total_score: row.total_score ?? '',
|
||||
level: row.level ? (levelLabels[row.level] ?? row.level) : '',
|
||||
reward_punish: row.reward_punish ?? '',
|
||||
work_summary: row.work_summary ?? '',
|
||||
review_opinion: row.review_opinion ?? '',
|
||||
submit_time: row.submit_time ? new Date(row.submit_time).toLocaleString('zh-CN') : '',
|
||||
review_time: row.review_time ? new Date(row.review_time).toLocaleString('zh-CN') : '',
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-fit row heights for wrapped text columns
|
||||
sheet.getColumn('work_summary').alignment = { wrapText: true };
|
||||
sheet.getColumn('review_opinion').alignment = { wrapText: true };
|
||||
sheet.getColumn('reward_punish').alignment = { wrapText: true };
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
180
backend/src/services/StatisticsService.ts
Normal file
180
backend/src/services/StatisticsService.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import pool from '../config/database';
|
||||
import { PerformanceLevel } from '../dao/PerformanceDAO';
|
||||
|
||||
export interface TeamStats {
|
||||
averageScore: number;
|
||||
totalCount: number;
|
||||
excellentCount: number;
|
||||
qualifiedCount: number;
|
||||
needMotivationCount: number;
|
||||
unqualifiedCount: number;
|
||||
excellentRate: number;
|
||||
qualifiedRate: number;
|
||||
needMotivationRate: number;
|
||||
unqualifiedRate: number;
|
||||
}
|
||||
|
||||
export interface DepartmentStat {
|
||||
department: string;
|
||||
averageScore: number;
|
||||
totalCount: number;
|
||||
levelDistribution: Record<PerformanceLevel, number>;
|
||||
}
|
||||
|
||||
export interface PositionStat {
|
||||
position: string;
|
||||
averageScore: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface CompanyStats {
|
||||
month: string;
|
||||
totalCount: number;
|
||||
averageScore: number;
|
||||
departmentStats: DepartmentStat[];
|
||||
positionStats: PositionStat[];
|
||||
levelDistribution: Record<PerformanceLevel, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get team statistics for a manager's subordinates for a given month.
|
||||
* Requirements: 7.4, 7.5
|
||||
*/
|
||||
export async function getTeamStatistics(managerId: number, month: string): Promise<TeamStats> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.total_score, pm.level
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE u.manager_id = ? AND pm.month = ? AND pm.status = 'completed' AND pm.total_score IS NOT NULL`,
|
||||
[managerId, month]
|
||||
);
|
||||
|
||||
const totalCount = rows.length;
|
||||
if (totalCount === 0) {
|
||||
return {
|
||||
averageScore: 0,
|
||||
totalCount: 0,
|
||||
excellentCount: 0,
|
||||
qualifiedCount: 0,
|
||||
needMotivationCount: 0,
|
||||
unqualifiedCount: 0,
|
||||
excellentRate: 0,
|
||||
qualifiedRate: 0,
|
||||
needMotivationRate: 0,
|
||||
unqualifiedRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let sumScore = 0;
|
||||
let excellentCount = 0;
|
||||
let qualifiedCount = 0;
|
||||
let needMotivationCount = 0;
|
||||
let unqualifiedCount = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
sumScore += Number(row.total_score);
|
||||
switch (row.level as PerformanceLevel) {
|
||||
case 'excellent':
|
||||
excellentCount++;
|
||||
break;
|
||||
case 'qualified':
|
||||
qualifiedCount++;
|
||||
break;
|
||||
case 'need_motivation':
|
||||
needMotivationCount++;
|
||||
break;
|
||||
case 'unqualified':
|
||||
unqualifiedCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const averageScore = parseFloat((sumScore / totalCount).toFixed(2));
|
||||
const toRate = (n: number) => parseFloat(((n / totalCount) * 100).toFixed(2));
|
||||
|
||||
return {
|
||||
averageScore,
|
||||
totalCount,
|
||||
excellentCount,
|
||||
qualifiedCount,
|
||||
needMotivationCount,
|
||||
unqualifiedCount,
|
||||
excellentRate: toRate(excellentCount),
|
||||
qualifiedRate: toRate(qualifiedCount),
|
||||
needMotivationRate: toRate(needMotivationCount),
|
||||
unqualifiedRate: toRate(unqualifiedCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get company-wide statistics for a given month, broken down by department and position.
|
||||
* Requirements: 8.1, 8.2
|
||||
*/
|
||||
export async function getCompanyStatistics(month: string): Promise<CompanyStats> {
|
||||
const [rows] = await pool.query<any[]>(
|
||||
`SELECT pm.total_score, pm.level, u.department, u.position
|
||||
FROM performance_month pm
|
||||
JOIN user u ON pm.user_id = u.user_id
|
||||
WHERE pm.month = ? AND pm.status = 'completed' AND pm.total_score IS NOT NULL`,
|
||||
[month]
|
||||
);
|
||||
|
||||
const totalCount = rows.length;
|
||||
const levelDistribution: Record<PerformanceLevel, number> = {
|
||||
excellent: 0,
|
||||
qualified: 0,
|
||||
need_motivation: 0,
|
||||
unqualified: 0,
|
||||
};
|
||||
|
||||
// Aggregate by department
|
||||
const deptMap = new Map<string, { scores: number[]; levels: Record<PerformanceLevel, number> }>();
|
||||
// Aggregate by position
|
||||
const posMap = new Map<string, number[]>();
|
||||
|
||||
let totalSum = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const score = Number(row.total_score);
|
||||
const level = row.level as PerformanceLevel;
|
||||
const dept = row.department as string;
|
||||
const pos = row.position as string;
|
||||
|
||||
totalSum += score;
|
||||
if (level in levelDistribution) levelDistribution[level]++;
|
||||
|
||||
// Department
|
||||
if (!deptMap.has(dept)) {
|
||||
deptMap.set(dept, { scores: [], levels: { excellent: 0, qualified: 0, need_motivation: 0, unqualified: 0 } });
|
||||
}
|
||||
const deptEntry = deptMap.get(dept)!;
|
||||
deptEntry.scores.push(score);
|
||||
if (level in deptEntry.levels) deptEntry.levels[level]++;
|
||||
|
||||
// Position
|
||||
if (!posMap.has(pos)) posMap.set(pos, []);
|
||||
posMap.get(pos)!.push(score);
|
||||
}
|
||||
|
||||
const departmentStats: DepartmentStat[] = Array.from(deptMap.entries()).map(([department, data]) => ({
|
||||
department,
|
||||
averageScore: parseFloat((data.scores.reduce((a, b) => a + b, 0) / data.scores.length).toFixed(2)),
|
||||
totalCount: data.scores.length,
|
||||
levelDistribution: data.levels,
|
||||
}));
|
||||
|
||||
const positionStats: PositionStat[] = Array.from(posMap.entries()).map(([position, scores]) => ({
|
||||
position,
|
||||
averageScore: parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2)),
|
||||
totalCount: scores.length,
|
||||
}));
|
||||
|
||||
return {
|
||||
month,
|
||||
totalCount,
|
||||
averageScore: totalCount > 0 ? parseFloat((totalSum / totalCount).toFixed(2)) : 0,
|
||||
departmentStats,
|
||||
positionStats,
|
||||
levelDistribution,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as fc from 'fast-check';
|
||||
import { parseAIResponse, AIScoreData } from '../AIEvaluationService';
|
||||
import type { AIScoreItem } from '../../dao/AIResultDAO';
|
||||
|
||||
// ─── Arbitraries ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a valid AIScoreItem */
|
||||
const aiScoreItemArb: fc.Arbitrary<AIScoreItem> = fc.record({
|
||||
itemName: fc.string({ minLength: 1, maxLength: 50 }),
|
||||
weight: fc.integer({ min: 1, max: 30 }),
|
||||
aiScore: fc.float({ min: 0, max: 100, noNaN: true }),
|
||||
scoreExplanation: fc.string({ minLength: 1, maxLength: 200 }),
|
||||
});
|
||||
|
||||
/** Generate a valid AIScoreData object */
|
||||
const aiScoreDataArb: fc.Arbitrary<AIScoreData> = fc.record({
|
||||
aiScoreDetail: fc.array(aiScoreItemArb, { minLength: 1, maxLength: 17 }),
|
||||
aiTotalScore: fc.float({ min: 0, max: 100, noNaN: true }),
|
||||
aiProblems: fc.array(fc.string({ minLength: 1, maxLength: 100 }), { minLength: 3, maxLength: 5 }),
|
||||
aiSuggestions: fc.array(fc.string({ minLength: 1, maxLength: 100 }), { minLength: 3, maxLength: 5 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Serialize an AIScoreData object into the JSON string format that
|
||||
* parseAIResponse expects (matching the FastGPT output schema).
|
||||
*/
|
||||
function serializeToAIJson(data: AIScoreData): string {
|
||||
return JSON.stringify({
|
||||
ai_score_detail: data.aiScoreDetail.map((item) => ({
|
||||
itemName: item.itemName,
|
||||
weight: item.weight,
|
||||
aiScore: item.aiScore,
|
||||
scoreExplanation: item.scoreExplanation,
|
||||
})),
|
||||
ai_total_score: data.aiTotalScore,
|
||||
ai_problems: data.aiProblems,
|
||||
ai_suggestions: data.aiSuggestions,
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 9: AI 响应 JSON 解析往返一致性
|
||||
// For any valid AI score JSON string, parsing then re-serializing should
|
||||
// produce a semantically equivalent object.
|
||||
// Validates: Requirements 3.6, 13.3
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 9: AI 响应 JSON 解析往返一致性', () => {
|
||||
it('parse then re-serialize produces equivalent object', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const jsonStr = serializeToAIJson(original);
|
||||
const parsed = parseAIResponse(jsonStr);
|
||||
|
||||
// Round-trip: re-serialize and parse again
|
||||
const roundTripped = parseAIResponse(serializeToAIJson(parsed));
|
||||
|
||||
// The re-parsed result must be semantically equivalent to the first parse
|
||||
expect(roundTripped.aiTotalScore).toBeCloseTo(parsed.aiTotalScore, 5);
|
||||
expect(roundTripped.aiProblems).toEqual(parsed.aiProblems);
|
||||
expect(roundTripped.aiSuggestions).toEqual(parsed.aiSuggestions);
|
||||
expect(roundTripped.aiScoreDetail.length).toBe(parsed.aiScoreDetail.length);
|
||||
|
||||
roundTripped.aiScoreDetail.forEach((item, idx) => {
|
||||
expect(item.itemName).toBe(parsed.aiScoreDetail[idx].itemName);
|
||||
expect(item.weight).toBe(parsed.aiScoreDetail[idx].weight);
|
||||
expect(item.aiScore).toBeCloseTo(parsed.aiScoreDetail[idx].aiScore, 5);
|
||||
expect(item.scoreExplanation).toBe(parsed.aiScoreDetail[idx].scoreExplanation);
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parsed result preserves all score detail fields from original JSON', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const jsonStr = serializeToAIJson(original);
|
||||
const parsed = parseAIResponse(jsonStr);
|
||||
|
||||
expect(parsed.aiScoreDetail.length).toBe(original.aiScoreDetail.length);
|
||||
parsed.aiScoreDetail.forEach((item, idx) => {
|
||||
expect(item.itemName).toBe(original.aiScoreDetail[idx].itemName);
|
||||
expect(item.weight).toBe(original.aiScoreDetail[idx].weight);
|
||||
expect(item.scoreExplanation).toBe(original.aiScoreDetail[idx].scoreExplanation);
|
||||
});
|
||||
expect(parsed.aiProblems).toEqual(original.aiProblems);
|
||||
expect(parsed.aiSuggestions).toEqual(original.aiSuggestions);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parseAIResponse handles JSON wrapped in markdown code blocks', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const jsonStr = serializeToAIJson(original);
|
||||
const wrapped = `\`\`\`json\n${jsonStr}\n\`\`\``;
|
||||
const parsed = parseAIResponse(wrapped);
|
||||
|
||||
expect(parsed.aiProblems).toEqual(original.aiProblems);
|
||||
expect(parsed.aiSuggestions).toEqual(original.aiSuggestions);
|
||||
expect(parsed.aiScoreDetail.length).toBe(original.aiScoreDetail.length);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 10: AI 输出格式约束
|
||||
// For any valid AI response, ai_problems and ai_suggestions arrays must each
|
||||
// have a length between 3 and 5 (inclusive).
|
||||
// Validates: Requirements 3.3, 3.4
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 10: AI 输出格式约束', () => {
|
||||
it('parsed ai_problems length is always between 3 and 5', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const parsed = parseAIResponse(serializeToAIJson(original));
|
||||
expect(parsed.aiProblems.length).toBeGreaterThanOrEqual(3);
|
||||
expect(parsed.aiProblems.length).toBeLessThanOrEqual(5);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parsed ai_suggestions length is always between 3 and 5', () => {
|
||||
fc.assert(
|
||||
fc.property(aiScoreDataArb, (original) => {
|
||||
const parsed = parseAIResponse(serializeToAIJson(original));
|
||||
expect(parsed.aiSuggestions.length).toBeGreaterThanOrEqual(3);
|
||||
expect(parsed.aiSuggestions.length).toBeLessThanOrEqual(5);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parseAIResponse rejects ai_problems with fewer than 3 items', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
detail: fc.array(aiScoreItemArb, { minLength: 1, maxLength: 5 }),
|
||||
totalScore: fc.float({ min: 0, max: 100, noNaN: true }),
|
||||
// 0, 1, or 2 problems — all invalid
|
||||
problems: fc.array(fc.string({ minLength: 1 }), { minLength: 0, maxLength: 2 }),
|
||||
suggestions: fc.array(fc.string({ minLength: 1 }), { minLength: 3, maxLength: 5 }),
|
||||
}),
|
||||
({ detail, totalScore, problems, suggestions }) => {
|
||||
const json = JSON.stringify({
|
||||
ai_score_detail: detail,
|
||||
ai_total_score: totalScore,
|
||||
ai_problems: problems,
|
||||
ai_suggestions: suggestions,
|
||||
});
|
||||
expect(() => parseAIResponse(json)).toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('parseAIResponse rejects ai_suggestions with more than 5 items', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
detail: fc.array(aiScoreItemArb, { minLength: 1, maxLength: 5 }),
|
||||
totalScore: fc.float({ min: 0, max: 100, noNaN: true }),
|
||||
problems: fc.array(fc.string({ minLength: 1 }), { minLength: 3, maxLength: 5 }),
|
||||
// 6 or more suggestions — all invalid
|
||||
suggestions: fc.array(fc.string({ minLength: 1 }), { minLength: 6, maxLength: 10 }),
|
||||
}),
|
||||
({ detail, totalScore, problems, suggestions }) => {
|
||||
const json = JSON.stringify({
|
||||
ai_score_detail: detail,
|
||||
ai_total_score: totalScore,
|
||||
ai_problems: problems,
|
||||
ai_suggestions: suggestions,
|
||||
});
|
||||
expect(() => parseAIResponse(json)).toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
139
backend/src/services/__tests__/AuthService.property.test.ts
Normal file
139
backend/src/services/__tests__/AuthService.property.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as fc from 'fast-check';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { login } from '../AuthService';
|
||||
import * as UserDAO from '../../dao/UserDAO';
|
||||
import { JWT_SECRET } from '../../config/jwt';
|
||||
import { UserRole } from '../../types';
|
||||
|
||||
jest.mock('../../dao/UserDAO');
|
||||
const mockFindByUsername = UserDAO.findByUsername as jest.MockedFunction<typeof UserDAO.findByUsername>;
|
||||
|
||||
// Feature: employee-performance-system, Property 1: 认证正确性
|
||||
// For any credentials, the authentication result is strictly consistent with credential validity.
|
||||
describe('Property 1: 认证正确性', () => {
|
||||
const ROLES: UserRole[] = ['employee', 'manager', 'generalManager'];
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('valid credentials always succeed and return a verifiable token', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
password: fc.string({ minLength: 6, maxLength: 30 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
userId: fc.integer({ min: 1, max: 9999 }),
|
||||
name: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
department: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
position: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
}),
|
||||
async ({ username, password, role, userId, name, department, position }) => {
|
||||
const hashedPassword = bcrypt.hashSync(password, 1); // cost 1 for speed
|
||||
const userRow: UserDAO.UserRow = {
|
||||
user_id: userId,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
name,
|
||||
role,
|
||||
department,
|
||||
position,
|
||||
manager_id: null,
|
||||
status: 'active',
|
||||
};
|
||||
mockFindByUsername.mockResolvedValue(userRow);
|
||||
|
||||
const result = await login(username, password, role);
|
||||
|
||||
// Must return a token
|
||||
expect(result.token).toBeTruthy();
|
||||
// Token must be verifiable and carry correct userId
|
||||
const decoded = jwt.verify(result.token, JWT_SECRET) as any;
|
||||
expect(decoded.userId).toBe(userId);
|
||||
expect(decoded.role).toBe(role);
|
||||
// userInfo must match
|
||||
expect(result.userInfo.userId).toBe(userId);
|
||||
expect(result.userInfo.role).toBe(role);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('invalid password always throws and never returns a token', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
correctPassword: fc.string({ minLength: 6, maxLength: 30 }),
|
||||
wrongPassword: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
}).filter(({ correctPassword, wrongPassword }) => correctPassword !== wrongPassword),
|
||||
async ({ username, correctPassword, wrongPassword, role }) => {
|
||||
const hashedPassword = bcrypt.hashSync(correctPassword, 1);
|
||||
mockFindByUsername.mockResolvedValue({
|
||||
user_id: 1,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
name: '测试',
|
||||
role,
|
||||
department: '部门',
|
||||
position: '职位',
|
||||
manager_id: null,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await expect(login(username, wrongPassword, role)).rejects.toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('non-existent user always throws', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
password: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
role: fc.constantFrom<UserRole>(...ROLES),
|
||||
}),
|
||||
async ({ username, password, role }) => {
|
||||
mockFindByUsername.mockResolvedValue(null);
|
||||
await expect(login(username, password, role)).rejects.toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('role mismatch always throws', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.record({
|
||||
username: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
password: fc.string({ minLength: 6, maxLength: 30 }),
|
||||
storedRole: fc.constantFrom<UserRole>(...ROLES),
|
||||
requestedRole: fc.constantFrom<UserRole>(...ROLES),
|
||||
}).filter(({ storedRole, requestedRole }) => storedRole !== requestedRole),
|
||||
async ({ username, password, storedRole, requestedRole }) => {
|
||||
const hashedPassword = bcrypt.hashSync(password, 1);
|
||||
mockFindByUsername.mockResolvedValue({
|
||||
user_id: 1,
|
||||
username,
|
||||
password: hashedPassword,
|
||||
name: '测试',
|
||||
role: storedRole,
|
||||
department: '部门',
|
||||
position: '职位',
|
||||
manager_id: null,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await expect(login(username, password, requestedRole)).rejects.toThrow();
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
56
backend/src/services/__tests__/AuthService.test.ts
Normal file
56
backend/src/services/__tests__/AuthService.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { login } from '../AuthService';
|
||||
import * as UserDAO from '../../dao/UserDAO';
|
||||
import { JWT_SECRET } from '../../config/jwt';
|
||||
|
||||
jest.mock('../../dao/UserDAO');
|
||||
const mockFindByUsername = UserDAO.findByUsername as jest.MockedFunction<typeof UserDAO.findByUsername>;
|
||||
|
||||
const baseUser: UserDAO.UserRow = {
|
||||
user_id: 1,
|
||||
username: 'emp001',
|
||||
password: bcrypt.hashSync('password123', 10),
|
||||
name: '张三',
|
||||
role: 'employee',
|
||||
department: '研发部',
|
||||
position: '工程师',
|
||||
manager_id: 2,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
describe('AuthService.login', () => {
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
it('valid credentials return token and userInfo', async () => {
|
||||
mockFindByUsername.mockResolvedValue(baseUser);
|
||||
const result = await login('emp001', 'password123', 'employee');
|
||||
|
||||
expect(result.token).toBeTruthy();
|
||||
expect(result.userInfo.userId).toBe(1);
|
||||
expect(result.userInfo.role).toBe('employee');
|
||||
|
||||
const decoded = jwt.verify(result.token, JWT_SECRET) as any;
|
||||
expect(decoded.userId).toBe(1);
|
||||
});
|
||||
|
||||
it('wrong password throws error', async () => {
|
||||
mockFindByUsername.mockResolvedValue(baseUser);
|
||||
await expect(login('emp001', 'wrongpass', 'employee')).rejects.toThrow('用户名或密码错误');
|
||||
});
|
||||
|
||||
it('non-existent user throws error', async () => {
|
||||
mockFindByUsername.mockResolvedValue(null);
|
||||
await expect(login('nobody', 'pass', 'employee')).rejects.toThrow('用户名或密码错误');
|
||||
});
|
||||
|
||||
it('role mismatch throws error', async () => {
|
||||
mockFindByUsername.mockResolvedValue(baseUser);
|
||||
await expect(login('emp001', 'password123', 'manager')).rejects.toThrow('角色不匹配');
|
||||
});
|
||||
|
||||
it('inactive user throws error', async () => {
|
||||
mockFindByUsername.mockResolvedValue({ ...baseUser, status: 'inactive' });
|
||||
await expect(login('emp001', 'password123', 'employee')).rejects.toThrow('用户名或密码错误');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import * as fc from 'fast-check';
|
||||
import {
|
||||
calculateAttendanceScore,
|
||||
calculateLevelAndReward,
|
||||
AttendanceInput,
|
||||
} from '../CalculationService';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 5: 绩效等级与奖惩计算正确性
|
||||
// For any total score (0-100), the level and reward/punishment must strictly
|
||||
// follow the defined rules.
|
||||
// Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 5: 绩效等级与奖惩计算正确性', () => {
|
||||
it('score >= 90 → excellent with reward description', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 90, max: 100 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('excellent');
|
||||
expect(result.rewardPunish).toContain('奖励');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('80 <= score < 90 → qualified, deduct 100', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Use integer scores in [80, 89] to stay within 32-bit float constraints
|
||||
fc.integer({ min: 80, max: 89 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('qualified');
|
||||
expect(result.rewardPunish).toContain('100');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('70 <= score < 80 → qualified, deduct 200', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 70, max: 79 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('qualified');
|
||||
expect(result.rewardPunish).toContain('200');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('60 <= score < 70 → need_motivation, deduct 300', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 60, max: 69 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('need_motivation');
|
||||
expect(result.rewardPunish).toContain('300');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('score < 60 → unqualified, deduct 600', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 59 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBe('unqualified');
|
||||
expect(result.rewardPunish).toContain('600');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('every score in [0,100] produces a non-empty level and rewardPunish', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 100 }),
|
||||
(score) => {
|
||||
const result = calculateLevelAndReward(score);
|
||||
expect(result.level).toBeTruthy();
|
||||
expect(result.rewardPunish.length).toBeGreaterThan(0);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 6: 连续低分预警正确性
|
||||
// For any sequence of completed performance scores, consecutive months below 60
|
||||
// must trigger the correct warning level.
|
||||
// Validates: Requirements 5.6, 5.7
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 6: 连续低分预警正确性', () => {
|
||||
/**
|
||||
* We test the pure logic by extracting it from checkConsecutiveLowScore.
|
||||
* The function queries the DB, so we replicate the counting logic here and
|
||||
* verify it against the same rules the implementation uses.
|
||||
*/
|
||||
function deriveWarning(scores: number[]): { consecutiveMonths: number; warning: string } {
|
||||
let consecutiveMonths = 0;
|
||||
for (const score of scores) {
|
||||
if (score < 60) {
|
||||
consecutiveMonths++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let warning = 'none';
|
||||
if (consecutiveMonths >= 3) warning = 'dismissal';
|
||||
else if (consecutiveMonths >= 2) warning = 'written_warning';
|
||||
return { consecutiveMonths, warning };
|
||||
}
|
||||
|
||||
it('0 or 1 consecutive low scores → no warning', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
// Scores where the first element is >= 60 (no consecutive low streak)
|
||||
fc.array(fc.float({ min: 0, max: 100, noNaN: true }), { minLength: 1, maxLength: 6 }).map(
|
||||
(arr) => [arr[0] >= 60 ? arr[0] : arr[0] + 60, ...arr.slice(1)]
|
||||
),
|
||||
(scores) => {
|
||||
const { warning } = deriveWarning(scores);
|
||||
expect(warning).toBe('none');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('exactly 2 consecutive low scores → written_warning', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
low1: fc.integer({ min: 0, max: 59 }),
|
||||
low2: fc.integer({ min: 0, max: 59 }),
|
||||
// Third score is >= 60 to stop the streak at exactly 2
|
||||
third: fc.integer({ min: 60, max: 100 }),
|
||||
}),
|
||||
({ low1, low2, third }) => {
|
||||
const scores = [low1, low2, third];
|
||||
const { consecutiveMonths, warning } = deriveWarning(scores);
|
||||
expect(consecutiveMonths).toBe(2);
|
||||
expect(warning).toBe('written_warning');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('3 or more consecutive low scores → dismissal', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 0, max: 59 }), { minLength: 3, maxLength: 6 }),
|
||||
(lowScores) => {
|
||||
const { warning } = deriveWarning(lowScores);
|
||||
expect(warning).toBe('dismissal');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a high score resets the consecutive streak', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
highScore: fc.integer({ min: 60, max: 100 }),
|
||||
lowScores: fc.array(fc.integer({ min: 0, max: 59 }), { minLength: 1, maxLength: 5 }),
|
||||
}),
|
||||
({ highScore, lowScores }) => {
|
||||
// High score first, then low scores — streak should be 0
|
||||
const scores = [highScore, ...lowScores];
|
||||
const { consecutiveMonths, warning } = deriveWarning(scores);
|
||||
expect(consecutiveMonths).toBe(0);
|
||||
expect(warning).toBe('none');
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Feature: employee-performance-system, Property 8: 考勤分数计算正确性
|
||||
// For any attendance data, the score must follow the deduction rules and never
|
||||
// fall below 0.
|
||||
// Validates: Requirements 11.1, 11.2, 11.3, 11.5
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Property 8: 考勤分数计算正确性', () => {
|
||||
const attendanceArb = fc.record<AttendanceInput>({
|
||||
leaveDays: fc.integer({ min: 0, max: 10 }),
|
||||
lateTimes: fc.integer({ min: 0, max: 10 }),
|
||||
lackCardTimes: fc.integer({ min: 0, max: 10 }),
|
||||
});
|
||||
|
||||
it('score is always >= 0 (floor protection)', () => {
|
||||
fc.assert(
|
||||
fc.property(attendanceArb, (input) => {
|
||||
expect(calculateAttendanceScore(input)).toBeGreaterThanOrEqual(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('perfect attendance (all zeros) → full score of 10', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant<AttendanceInput>({ leaveDays: 0, lateTimes: 0, lackCardTimes: 0 }),
|
||||
(input) => {
|
||||
expect(calculateAttendanceScore(input)).toBe(10);
|
||||
}
|
||||
),
|
||||
{ numRuns: 1 }
|
||||
);
|
||||
});
|
||||
|
||||
it('each leave day deducts exactly 5 points (when no other deductions)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 2 }), // keep within range where score stays >= 0
|
||||
(leaveDays) => {
|
||||
const score = calculateAttendanceScore({ leaveDays, lateTimes: 0, lackCardTimes: 0 });
|
||||
const expected = Math.max(0, 10 - leaveDays * 5);
|
||||
expect(score).toBe(expected);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('each late/lack-card occurrence deducts exactly 2 points (when no other deductions)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 5 }),
|
||||
fc.integer({ min: 0, max: 5 }),
|
||||
(lateTimes, lackCardTimes) => {
|
||||
const score = calculateAttendanceScore({ leaveDays: 0, lateTimes, lackCardTimes });
|
||||
const expected = Math.max(0, 10 - lateTimes * 2 - lackCardTimes * 2);
|
||||
expect(score).toBe(expected);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('score is always <= 10 (cannot exceed base score)', () => {
|
||||
fc.assert(
|
||||
fc.property(attendanceArb, (input) => {
|
||||
expect(calculateAttendanceScore(input)).toBeLessThanOrEqual(10);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('more absences never produce a higher score (monotone deduction)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.record({
|
||||
leaveDays: fc.integer({ min: 0, max: 5 }),
|
||||
lateTimes: fc.integer({ min: 0, max: 5 }),
|
||||
lackCardTimes: fc.integer({ min: 0, max: 5 }),
|
||||
extraLeave: fc.integer({ min: 1, max: 3 }),
|
||||
}),
|
||||
({ leaveDays, lateTimes, lackCardTimes, extraLeave }) => {
|
||||
const base = calculateAttendanceScore({ leaveDays, lateTimes, lackCardTimes });
|
||||
const worse = calculateAttendanceScore({ leaveDays: leaveDays + extraLeave, lateTimes, lackCardTimes });
|
||||
expect(worse).toBeLessThanOrEqual(base);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
24
backend/src/types/index.ts
Normal file
24
backend/src/types/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type UserRole = 'employee' | 'manager' | 'generalManager';
|
||||
|
||||
export interface UserInfo {
|
||||
userId: number;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
department: string;
|
||||
position: string;
|
||||
managerId?: number | null;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string;
|
||||
userInfo: UserInfo;
|
||||
}
|
||||
|
||||
// Extend Express Request to carry authenticated user
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: UserInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
backend/test-db.ts
Normal file
42
backend/test-db.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function testConnection() {
|
||||
console.log('测试数据库连接...');
|
||||
console.log('配置信息:');
|
||||
console.log(' Host:', process.env.DB_HOST);
|
||||
console.log(' Port:', process.env.DB_PORT);
|
||||
console.log(' User:', process.env.DB_USER);
|
||||
console.log(' Password:', process.env.DB_PASSWORD ? '***' : '(空)');
|
||||
console.log(' Database:', process.env.DB_NAME);
|
||||
|
||||
try {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('\n✅ 数据库连接成功!');
|
||||
|
||||
// 测试查询
|
||||
const [rows] = await connection.query('SELECT COUNT(*) as count FROM user');
|
||||
console.log('用户表记录数:', (rows as any)[0].count);
|
||||
|
||||
await connection.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('\n❌ 数据库连接失败:', error.message);
|
||||
console.error('\n请检查:');
|
||||
console.error('1. MySQL 服务是否正在运行');
|
||||
console.error('2. 用户名和密码是否正确');
|
||||
console.error('3. 数据库是否已创建');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
testConnection();
|
||||
51
backend/test-fastgpt.ts
Normal file
51
backend/test-fastgpt.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios';
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const FASTGPT_API_URL = process.env.FASTGPT_API_URL!;
|
||||
const FASTGPT_API_KEY = process.env.FASTGPT_API_KEY!;
|
||||
const HTTPS_PROXY = process.env.HTTPS_PROXY;
|
||||
|
||||
async function test() {
|
||||
console.log('API URL:', FASTGPT_API_URL);
|
||||
console.log('代理:', HTTPS_PROXY || '无');
|
||||
console.log('正在发送请求...\n');
|
||||
|
||||
const contentText = `考勤情况:全勤,无迟到缺卡\n\n工作汇总:本月完成了前端页面开发,项目顺利交付。\n\n考核项目:\n1. 【工作目标完成情况】(权重15分)\n 员工填写:已完成所有既定目标\n 自评分:90`;
|
||||
|
||||
const proxyConfig = HTTPS_PROXY ? (() => {
|
||||
const url = new URL(HTTPS_PROXY);
|
||||
return { host: url.hostname, port: parseInt(url.port, 10), protocol: 'http' as const };
|
||||
})() : undefined;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
FASTGPT_API_URL,
|
||||
{
|
||||
messages: [{ role: 'user', content: contentText }],
|
||||
variables: { zslh34AG: '前端工程师', month: '2026-04' },
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${FASTGPT_API_KEY}`, 'Content-Type': 'application/json' },
|
||||
timeout: 60_000,
|
||||
proxy: proxyConfig,
|
||||
}
|
||||
);
|
||||
|
||||
console.log('状态码:', response.status);
|
||||
const content = response.data?.choices?.[0]?.message?.content;
|
||||
console.log('content 类型:', typeof content);
|
||||
console.log('content 内容:', typeof content === 'string' ? content.substring(0, 800) : JSON.stringify(content).substring(0, 800));
|
||||
} catch (err: any) {
|
||||
if (err.response) {
|
||||
console.error('HTTP 状态码:', err.response.status);
|
||||
console.error('响应体:', JSON.stringify(err.response.data, null, 2));
|
||||
} else {
|
||||
console.error('错误码:', err.code);
|
||||
console.error('错误信息:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test();
|
||||
19
backend/tsconfig.json
Normal file
19
backend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
34
backend/update-accounts.ts
Normal file
34
backend/update-accounts.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
async function run() {
|
||||
const hash = await bcrypt.hash('123456', 10);
|
||||
|
||||
// 删除 mgr002
|
||||
await pool.query('DELETE FROM user WHERE username = ?', ['mgr002']);
|
||||
console.log('删除 mgr002');
|
||||
|
||||
// 更新 gm001 → lister / 李总 / 总经理
|
||||
await pool.query(
|
||||
'UPDATE user SET username = ?, name = ?, password = ? WHERE username = ?',
|
||||
['lister', '李总', hash, 'gm001']
|
||||
);
|
||||
console.log('更新 gm001 → lister');
|
||||
|
||||
// 更新 mgr001 → xinxin / 孙薪薪 / 管理层
|
||||
await pool.query(
|
||||
'UPDATE user SET username = ?, name = ?, password = ? WHERE username = ?',
|
||||
['xinxin', '孙薪薪', hash, 'mgr001']
|
||||
);
|
||||
console.log('更新 mgr001 → xinxin');
|
||||
|
||||
const [rows] = await pool.query<any[]>('SELECT user_id, username, name, role, department, position FROM user ORDER BY role');
|
||||
console.log('\n当前账号:');
|
||||
console.table(rows);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
19
backend/update-passwords-final.ts
Normal file
19
backend/update-passwords-final.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
import pool from './src/config/database';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
async function run() {
|
||||
const xinxinHash = await bcrypt.hash('sxx980623', 10);
|
||||
const listerHash = await bcrypt.hash('lister123', 10);
|
||||
|
||||
await pool.query('UPDATE user SET password = ? WHERE username = ?', [xinxinHash, 'xinxin']);
|
||||
await pool.query('UPDATE user SET password = ? WHERE username = ?', [listerHash, 'lister']);
|
||||
|
||||
console.log('xinxin 密码已更新为 sxx980623');
|
||||
console.log('lister 密码已更新为 lister123');
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
44
backend/update-passwords.ts
Normal file
44
backend/update-passwords.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function updatePasswords() {
|
||||
try {
|
||||
const conn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'employee_performance',
|
||||
});
|
||||
|
||||
console.log('生成新的密码哈希...');
|
||||
const hash = await bcrypt.hash('123456', 10);
|
||||
console.log('新哈希:', hash);
|
||||
|
||||
// 测试旧哈希
|
||||
const oldHash = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
|
||||
const matchOld = await bcrypt.compare('123456', oldHash);
|
||||
console.log('旧哈希验证:', matchOld);
|
||||
|
||||
// 更新所有用户密码
|
||||
await conn.query('UPDATE user SET password = ?', [hash]);
|
||||
console.log('✓ 所有用户密码已更新');
|
||||
|
||||
// 验证更新
|
||||
const [rows] = await conn.query('SELECT username, password FROM user LIMIT 1');
|
||||
const user = (rows as any)[0];
|
||||
const matchNew = await bcrypt.compare('123456', user.password);
|
||||
console.log('新密码验证:', matchNew);
|
||||
|
||||
await conn.end();
|
||||
process.exit(0);
|
||||
} catch (error: any) {
|
||||
console.error('错误:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
updatePasswords();
|
||||
Reference in New Issue
Block a user