Initial commit: KPI Management System Backend
Features: - FastAPI backend with JWT authentication - MySQL database with SQLAlchemy ORM - KPI workflow: draft → pending → approved → evaluation → completed - Ollama LLM API integration for AI features - Gitea API integration for version control - Complete API endpoints for KPI, dashboard, notifications Tables: KPI_D_* prefix naming convention 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
12
app/services/__init__.py
Normal file
12
app/services/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Business Logic Services
|
||||
"""
|
||||
from app.services.kpi_service import KPISheetService
|
||||
from app.services.notify_service import NotifyService
|
||||
from app.services.dashboard_service import DashboardService
|
||||
|
||||
__all__ = [
|
||||
"KPISheetService",
|
||||
"NotifyService",
|
||||
"DashboardService",
|
||||
]
|
||||
277
app/services/dashboard_service.py
Normal file
277
app/services/dashboard_service.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
儀表板服務
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.kpi_sheet import KPISheet, KPISheetStatus
|
||||
from app.models.kpi_period import KPIPeriod
|
||||
from app.models.department import Department
|
||||
from app.models.employee import Employee
|
||||
from app.models.dashboard_alert import DashboardAlert
|
||||
from app.schemas.dashboard import (
|
||||
ProgressStats,
|
||||
DistributionItem,
|
||||
TrendItem,
|
||||
DashboardProgressResponse,
|
||||
DashboardDistributionResponse,
|
||||
DashboardTrendsResponse,
|
||||
)
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""儀表板服務"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_progress(
|
||||
self, period_id: Optional[int] = None, department_id: Optional[int] = None
|
||||
) -> DashboardProgressResponse:
|
||||
"""取得進度統計"""
|
||||
# 取得期間
|
||||
if period_id:
|
||||
period = self.db.query(KPIPeriod).filter(KPIPeriod.id == period_id).first()
|
||||
else:
|
||||
period = (
|
||||
self.db.query(KPIPeriod)
|
||||
.filter(KPIPeriod.status != "completed")
|
||||
.order_by(KPIPeriod.start_date.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not period:
|
||||
return DashboardProgressResponse(
|
||||
period_code="N/A",
|
||||
period_name="無期間資料",
|
||||
stats=ProgressStats(
|
||||
total=0,
|
||||
draft=0,
|
||||
pending=0,
|
||||
approved=0,
|
||||
self_eval=0,
|
||||
manager_eval=0,
|
||||
completed=0,
|
||||
),
|
||||
completion_rate=0.0,
|
||||
)
|
||||
|
||||
# 查詢統計
|
||||
query = self.db.query(KPISheet).filter(KPISheet.period_id == period.id)
|
||||
if department_id:
|
||||
query = query.filter(KPISheet.department_id == department_id)
|
||||
|
||||
sheets = query.all()
|
||||
total = len(sheets)
|
||||
|
||||
stats = ProgressStats(
|
||||
total=total,
|
||||
draft=sum(1 for s in sheets if s.status == KPISheetStatus.DRAFT),
|
||||
pending=sum(1 for s in sheets if s.status == KPISheetStatus.PENDING),
|
||||
approved=sum(1 for s in sheets if s.status == KPISheetStatus.APPROVED),
|
||||
self_eval=sum(1 for s in sheets if s.status == KPISheetStatus.SELF_EVAL),
|
||||
manager_eval=sum(
|
||||
1 for s in sheets if s.status == KPISheetStatus.MANAGER_EVAL
|
||||
),
|
||||
completed=sum(1 for s in sheets if s.status == KPISheetStatus.COMPLETED),
|
||||
)
|
||||
|
||||
completion_rate = (stats.completed / total * 100) if total > 0 else 0.0
|
||||
|
||||
return DashboardProgressResponse(
|
||||
period_code=period.code,
|
||||
period_name=period.name,
|
||||
stats=stats,
|
||||
completion_rate=round(completion_rate, 1),
|
||||
)
|
||||
|
||||
def get_distribution(
|
||||
self, period_id: Optional[int] = None
|
||||
) -> DashboardDistributionResponse:
|
||||
"""取得分佈統計"""
|
||||
# 取得期間
|
||||
if period_id:
|
||||
period = self.db.query(KPIPeriod).filter(KPIPeriod.id == period_id).first()
|
||||
else:
|
||||
period = (
|
||||
self.db.query(KPIPeriod)
|
||||
.filter(KPIPeriod.status != "completed")
|
||||
.order_by(KPIPeriod.start_date.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not period:
|
||||
return DashboardDistributionResponse(
|
||||
by_department=[],
|
||||
by_status=[],
|
||||
by_score_range=[],
|
||||
)
|
||||
|
||||
sheets = (
|
||||
self.db.query(KPISheet).filter(KPISheet.period_id == period.id).all()
|
||||
)
|
||||
total = len(sheets) or 1 # 避免除以零
|
||||
|
||||
# 按部門分佈
|
||||
dept_counts = {}
|
||||
for sheet in sheets:
|
||||
dept_id = sheet.department_id
|
||||
dept = self.db.query(Department).filter(Department.id == dept_id).first()
|
||||
dept_name = dept.name if dept else "未知"
|
||||
dept_counts[dept_name] = dept_counts.get(dept_name, 0) + 1
|
||||
|
||||
by_department = [
|
||||
DistributionItem(
|
||||
label=name, count=count, percentage=round(count / total * 100, 1)
|
||||
)
|
||||
for name, count in dept_counts.items()
|
||||
]
|
||||
|
||||
# 按狀態分佈
|
||||
status_labels = {
|
||||
KPISheetStatus.DRAFT: "草稿",
|
||||
KPISheetStatus.PENDING: "待審核",
|
||||
KPISheetStatus.APPROVED: "已核准",
|
||||
KPISheetStatus.SELF_EVAL: "自評中",
|
||||
KPISheetStatus.MANAGER_EVAL: "主管評核中",
|
||||
KPISheetStatus.COMPLETED: "已完成",
|
||||
}
|
||||
status_counts = {}
|
||||
for sheet in sheets:
|
||||
label = status_labels.get(sheet.status, sheet.status)
|
||||
status_counts[label] = status_counts.get(label, 0) + 1
|
||||
|
||||
by_status = [
|
||||
DistributionItem(
|
||||
label=name, count=count, percentage=round(count / total * 100, 1)
|
||||
)
|
||||
for name, count in status_counts.items()
|
||||
]
|
||||
|
||||
# 按分數區間分佈(僅已完成)
|
||||
completed_sheets = [
|
||||
s for s in sheets if s.status == KPISheetStatus.COMPLETED and s.total_score
|
||||
]
|
||||
score_ranges = {
|
||||
"0-0.25": 0,
|
||||
"0.25-0.5": 0,
|
||||
"0.5-0.75": 0,
|
||||
"0.75-1.0": 0,
|
||||
}
|
||||
|
||||
for sheet in completed_sheets:
|
||||
score = float(sheet.total_score)
|
||||
if score < 0.25:
|
||||
score_ranges["0-0.25"] += 1
|
||||
elif score < 0.5:
|
||||
score_ranges["0.25-0.5"] += 1
|
||||
elif score < 0.75:
|
||||
score_ranges["0.5-0.75"] += 1
|
||||
else:
|
||||
score_ranges["0.75-1.0"] += 1
|
||||
|
||||
completed_total = len(completed_sheets) or 1
|
||||
by_score_range = [
|
||||
DistributionItem(
|
||||
label=name,
|
||||
count=count,
|
||||
percentage=round(count / completed_total * 100, 1),
|
||||
)
|
||||
for name, count in score_ranges.items()
|
||||
]
|
||||
|
||||
return DashboardDistributionResponse(
|
||||
by_department=by_department,
|
||||
by_status=by_status,
|
||||
by_score_range=by_score_range,
|
||||
)
|
||||
|
||||
def get_trends(self, limit: int = 4) -> DashboardTrendsResponse:
|
||||
"""取得趨勢統計"""
|
||||
periods = (
|
||||
self.db.query(KPIPeriod)
|
||||
.filter(KPIPeriod.status == "completed")
|
||||
.order_by(KPIPeriod.end_date.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
trends = []
|
||||
for period in reversed(periods):
|
||||
completed_sheets = (
|
||||
self.db.query(KPISheet)
|
||||
.filter(
|
||||
KPISheet.period_id == period.id,
|
||||
KPISheet.status == KPISheetStatus.COMPLETED,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if completed_sheets:
|
||||
scores = [
|
||||
float(s.total_score) for s in completed_sheets if s.total_score
|
||||
]
|
||||
avg_score = sum(scores) / len(scores) if scores else 0
|
||||
else:
|
||||
avg_score = 0
|
||||
|
||||
trends.append(
|
||||
TrendItem(
|
||||
period=period.code,
|
||||
average_score=round(avg_score, 3),
|
||||
completed_count=len(completed_sheets),
|
||||
)
|
||||
)
|
||||
|
||||
return DashboardTrendsResponse(trends=trends)
|
||||
|
||||
# ==================== 警示 ====================
|
||||
|
||||
def get_alerts(
|
||||
self, is_resolved: Optional[bool] = False, limit: int = 50
|
||||
) -> List[DashboardAlert]:
|
||||
"""取得警示"""
|
||||
query = self.db.query(DashboardAlert)
|
||||
if is_resolved is not None:
|
||||
query = query.filter(DashboardAlert.is_resolved == is_resolved)
|
||||
return query.order_by(DashboardAlert.created_at.desc()).limit(limit).all()
|
||||
|
||||
def create_alert(
|
||||
self,
|
||||
alert_type: str,
|
||||
severity: str,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
related_sheet_id: Optional[int] = None,
|
||||
related_employee_id: Optional[int] = None,
|
||||
) -> DashboardAlert:
|
||||
"""建立警示"""
|
||||
alert = DashboardAlert(
|
||||
alert_type=alert_type,
|
||||
severity=severity,
|
||||
title=title,
|
||||
description=description,
|
||||
related_sheet_id=related_sheet_id,
|
||||
related_employee_id=related_employee_id,
|
||||
)
|
||||
self.db.add(alert)
|
||||
self.db.commit()
|
||||
self.db.refresh(alert)
|
||||
return alert
|
||||
|
||||
def resolve_alert(self, alert_id: int, resolver_id: int) -> Optional[DashboardAlert]:
|
||||
"""解決警示"""
|
||||
alert = self.db.query(DashboardAlert).filter(DashboardAlert.id == alert_id).first()
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.is_resolved = True
|
||||
alert.resolved_at = datetime.utcnow()
|
||||
alert.resolved_by = resolver_id
|
||||
self.db.commit()
|
||||
self.db.refresh(alert)
|
||||
return alert
|
||||
198
app/services/gitea_service.py
Normal file
198
app/services/gitea_service.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Gitea 版本控制服務
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import requests
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class GiteaService:
|
||||
"""Gitea API 服務"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.GITEA_URL
|
||||
self.user = settings.GITEA_USER
|
||||
self.token = settings.GITEA_TOKEN
|
||||
self.headers = {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
發送 API 請求
|
||||
|
||||
Args:
|
||||
method: HTTP 方法
|
||||
endpoint: API 端點
|
||||
data: 請求資料
|
||||
|
||||
Returns:
|
||||
API 回應
|
||||
"""
|
||||
url = f"{self.base_url}/api/v1{endpoint}"
|
||||
|
||||
try:
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=self.headers,
|
||||
json=data,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code == 204:
|
||||
return {"success": True}
|
||||
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def get_user(self) -> Dict[str, Any]:
|
||||
"""取得當前使用者資訊"""
|
||||
return self._request("GET", "/user")
|
||||
|
||||
def list_repos(self) -> List[Dict[str, Any]]:
|
||||
"""列出使用者的所有 Repo"""
|
||||
return self._request("GET", f"/users/{self.user}/repos")
|
||||
|
||||
def get_repo(self, repo_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
取得 Repo 資訊
|
||||
|
||||
Args:
|
||||
repo_name: Repo 名稱
|
||||
"""
|
||||
return self._request("GET", f"/repos/{self.user}/{repo_name}")
|
||||
|
||||
def create_repo(
|
||||
self,
|
||||
name: str,
|
||||
description: str = "",
|
||||
private: bool = False,
|
||||
auto_init: bool = True,
|
||||
default_branch: str = "main",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
建立新的 Repo
|
||||
|
||||
Args:
|
||||
name: Repo 名稱
|
||||
description: 描述
|
||||
private: 是否為私有
|
||||
auto_init: 是否自動初始化 (建立 README)
|
||||
default_branch: 預設分支名稱
|
||||
|
||||
Returns:
|
||||
建立結果
|
||||
"""
|
||||
data = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"private": private,
|
||||
"auto_init": auto_init,
|
||||
"default_branch": default_branch,
|
||||
}
|
||||
return self._request("POST", "/user/repos", data)
|
||||
|
||||
def delete_repo(self, repo_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
刪除 Repo
|
||||
|
||||
Args:
|
||||
repo_name: Repo 名稱
|
||||
"""
|
||||
return self._request("DELETE", f"/repos/{self.user}/{repo_name}")
|
||||
|
||||
def create_file(
|
||||
self,
|
||||
repo_name: str,
|
||||
file_path: str,
|
||||
content: str,
|
||||
message: str = "Add file",
|
||||
branch: str = "main",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
在 Repo 中建立檔案
|
||||
|
||||
Args:
|
||||
repo_name: Repo 名稱
|
||||
file_path: 檔案路徑
|
||||
content: 檔案內容
|
||||
message: Commit 訊息
|
||||
branch: 分支名稱
|
||||
"""
|
||||
import base64
|
||||
|
||||
data = {
|
||||
"content": base64.b64encode(content.encode()).decode(),
|
||||
"message": message,
|
||||
"branch": branch,
|
||||
}
|
||||
return self._request(
|
||||
"POST", f"/repos/{self.user}/{repo_name}/contents/{file_path}", data
|
||||
)
|
||||
|
||||
def get_file(self, repo_name: str, file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
取得檔案內容
|
||||
|
||||
Args:
|
||||
repo_name: Repo 名稱
|
||||
file_path: 檔案路徑
|
||||
"""
|
||||
return self._request(
|
||||
"GET", f"/repos/{self.user}/{repo_name}/contents/{file_path}"
|
||||
)
|
||||
|
||||
def list_branches(self, repo_name: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出分支
|
||||
|
||||
Args:
|
||||
repo_name: Repo 名稱
|
||||
"""
|
||||
return self._request("GET", f"/repos/{self.user}/{repo_name}/branches")
|
||||
|
||||
def list_commits(
|
||||
self, repo_name: str, branch: str = "main", limit: int = 10
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出 Commits
|
||||
|
||||
Args:
|
||||
repo_name: Repo 名稱
|
||||
branch: 分支名稱
|
||||
limit: 數量限制
|
||||
"""
|
||||
return self._request(
|
||||
"GET", f"/repos/{self.user}/{repo_name}/commits?sha={branch}&limit={limit}"
|
||||
)
|
||||
|
||||
|
||||
# 單例
|
||||
gitea_service = GiteaService()
|
||||
|
||||
|
||||
def create_kpi_management_repo() -> Dict[str, Any]:
|
||||
"""
|
||||
建立 KPI Management Repo
|
||||
|
||||
Returns:
|
||||
建立結果
|
||||
"""
|
||||
result = gitea_service.create_repo(
|
||||
name="KPI-management",
|
||||
description="KPI 管理系統 - 員工績效考核管理平台",
|
||||
private=False,
|
||||
auto_init=True,
|
||||
default_branch="main",
|
||||
)
|
||||
return result
|
||||
408
app/services/kpi_service.py
Normal file
408
app/services/kpi_service.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
KPI 表單服務
|
||||
"""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.employee import Employee
|
||||
from app.models.kpi_period import KPIPeriod
|
||||
from app.models.kpi_sheet import KPISheet, KPISheetStatus, VALID_STATUS_TRANSITIONS
|
||||
from app.models.kpi_item import KPIItem
|
||||
from app.models.kpi_template import KPITemplate, KPIPreset
|
||||
from app.models.kpi_review_log import KPIReviewLog
|
||||
from app.schemas.kpi_sheet import KPISheetCreate, SelfEvalRequest, ManagerEvalRequest
|
||||
from app.schemas.kpi_item import KPIItemCreate
|
||||
|
||||
# 等級對應獎金月數
|
||||
LEVEL_MONTHS = {
|
||||
0: Decimal("0"),
|
||||
1: Decimal("0.25"),
|
||||
2: Decimal("0.5"),
|
||||
3: Decimal("0.75"),
|
||||
4: Decimal("1.0"),
|
||||
}
|
||||
|
||||
|
||||
class KPISheetService:
|
||||
"""KPI 表單服務"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# ==================== 查詢 ====================
|
||||
|
||||
def get_by_id(self, sheet_id: int) -> Optional[KPISheet]:
|
||||
"""根據 ID 取得表單"""
|
||||
return self.db.query(KPISheet).filter(KPISheet.id == sheet_id).first()
|
||||
|
||||
def get_by_employee_period(
|
||||
self, employee_id: int, period_id: int
|
||||
) -> Optional[KPISheet]:
|
||||
"""根據員工和期間取得表單"""
|
||||
return (
|
||||
self.db.query(KPISheet)
|
||||
.filter(
|
||||
KPISheet.employee_id == employee_id, KPISheet.period_id == period_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def get_multi(
|
||||
self,
|
||||
period_id: Optional[int] = None,
|
||||
department_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[KPISheet]:
|
||||
"""查詢多筆表單"""
|
||||
query = self.db.query(KPISheet)
|
||||
|
||||
if period_id:
|
||||
query = query.filter(KPISheet.period_id == period_id)
|
||||
if department_id:
|
||||
query = query.filter(KPISheet.department_id == department_id)
|
||||
if status:
|
||||
query = query.filter(KPISheet.status == status)
|
||||
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
def get_my_sheets(self, employee_id: int) -> List[KPISheet]:
|
||||
"""取得我的 KPI 表單"""
|
||||
return (
|
||||
self.db.query(KPISheet)
|
||||
.filter(KPISheet.employee_id == employee_id)
|
||||
.order_by(KPISheet.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_pending_for_manager(self, manager_id: int) -> List[KPISheet]:
|
||||
"""取得待主管審核的表單"""
|
||||
return (
|
||||
self.db.query(KPISheet)
|
||||
.join(Employee, KPISheet.employee_id == Employee.id)
|
||||
.filter(
|
||||
Employee.manager_id == manager_id,
|
||||
KPISheet.status == KPISheetStatus.PENDING,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# ==================== 建立 ====================
|
||||
|
||||
def create(self, employee: Employee, data: KPISheetCreate) -> KPISheet:
|
||||
"""建立 KPI 表單"""
|
||||
# 檢查期間是否在設定期間
|
||||
period = self.db.query(KPIPeriod).filter(KPIPeriod.id == data.period_id).first()
|
||||
if not period:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "PERIOD001", "message": "期間不存在"},
|
||||
)
|
||||
|
||||
if period.status != "setting":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "PERIOD001", "message": "目前不在 KPI 設定期間"},
|
||||
)
|
||||
|
||||
# 檢查是否已存在
|
||||
existing = self.get_by_employee_period(employee.id, data.period_id)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "KPI005", "message": "該期間已有 KPI 表單"},
|
||||
)
|
||||
|
||||
# 建立表單
|
||||
sheet = KPISheet(
|
||||
employee_id=employee.id,
|
||||
period_id=data.period_id,
|
||||
department_id=employee.department_id,
|
||||
status=KPISheetStatus.DRAFT,
|
||||
)
|
||||
self.db.add(sheet)
|
||||
self.db.flush()
|
||||
|
||||
# 建立項目
|
||||
for i, item_data in enumerate(data.items):
|
||||
item = KPIItem(
|
||||
sheet_id=sheet.id,
|
||||
template_id=item_data.template_id,
|
||||
sort_order=i,
|
||||
name=item_data.name,
|
||||
category=item_data.category,
|
||||
weight=item_data.weight,
|
||||
level0_criteria=item_data.level0_criteria,
|
||||
level1_criteria=item_data.level1_criteria,
|
||||
level2_criteria=item_data.level2_criteria,
|
||||
level3_criteria=item_data.level3_criteria,
|
||||
level4_criteria=item_data.level4_criteria,
|
||||
)
|
||||
self.db.add(item)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(sheet)
|
||||
return sheet
|
||||
|
||||
def copy_from_previous(self, employee: Employee, period_id: int) -> KPISheet:
|
||||
"""從上期複製 KPI"""
|
||||
# 找到上一期表單
|
||||
prev_sheet = (
|
||||
self.db.query(KPISheet)
|
||||
.filter(
|
||||
KPISheet.employee_id == employee.id,
|
||||
KPISheet.period_id != period_id,
|
||||
KPISheet.status == KPISheetStatus.COMPLETED,
|
||||
)
|
||||
.order_by(KPISheet.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not prev_sheet:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "KPI006", "message": "找不到上期 KPI 可供複製"},
|
||||
)
|
||||
|
||||
# 轉換為建立資料
|
||||
items = [
|
||||
KPIItemCreate(
|
||||
template_id=item.template_id,
|
||||
name=item.name,
|
||||
category=item.category,
|
||||
weight=item.weight,
|
||||
level0_criteria=item.level0_criteria,
|
||||
level1_criteria=item.level1_criteria,
|
||||
level2_criteria=item.level2_criteria,
|
||||
level3_criteria=item.level3_criteria,
|
||||
level4_criteria=item.level4_criteria,
|
||||
)
|
||||
for item in prev_sheet.items
|
||||
]
|
||||
|
||||
data = KPISheetCreate(period_id=period_id, items=items)
|
||||
return self.create(employee, data)
|
||||
|
||||
def apply_preset(
|
||||
self, employee: Employee, period_id: int, preset_id: int
|
||||
) -> KPISheet:
|
||||
"""套用預設組合"""
|
||||
preset = self.db.query(KPIPreset).filter(KPIPreset.id == preset_id).first()
|
||||
if not preset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "PRESET001", "message": "預設組合不存在"},
|
||||
)
|
||||
|
||||
# 轉換為建立資料
|
||||
items = []
|
||||
for preset_item in preset.items:
|
||||
template = preset_item.template
|
||||
items.append(
|
||||
KPIItemCreate(
|
||||
template_id=template.id,
|
||||
name=template.name,
|
||||
category=template.category,
|
||||
weight=preset_item.default_weight,
|
||||
level0_criteria=template.level0_desc,
|
||||
level1_criteria=template.level1_desc,
|
||||
level2_criteria=template.level2_desc,
|
||||
level3_criteria=template.level3_desc,
|
||||
level4_criteria=template.level4_desc,
|
||||
)
|
||||
)
|
||||
|
||||
data = KPISheetCreate(period_id=period_id, items=items)
|
||||
return self.create(employee, data)
|
||||
|
||||
# ==================== 驗證 ====================
|
||||
|
||||
def validate_weight(self, sheet_id: int) -> bool:
|
||||
"""驗證權重總和是否等於 100"""
|
||||
items = self.db.query(KPIItem).filter(KPIItem.sheet_id == sheet_id).all()
|
||||
total_weight = sum(item.weight for item in items)
|
||||
return total_weight == 100
|
||||
|
||||
def _check_status_transition(self, sheet: KPISheet, new_status: str) -> None:
|
||||
"""檢查狀態轉換是否合法"""
|
||||
if not sheet.can_transition_to(new_status):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "KPI003",
|
||||
"message": f"無法從 {sheet.status} 轉換到 {new_status}",
|
||||
},
|
||||
)
|
||||
|
||||
def _log_action(
|
||||
self,
|
||||
sheet: KPISheet,
|
||||
action: str,
|
||||
actor: Employee,
|
||||
from_status: str,
|
||||
to_status: str,
|
||||
comment: Optional[str] = None,
|
||||
) -> None:
|
||||
"""記錄審核動作"""
|
||||
log = KPIReviewLog(
|
||||
sheet_id=sheet.id,
|
||||
action=action,
|
||||
actor_id=actor.id,
|
||||
from_status=from_status,
|
||||
to_status=to_status,
|
||||
comment=comment,
|
||||
)
|
||||
self.db.add(log)
|
||||
|
||||
# ==================== 審核流程 ====================
|
||||
|
||||
def submit(self, sheet: KPISheet, actor: Employee) -> KPISheet:
|
||||
"""提交 KPI"""
|
||||
from_status = sheet.status
|
||||
self._check_status_transition(sheet, KPISheetStatus.PENDING)
|
||||
|
||||
# 檢查權重
|
||||
if not self.validate_weight(sheet.id):
|
||||
total = sum(item.weight for item in sheet.items)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "KPI001",
|
||||
"message": "權重總和必須等於 100%",
|
||||
"details": {"current_total": total, "expected": 100},
|
||||
},
|
||||
)
|
||||
|
||||
# 檢查項目數量
|
||||
if len(sheet.items) < 3:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "KPI002",
|
||||
"message": "KPI 項目數量不足,至少需要 3 項",
|
||||
},
|
||||
)
|
||||
|
||||
sheet.status = KPISheetStatus.PENDING
|
||||
sheet.submitted_at = datetime.utcnow()
|
||||
self._log_action(sheet, "submit", actor, from_status, sheet.status)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(sheet)
|
||||
return sheet
|
||||
|
||||
def approve(
|
||||
self, sheet: KPISheet, actor: Employee, comment: Optional[str] = None
|
||||
) -> KPISheet:
|
||||
"""審核通過"""
|
||||
from_status = sheet.status
|
||||
self._check_status_transition(sheet, KPISheetStatus.APPROVED)
|
||||
|
||||
sheet.status = KPISheetStatus.APPROVED
|
||||
sheet.approved_by = actor.id
|
||||
sheet.approved_at = datetime.utcnow()
|
||||
sheet.approve_comment = comment
|
||||
self._log_action(sheet, "approve", actor, from_status, sheet.status, comment)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(sheet)
|
||||
return sheet
|
||||
|
||||
def reject(self, sheet: KPISheet, actor: Employee, reason: str) -> KPISheet:
|
||||
"""退回"""
|
||||
from_status = sheet.status
|
||||
self._check_status_transition(sheet, KPISheetStatus.DRAFT)
|
||||
|
||||
sheet.status = KPISheetStatus.DRAFT
|
||||
sheet.rejected_by = actor.id
|
||||
sheet.rejected_at = datetime.utcnow()
|
||||
sheet.reject_reason = reason
|
||||
self._log_action(sheet, "reject", actor, from_status, sheet.status, reason)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(sheet)
|
||||
return sheet
|
||||
|
||||
# ==================== 評核 ====================
|
||||
|
||||
def self_eval(
|
||||
self, sheet: KPISheet, actor: Employee, data: SelfEvalRequest
|
||||
) -> KPISheet:
|
||||
"""員工自評"""
|
||||
from_status = sheet.status
|
||||
|
||||
# 如果是 approved 狀態,先轉換到 self_eval
|
||||
if sheet.status == KPISheetStatus.APPROVED:
|
||||
sheet.status = KPISheetStatus.SELF_EVAL
|
||||
|
||||
self._check_status_transition(sheet, KPISheetStatus.MANAGER_EVAL)
|
||||
|
||||
# 更新項目自評
|
||||
item_map = {item.id: item for item in sheet.items}
|
||||
for eval_item in data.items:
|
||||
item = item_map.get(eval_item.id)
|
||||
if item:
|
||||
item.self_eval_level = eval_item.level
|
||||
item.self_eval_note = eval_item.note
|
||||
|
||||
sheet.status = KPISheetStatus.MANAGER_EVAL
|
||||
sheet.self_eval_at = datetime.utcnow()
|
||||
self._log_action(sheet, "self_eval", actor, from_status, sheet.status)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(sheet)
|
||||
return sheet
|
||||
|
||||
def manager_eval(
|
||||
self, sheet: KPISheet, actor: Employee, data: ManagerEvalRequest
|
||||
) -> KPISheet:
|
||||
"""主管評核"""
|
||||
from_status = sheet.status
|
||||
self._check_status_transition(sheet, KPISheetStatus.COMPLETED)
|
||||
|
||||
# 更新項目評核
|
||||
item_map = {item.id: item for item in sheet.items}
|
||||
for eval_item in data.items:
|
||||
item = item_map.get(eval_item.id)
|
||||
if item:
|
||||
item.final_level = eval_item.level
|
||||
item.final_note = eval_item.note
|
||||
|
||||
# 計算總分
|
||||
total_score = Decimal("0")
|
||||
for item in sheet.items:
|
||||
if item.final_level is not None:
|
||||
weight_ratio = Decimal(str(item.weight)) / Decimal("100")
|
||||
level_month = LEVEL_MONTHS.get(item.final_level, Decimal("0"))
|
||||
total_score += weight_ratio * level_month
|
||||
|
||||
sheet.status = KPISheetStatus.COMPLETED
|
||||
sheet.manager_eval_by = actor.id
|
||||
sheet.manager_eval_at = datetime.utcnow()
|
||||
sheet.manager_eval_comment = data.comment
|
||||
sheet.total_score = total_score
|
||||
self._log_action(
|
||||
sheet, "manager_eval", actor, from_status, sheet.status, data.comment
|
||||
)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(sheet)
|
||||
return sheet
|
||||
|
||||
# ==================== 刪除 ====================
|
||||
|
||||
def delete(self, sheet: KPISheet) -> None:
|
||||
"""刪除表單(只能刪除草稿)"""
|
||||
if sheet.status != KPISheetStatus.DRAFT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "KPI007", "message": "只能刪除草稿狀態的表單"},
|
||||
)
|
||||
|
||||
self.db.delete(sheet)
|
||||
self.db.commit()
|
||||
173
app/services/llm_service.py
Normal file
173
app/services/llm_service.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Ollama LLM API 服務
|
||||
"""
|
||||
import json
|
||||
from typing import Optional, List, Generator
|
||||
|
||||
import requests
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""Ollama LLM API 服務"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = settings.OLLAMA_API_URL
|
||||
self.default_model = settings.OLLAMA_DEFAULT_MODEL
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""
|
||||
列出可用模型
|
||||
|
||||
Returns:
|
||||
模型 ID 列表
|
||||
"""
|
||||
try:
|
||||
response = requests.get(f"{self.api_url}/v1/models", timeout=10)
|
||||
response.raise_for_status()
|
||||
models = response.json()
|
||||
return [m["id"] for m in models.get("data", [])]
|
||||
except Exception as e:
|
||||
return [f"Error: {str(e)}"]
|
||||
|
||||
def chat(
|
||||
self,
|
||||
messages: List[dict],
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> str:
|
||||
"""
|
||||
聊天完成請求
|
||||
|
||||
Args:
|
||||
messages: 對話訊息列表 [{"role": "user", "content": "..."}]
|
||||
model: 模型名稱,預設使用設定檔中的模型
|
||||
temperature: 溫度參數 (0-1)
|
||||
max_tokens: 最大 token 數
|
||||
|
||||
Returns:
|
||||
AI 回應內容
|
||||
"""
|
||||
chat_request = {
|
||||
"model": model or self.default_model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
if max_tokens:
|
||||
chat_request["max_tokens"] = max_tokens
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.api_url}/v1/chat/completions",
|
||||
json=chat_request,
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result["choices"][0]["message"]["content"]
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
def chat_stream(
|
||||
self,
|
||||
messages: List[dict],
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
) -> Generator[str, None, None]:
|
||||
"""
|
||||
串流聊天請求
|
||||
|
||||
Args:
|
||||
messages: 對話訊息列表
|
||||
model: 模型名稱
|
||||
temperature: 溫度參數
|
||||
|
||||
Yields:
|
||||
AI 回應內容片段
|
||||
"""
|
||||
chat_request = {
|
||||
"model": model or self.default_model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.api_url}/v1/chat/completions",
|
||||
json=chat_request,
|
||||
stream=True,
|
||||
timeout=120,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
if line.startswith(b"data: "):
|
||||
data_str = line[6:].decode("utf-8")
|
||||
if data_str.strip() != "[DONE]":
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
if "choices" in data:
|
||||
delta = data["choices"][0].get("delta", {})
|
||||
if "content" in delta:
|
||||
yield delta["content"]
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
yield f"Error: {str(e)}"
|
||||
|
||||
def simple_ask(
|
||||
self,
|
||||
question: str,
|
||||
system_prompt: str = "You are a helpful assistant.",
|
||||
model: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
簡單問答
|
||||
|
||||
Args:
|
||||
question: 使用者問題
|
||||
system_prompt: 系統提示詞
|
||||
model: 模型名稱
|
||||
|
||||
Returns:
|
||||
AI 回應
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": question},
|
||||
]
|
||||
return self.chat(messages, model=model)
|
||||
|
||||
def analyze_kpi(self, kpi_data: dict) -> str:
|
||||
"""
|
||||
分析 KPI 數據
|
||||
|
||||
Args:
|
||||
kpi_data: KPI 相關數據
|
||||
|
||||
Returns:
|
||||
AI 分析結果
|
||||
"""
|
||||
system_prompt = """你是一位專業的 KPI 分析師。
|
||||
請根據提供的 KPI 數據,給出專業的分析和建議。
|
||||
回應請使用繁體中文,並保持專業且易懂。"""
|
||||
|
||||
question = f"""請分析以下 KPI 數據:
|
||||
|
||||
{json.dumps(kpi_data, ensure_ascii=False, indent=2)}
|
||||
|
||||
請提供:
|
||||
1. 數據摘要
|
||||
2. 表現評估
|
||||
3. 改善建議"""
|
||||
|
||||
return self.simple_ask(question, system_prompt)
|
||||
|
||||
|
||||
# 單例
|
||||
llm_service = LLMService()
|
||||
188
app/services/notify_service.py
Normal file
188
app/services/notify_service.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
通知服務
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.notification import Notification, NotificationPreference
|
||||
from app.models.employee import Employee
|
||||
from app.models.kpi_sheet import KPISheet
|
||||
|
||||
|
||||
class NotifyService:
|
||||
"""通知服務"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create(
|
||||
self,
|
||||
recipient_id: int,
|
||||
type: str,
|
||||
title: str,
|
||||
content: Optional[str] = None,
|
||||
related_sheet_id: Optional[int] = None,
|
||||
) -> Notification:
|
||||
"""建立通知"""
|
||||
notification = Notification(
|
||||
recipient_id=recipient_id,
|
||||
type=type,
|
||||
title=title,
|
||||
content=content,
|
||||
related_sheet_id=related_sheet_id,
|
||||
)
|
||||
self.db.add(notification)
|
||||
self.db.commit()
|
||||
self.db.refresh(notification)
|
||||
return notification
|
||||
|
||||
def get_by_recipient(
|
||||
self, recipient_id: int, skip: int = 0, limit: int = 50
|
||||
) -> List[Notification]:
|
||||
"""取得通知列表"""
|
||||
return (
|
||||
self.db.query(Notification)
|
||||
.filter(Notification.recipient_id == recipient_id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
def get_unread_count(self, recipient_id: int) -> int:
|
||||
"""取得未讀數量"""
|
||||
return (
|
||||
self.db.query(Notification)
|
||||
.filter(
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.is_read == False,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
def mark_as_read(self, notification_id: int, recipient_id: int) -> bool:
|
||||
"""標記已讀"""
|
||||
notification = (
|
||||
self.db.query(Notification)
|
||||
.filter(
|
||||
Notification.id == notification_id,
|
||||
Notification.recipient_id == recipient_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not notification:
|
||||
return False
|
||||
|
||||
notification.is_read = True
|
||||
notification.read_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def mark_all_as_read(self, recipient_id: int) -> int:
|
||||
"""全部標記已讀"""
|
||||
result = (
|
||||
self.db.query(Notification)
|
||||
.filter(
|
||||
Notification.recipient_id == recipient_id,
|
||||
Notification.is_read == False,
|
||||
)
|
||||
.update({"is_read": True, "read_at": datetime.utcnow()})
|
||||
)
|
||||
self.db.commit()
|
||||
return result
|
||||
|
||||
def get_preferences(self, employee_id: int) -> Optional[NotificationPreference]:
|
||||
"""取得通知偏好"""
|
||||
return (
|
||||
self.db.query(NotificationPreference)
|
||||
.filter(NotificationPreference.employee_id == employee_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def update_preferences(
|
||||
self,
|
||||
employee_id: int,
|
||||
email_enabled: Optional[bool] = None,
|
||||
in_app_enabled: Optional[bool] = None,
|
||||
reminder_days_before: Optional[int] = None,
|
||||
) -> NotificationPreference:
|
||||
"""更新通知偏好"""
|
||||
pref = self.get_preferences(employee_id)
|
||||
|
||||
if not pref:
|
||||
pref = NotificationPreference(employee_id=employee_id)
|
||||
self.db.add(pref)
|
||||
|
||||
if email_enabled is not None:
|
||||
pref.email_enabled = email_enabled
|
||||
if in_app_enabled is not None:
|
||||
pref.in_app_enabled = in_app_enabled
|
||||
if reminder_days_before is not None:
|
||||
pref.reminder_days_before = reminder_days_before
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(pref)
|
||||
return pref
|
||||
|
||||
# ==================== 事件通知 ====================
|
||||
|
||||
def notify_kpi_submitted(self, sheet: KPISheet) -> None:
|
||||
"""通知 KPI 已提交"""
|
||||
employee = sheet.employee
|
||||
if not employee.manager_id:
|
||||
return
|
||||
|
||||
self.create(
|
||||
recipient_id=employee.manager_id,
|
||||
type="kpi_submitted",
|
||||
title="KPI 待審核",
|
||||
content=f"{employee.name} 的 {sheet.period.code} KPI 已提交,請審核。",
|
||||
related_sheet_id=sheet.id,
|
||||
)
|
||||
|
||||
def notify_kpi_approved(self, sheet: KPISheet) -> None:
|
||||
"""通知 KPI 已核准"""
|
||||
self.create(
|
||||
recipient_id=sheet.employee_id,
|
||||
type="kpi_approved",
|
||||
title="KPI 已核准",
|
||||
content=f"您的 {sheet.period.code} KPI 已審核通過。",
|
||||
related_sheet_id=sheet.id,
|
||||
)
|
||||
|
||||
def notify_kpi_rejected(self, sheet: KPISheet, reason: str) -> None:
|
||||
"""通知 KPI 已退回"""
|
||||
self.create(
|
||||
recipient_id=sheet.employee_id,
|
||||
type="kpi_rejected",
|
||||
title="KPI 已退回",
|
||||
content=f"您的 {sheet.period.code} KPI 已退回,原因:{reason}",
|
||||
related_sheet_id=sheet.id,
|
||||
)
|
||||
|
||||
def notify_self_eval_completed(self, sheet: KPISheet) -> None:
|
||||
"""通知自評已完成"""
|
||||
employee = sheet.employee
|
||||
if not employee.manager_id:
|
||||
return
|
||||
|
||||
self.create(
|
||||
recipient_id=employee.manager_id,
|
||||
type="self_eval_completed",
|
||||
title="員工自評已完成",
|
||||
content=f"{employee.name} 的 {sheet.period.code} 自評已完成,請進行覆核。",
|
||||
related_sheet_id=sheet.id,
|
||||
)
|
||||
|
||||
def notify_manager_eval_completed(self, sheet: KPISheet) -> None:
|
||||
"""通知主管評核已完成"""
|
||||
self.create(
|
||||
recipient_id=sheet.employee_id,
|
||||
type="manager_eval_completed",
|
||||
title="KPI 評核已完成",
|
||||
content=f"您的 {sheet.period.code} KPI 評核已完成,獎金月數:{sheet.total_score}。",
|
||||
related_sheet_id=sheet.id,
|
||||
)
|
||||
Reference in New Issue
Block a user