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

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