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,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[];
}