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:
2025-12-11 16:20:57 +08:00
commit f810ddc2ea
48 changed files with 4950 additions and 0 deletions

12
app/services/__init__.py Normal file
View 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",
]

View 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

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

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