first commit

This commit is contained in:
2026-04-11 11:51:54 +08:00
commit b12a84e388
99 changed files with 19620 additions and 0 deletions

18
backend/.env.example Normal file
View 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
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.log
coverage/

21
backend/backfill-ai.ts Normal file
View 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);

View 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
View 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
View 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);

View 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();

View 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);

View 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
View 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
View 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();

View 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
View 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();

View 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
View 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);

View 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';

View 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
View 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
View 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();

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

39
backend/package.json Normal file
View 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"
}
}

View 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;

View File

@@ -0,0 +1,2 @@
export const JWT_SECRET = process.env.JWT_SECRET || 'change_this_secret_in_production';
export const JWT_EXPIRES_IN = '24h';

View 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,
};
}

View 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]
);
}

View 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();
}
}

View 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
View 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-MMNULL表示立即生效',
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
View 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
View 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
View 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;

View 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);
});
});

View 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 }
);
});
});

View 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);
});
});

View 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: '访问令牌无效或已过期' });
}
}

View 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();
};
}

View 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 }
);
});
});

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
}

View 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 };
}

View 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 };
}

View 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;
}

View 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);
}

View 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,
};
}

View File

@@ -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 }
);
});
});

View 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 }
);
});
});

View 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('用户名或密码错误');
});
});

View File

@@ -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 }
);
});
});

View 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
View 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
View 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
View 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"]
}

View 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);

View 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);

View 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();