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

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# KPI Management System

18
app/api/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
"""
API Routers
"""
from app.api.auth import router as auth_router
from app.api.kpi import router as kpi_router
from app.api.dashboard import router as dashboard_router
from app.api.notifications import router as notifications_router
from app.api.llm import router as llm_router
from app.api.gitea import router as gitea_router
__all__ = [
"auth_router",
"kpi_router",
"dashboard_router",
"notifications_router",
"llm_router",
"gitea_router",
]

133
app/api/auth.py Normal file
View File

@@ -0,0 +1,133 @@
"""
認證 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.core.security import (
verify_password,
create_access_token,
create_refresh_token,
decode_token,
)
from app.models.employee import Employee
from app.schemas.auth import (
LoginRequest,
TokenResponse,
RefreshTokenRequest,
UserInfo,
)
router = APIRouter(prefix="/api/auth", tags=["認證"])
@router.post("/login", response_model=TokenResponse)
def login(data: LoginRequest, db: Session = Depends(get_db)):
"""
登入
使用員工編號和密碼登入,返回 JWT Token。
"""
# 查詢員工
employee = (
db.query(Employee).filter(Employee.employee_no == data.employee_no).first()
)
if not employee:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "員工編號或密碼錯誤"},
)
# 驗證密碼
if not verify_password(data.password, employee.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "員工編號或密碼錯誤"},
)
# 檢查帳號狀態
if employee.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH004", "message": "帳號已停用"},
)
# 產生 Token
access_token = create_access_token({"sub": str(employee.id)})
refresh_token = create_refresh_token({"sub": str(employee.id)})
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
)
@router.post("/refresh", response_model=TokenResponse)
def refresh_token(data: RefreshTokenRequest, db: Session = Depends(get_db)):
"""
更新 Token
使用 Refresh Token 取得新的 Access Token。
"""
payload = decode_token(data.refresh_token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH002", "message": "Token 過期或無效"},
)
if payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "Token 類型錯誤"},
)
user_id = payload.get("sub")
employee = db.query(Employee).filter(Employee.id == int(user_id)).first()
if not employee or employee.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "使用者不存在或已停用"},
)
# 產生新 Token
access_token = create_access_token({"sub": str(employee.id)})
new_refresh_token = create_refresh_token({"sub": str(employee.id)})
return TokenResponse(
access_token=access_token,
refresh_token=new_refresh_token,
)
@router.post("/logout")
def logout():
"""
登出
前端清除 Token 即可,後端不做處理。
"""
return {"message": "登出成功"}
@router.get("/me", response_model=UserInfo)
def get_me(current_user: Employee = Depends(get_current_user)):
"""
取得當前使用者資訊
"""
return UserInfo(
id=current_user.id,
employee_no=current_user.employee_no,
name=current_user.name,
email=current_user.email,
department_id=current_user.department_id,
department_name=current_user.department.name,
job_title=current_user.job_title,
role=current_user.role,
is_manager=current_user.is_manager,
is_admin=current_user.is_admin,
)

105
app/api/dashboard.py Normal file
View File

@@ -0,0 +1,105 @@
"""
儀表板 API
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user, get_current_manager
from app.models.employee import Employee
from app.services.dashboard_service import DashboardService
from app.schemas.dashboard import (
DashboardProgressResponse,
DashboardDistributionResponse,
DashboardTrendsResponse,
DashboardAlertResponse,
)
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/api/dashboard", tags=["儀表板"])
@router.get("/progress", response_model=DashboardProgressResponse)
def get_progress(
period_id: Optional[int] = Query(None),
department_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得進度統計
返回指定期間的 KPI 完成進度,包含各狀態的數量統計。
"""
service = DashboardService(db)
return service.get_progress(period_id, department_id)
@router.get("/distribution", response_model=DashboardDistributionResponse)
def get_distribution(
period_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得分佈統計
返回 KPI 的部門分佈、狀態分佈、分數區間分佈。
"""
service = DashboardService(db)
return service.get_distribution(period_id)
@router.get("/trends", response_model=DashboardTrendsResponse)
def get_trends(
limit: int = Query(4, ge=1, le=10),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得趨勢統計
返回歷史期間的平均分數趨勢。
"""
service = DashboardService(db)
return service.get_trends(limit)
@router.get("/alerts", response_model=List[DashboardAlertResponse])
def get_alerts(
is_resolved: Optional[bool] = Query(False),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""
取得儀表板警示
返回系統警示列表(主管以上可查看)。
"""
service = DashboardService(db)
return service.get_alerts(is_resolved, limit)
@router.put("/alerts/{alert_id}/resolve", response_model=DashboardAlertResponse)
def resolve_alert(
alert_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""
解決警示
將指定警示標記為已解決。
"""
service = DashboardService(db)
alert = service.resolve_alert(alert_id, current_user.id)
if not alert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "ALERT001", "message": "警示不存在"},
)
return alert

109
app/api/deps.py Normal file
View File

@@ -0,0 +1,109 @@
"""
API 依賴注入
"""
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.core.security import decode_token
from app.models.employee import Employee
# Bearer Token 安全機制
security = HTTPBearer()
def get_db() -> Generator:
"""取得資料庫 Session"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> Employee:
"""
取得當前使用者
從 Authorization header 解析 JWT Token
驗證並返回對應的員工物件。
"""
token = credentials.credentials
payload = decode_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "Token 無效"},
)
# 檢查 Token 類型
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "Token 類型錯誤"},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "Token 無效"},
)
user = db.query(Employee).filter(Employee.id == int(user_id)).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH001", "message": "使用者不存在"},
)
if user.status != "active":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "AUTH004", "message": "帳號已停用"},
)
return user
def get_current_manager(
current_user: Employee = Depends(get_current_user),
) -> Employee:
"""取得當前主管使用者"""
if not current_user.is_manager:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "權限不足,需要主管權限"},
)
return current_user
def get_current_admin(
current_user: Employee = Depends(get_current_user),
) -> Employee:
"""取得當前管理員使用者"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "權限不足,需要管理員權限"},
)
return current_user
def get_current_hr(
current_user: Employee = Depends(get_current_user),
) -> Employee:
"""取得當前人資使用者"""
if not current_user.is_hr and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "權限不足,需要人資權限"},
)
return current_user

166
app/api/gitea.py Normal file
View File

@@ -0,0 +1,166 @@
"""
Gitea API
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.deps import get_current_user, get_current_admin
from app.models.employee import Employee
from app.services.gitea_service import gitea_service, create_kpi_management_repo
router = APIRouter(prefix="/api/gitea", tags=["Gitea"])
class CreateRepoRequest(BaseModel):
"""建立 Repo 請求"""
name: str
description: Optional[str] = ""
private: bool = False
class CreateFileRequest(BaseModel):
"""建立檔案請求"""
repo_name: str
file_path: str
content: str
message: str = "Add file"
branch: str = "main"
@router.get("/user")
def get_user(current_user: Employee = Depends(get_current_admin)):
"""
取得 Gitea 使用者資訊
"""
return gitea_service.get_user()
@router.get("/repos")
def list_repos(current_user: Employee = Depends(get_current_admin)):
"""
列出所有 Repo
"""
repos = gitea_service.list_repos()
return {"repos": repos}
@router.get("/repos/{repo_name}")
def get_repo(
repo_name: str,
current_user: Employee = Depends(get_current_admin),
):
"""
取得 Repo 資訊
"""
return gitea_service.get_repo(repo_name)
@router.post("/repos")
def create_repo(
data: CreateRepoRequest,
current_user: Employee = Depends(get_current_admin),
):
"""
建立新的 Repo
"""
result = gitea_service.create_repo(
name=data.name,
description=data.description,
private=data.private,
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.delete("/repos/{repo_name}")
def delete_repo(
repo_name: str,
current_user: Employee = Depends(get_current_admin),
):
"""
刪除 Repo
"""
result = gitea_service.delete_repo(repo_name)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return {"message": f"Repo '{repo_name}' 已刪除"}
@router.post("/repos/kpi-management/init")
def init_kpi_repo(current_user: Employee = Depends(get_current_admin)):
"""
初始化 KPI Management Repo
"""
result = create_kpi_management_repo()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/repos/{repo_name}/branches")
def list_branches(
repo_name: str,
current_user: Employee = Depends(get_current_admin),
):
"""
列出分支
"""
return gitea_service.list_branches(repo_name)
@router.get("/repos/{repo_name}/commits")
def list_commits(
repo_name: str,
branch: str = "main",
limit: int = 10,
current_user: Employee = Depends(get_current_admin),
):
"""
列出 Commits
"""
return gitea_service.list_commits(repo_name, branch, limit)
@router.post("/files")
def create_file(
data: CreateFileRequest,
current_user: Employee = Depends(get_current_admin),
):
"""
建立檔案
"""
result = gitea_service.create_file(
repo_name=data.repo_name,
file_path=data.file_path,
content=data.content,
message=data.message,
branch=data.branch,
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/repos/{repo_name}/files/{file_path:path}")
def get_file(
repo_name: str,
file_path: str,
current_user: Employee = Depends(get_current_admin),
):
"""
取得檔案內容
"""
return gitea_service.get_file(repo_name, file_path)

358
app/api/kpi.py Normal file
View File

@@ -0,0 +1,358 @@
"""
KPI API
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user, get_current_manager
from app.models.employee import Employee
from app.models.kpi_sheet import KPISheet
from app.models.kpi_period import KPIPeriod
from app.models.kpi_template import KPITemplate, KPIPreset
from app.services.kpi_service import KPISheetService
from app.services.notify_service import NotifyService
from app.schemas.kpi_sheet import (
KPISheetCreate,
KPISheetResponse,
KPISheetListItem,
ApproveRequest,
RejectRequest,
SelfEvalRequest,
ManagerEvalRequest,
KPIPeriodResponse,
KPITemplateResponse,
KPIPresetResponse,
)
from app.schemas.common import PaginatedResponse, MessageResponse
router = APIRouter(prefix="/api/kpi", tags=["KPI"])
# ==================== KPI 表單 ====================
@router.get("/sheets", response_model=List[KPISheetListItem])
def list_sheets(
period_id: Optional[int] = Query(None),
department_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""查詢 KPI 表單清單"""
service = KPISheetService(db)
sheets = service.get_multi(
period_id=period_id,
department_id=department_id,
status=status,
skip=skip,
limit=limit,
)
return sheets
@router.get("/sheets/my", response_model=List[KPISheetListItem])
def get_my_sheets(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""取得我的 KPI 表單"""
service = KPISheetService(db)
return service.get_my_sheets(current_user.id)
@router.get("/sheets/pending", response_model=List[KPISheetListItem])
def get_pending_sheets(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""取得待審核的 KPI 表單(主管用)"""
service = KPISheetService(db)
return service.get_pending_for_manager(current_user.id)
@router.post("/sheets", response_model=KPISheetResponse, status_code=status.HTTP_201_CREATED)
def create_sheet(
data: KPISheetCreate,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""建立 KPI 表單"""
service = KPISheetService(db)
return service.create(current_user, data)
@router.post("/sheets/copy-from-previous", response_model=KPISheetResponse, status_code=status.HTTP_201_CREATED)
def copy_from_previous(
period_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""從上期複製 KPI"""
service = KPISheetService(db)
return service.copy_from_previous(current_user, period_id)
@router.post("/sheets/apply-preset/{preset_id}", response_model=KPISheetResponse, status_code=status.HTTP_201_CREATED)
def apply_preset(
preset_id: int,
period_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""套用預設組合"""
service = KPISheetService(db)
return service.apply_preset(current_user, period_id, preset_id)
@router.get("/sheets/{sheet_id}", response_model=KPISheetResponse)
def get_sheet(
sheet_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""取得單一 KPI 表單"""
service = KPISheetService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
return sheet
@router.delete("/sheets/{sheet_id}", response_model=MessageResponse)
def delete_sheet(
sheet_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""刪除 KPI 表單"""
service = KPISheetService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
if sheet.employee_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "權限不足"},
)
service.delete(sheet)
return MessageResponse(message="刪除成功")
# ==================== 審核流程 ====================
@router.post("/sheets/{sheet_id}/submit", response_model=KPISheetResponse)
def submit_sheet(
sheet_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""提交 KPI 審核"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
if sheet.employee_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能提交自己的 KPI"},
)
result = service.submit(sheet, current_user)
notify_service.notify_kpi_submitted(result)
return result
@router.post("/sheets/{sheet_id}/approve", response_model=KPISheetResponse)
def approve_sheet(
sheet_id: int,
data: ApproveRequest,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""審核通過"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
# 檢查是否為該員工的主管
if sheet.employee.manager_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能審核直屬部屬的 KPI"},
)
result = service.approve(sheet, current_user, data.comment)
notify_service.notify_kpi_approved(result)
return result
@router.post("/sheets/{sheet_id}/reject", response_model=KPISheetResponse)
def reject_sheet(
sheet_id: int,
data: RejectRequest,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""退回"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
# 檢查是否為該員工的主管
if sheet.employee.manager_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能審核直屬部屬的 KPI"},
)
result = service.reject(sheet, current_user, data.reason)
notify_service.notify_kpi_rejected(result, data.reason)
return result
# ==================== 評核 ====================
@router.post("/sheets/{sheet_id}/self-eval", response_model=KPISheetResponse)
def self_eval(
sheet_id: int,
data: SelfEvalRequest,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""員工自評"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
if sheet.employee_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能自評自己的 KPI"},
)
result = service.self_eval(sheet, current_user, data)
notify_service.notify_self_eval_completed(result)
return result
@router.post("/sheets/{sheet_id}/manager-eval", response_model=KPISheetResponse)
def manager_eval(
sheet_id: int,
data: ManagerEvalRequest,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_manager),
):
"""主管評核"""
service = KPISheetService(db)
notify_service = NotifyService(db)
sheet = service.get_by_id(sheet_id)
if not sheet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "KPI004", "message": "KPI 表單不存在"},
)
# 檢查是否為該員工的主管
if sheet.employee.manager_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "AUTH003", "message": "只能評核直屬部屬的 KPI"},
)
result = service.manager_eval(sheet, current_user, data)
notify_service.notify_manager_eval_completed(result)
return result
# ==================== 期間 ====================
@router.get("/periods", response_model=List[KPIPeriodResponse])
def list_periods(db: Session = Depends(get_db)):
"""取得 KPI 期間列表"""
periods = db.query(KPIPeriod).order_by(KPIPeriod.start_date.desc()).all()
return periods
@router.get("/periods/current", response_model=KPIPeriodResponse)
def get_current_period(db: Session = Depends(get_db)):
"""取得當前 KPI 期間"""
period = (
db.query(KPIPeriod)
.filter(KPIPeriod.status.in_(["setting", "self_eval", "manager_eval"]))
.order_by(KPIPeriod.start_date.desc())
.first()
)
if not period:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "PERIOD002", "message": "目前沒有進行中的 KPI 期間"},
)
return period
# ==================== 範本 ====================
@router.get("/templates", response_model=List[KPITemplateResponse])
def list_templates(
category: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""取得 KPI 範本列表"""
query = db.query(KPITemplate).filter(KPITemplate.is_active == True)
if category:
query = query.filter(KPITemplate.category == category)
return query.all()
@router.get("/presets", response_model=List[KPIPresetResponse])
def list_presets(db: Session = Depends(get_db)):
"""取得 KPI 預設組合列表"""
presets = db.query(KPIPreset).filter(KPIPreset.is_active == True).all()
return presets

140
app/api/llm.py Normal file
View File

@@ -0,0 +1,140 @@
"""
LLM API
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.api.deps import get_current_user
from app.models.employee import Employee
from app.services.llm_service import llm_service
router = APIRouter(prefix="/api/llm", tags=["LLM"])
class ChatMessage(BaseModel):
"""聊天訊息"""
role: str # system, user, assistant
content: str
class ChatRequest(BaseModel):
"""聊天請求"""
messages: List[ChatMessage]
model: Optional[str] = None
temperature: float = 0.7
stream: bool = False
class ChatResponse(BaseModel):
"""聊天回應"""
content: str
model: str
class SimpleAskRequest(BaseModel):
"""簡單問答請求"""
question: str
system_prompt: Optional[str] = "You are a helpful assistant."
model: Optional[str] = None
class KPIAnalyzeRequest(BaseModel):
"""KPI 分析請求"""
kpi_data: dict
@router.get("/models")
def list_models(current_user: Employee = Depends(get_current_user)):
"""
列出可用的 LLM 模型
"""
models = llm_service.list_models()
return {"models": models}
@router.post("/chat", response_model=ChatResponse)
def chat(
data: ChatRequest,
current_user: Employee = Depends(get_current_user),
):
"""
聊天完成請求
"""
if data.stream:
raise HTTPException(
status_code=400,
detail="請使用 /chat/stream 端點進行串流請求",
)
messages = [{"role": m.role, "content": m.content} for m in data.messages]
content = llm_service.chat(
messages=messages,
model=data.model,
temperature=data.temperature,
)
return ChatResponse(
content=content,
model=data.model or llm_service.default_model,
)
@router.post("/chat/stream")
async def chat_stream(
data: ChatRequest,
current_user: Employee = Depends(get_current_user),
):
"""
串流聊天請求
"""
messages = [{"role": m.role, "content": m.content} for m in data.messages]
def generate():
for chunk in llm_service.chat_stream(
messages=messages,
model=data.model,
temperature=data.temperature,
):
yield f"data: {chunk}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
)
@router.post("/ask")
def simple_ask(
data: SimpleAskRequest,
current_user: Employee = Depends(get_current_user),
):
"""
簡單問答
"""
response = llm_service.simple_ask(
question=data.question,
system_prompt=data.system_prompt,
model=data.model,
)
return {"answer": response}
@router.post("/analyze-kpi")
def analyze_kpi(
data: KPIAnalyzeRequest,
current_user: Employee = Depends(get_current_user),
):
"""
AI 分析 KPI 數據
"""
analysis = llm_service.analyze_kpi(data.kpi_data)
return {"analysis": analysis}

133
app/api/notifications.py Normal file
View File

@@ -0,0 +1,133 @@
"""
通知 API
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.models.employee import Employee
from app.services.notify_service import NotifyService
from app.schemas.notification import (
NotificationResponse,
NotificationPreferenceResponse,
NotificationPreferenceUpdate,
UnreadCountResponse,
)
from app.schemas.common import MessageResponse
router = APIRouter(prefix="/api/notifications", tags=["通知"])
@router.get("", response_model=List[NotificationResponse])
def list_notifications(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得通知列表
返回當前使用者的通知,按時間倒序排列。
"""
service = NotifyService(db)
return service.get_by_recipient(current_user.id, skip, limit)
@router.get("/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得未讀數量
返回當前使用者的未讀通知數量。
"""
service = NotifyService(db)
count = service.get_unread_count(current_user.id)
return UnreadCountResponse(count=count)
@router.put("/{notification_id}/read", response_model=MessageResponse)
def mark_as_read(
notification_id: int,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
標記已讀
將指定通知標記為已讀。
"""
service = NotifyService(db)
success = service.mark_as_read(notification_id, current_user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOTIFY001", "message": "通知不存在"},
)
return MessageResponse(message="已標記為已讀")
@router.put("/read-all", response_model=MessageResponse)
def mark_all_as_read(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
全部標記已讀
將當前使用者的所有通知標記為已讀。
"""
service = NotifyService(db)
count = service.mark_all_as_read(current_user.id)
return MessageResponse(message=f"已將 {count} 則通知標記為已讀")
@router.get("/preferences", response_model=NotificationPreferenceResponse)
def get_preferences(
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
取得通知偏好
返回當前使用者的通知偏好設定。
"""
service = NotifyService(db)
pref = service.get_preferences(current_user.id)
if not pref:
# 返回預設值
return NotificationPreferenceResponse(
email_enabled=True,
in_app_enabled=True,
reminder_days_before=3,
)
return pref
@router.put("/preferences", response_model=NotificationPreferenceResponse)
def update_preferences(
data: NotificationPreferenceUpdate,
db: Session = Depends(get_db),
current_user: Employee = Depends(get_current_user),
):
"""
更新通知偏好
更新當前使用者的通知偏好設定。
"""
service = NotifyService(db)
return service.update_preferences(
current_user.id,
email_enabled=data.email_enabled,
in_app_enabled=data.in_app_enabled,
reminder_days_before=data.reminder_days_before,
)

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Core module

52
app/core/config.py Normal file
View File

@@ -0,0 +1,52 @@
"""
應用程式設定
"""
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""應用程式設定"""
# App
APP_NAME: str = "KPI Management System"
DEBUG: bool = True
# Database (MySQL)
DB_HOST: str = "mysql.theaken.com"
DB_PORT: int = 33306
DB_NAME: str = "db_A102"
DB_USER: str = "A102"
DB_PASSWORD: str = "Bb123456"
@property
def DATABASE_URL(self) -> str:
return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
# JWT
SECRET_KEY: str = "your-super-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Ollama LLM API
OLLAMA_API_URL: str = "https://ollama_pjapi.theaken.com"
OLLAMA_DEFAULT_MODEL: str = "qwen2.5:3b"
# Gitea
GITEA_URL: str = "https://gitea.theaken.com"
GITEA_USER: str = "donald"
GITEA_TOKEN: str = "9e0a888d1a25bde9cf2ad5dff2bb7ee6d68d6ff0"
class Config:
env_file = ".env"
case_sensitive = True
@lru_cache
def get_settings() -> Settings:
"""取得設定單例"""
return Settings()
settings = get_settings()

39
app/core/database.py Normal file
View File

@@ -0,0 +1,39 @@
"""
資料庫連線設定 (MySQL)
"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import settings
# 建立資料庫引擎 (MySQL)
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
pool_recycle=3600, # MySQL 連線回收
)
# 建立 Session 工廠
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 建立 Base 類別
Base = declarative_base()
def get_db():
"""
取得資料庫 Session
用於 FastAPI 依賴注入
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""初始化資料庫(建立所有表)"""
Base.metadata.create_all(bind=engine)

83
app/core/security.py Normal file
View File

@@ -0,0 +1,83 @@
"""
安全模組JWT 認證與密碼雜湊
"""
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
# 密碼雜湊上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""驗證密碼"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""產生密碼雜湊"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
建立 Access Token
Args:
data: Token 內容 (通常包含 sub: user_id)
expires_delta: 過期時間
Returns:
JWT Token 字串
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
"""
建立 Refresh Token
Args:
data: Token 內容
Returns:
JWT Token 字串
"""
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[dict]:
"""
解碼 Token
Args:
token: JWT Token 字串
Returns:
Token 內容或 None (解碼失敗)
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
return None

97
app/main.py Normal file
View File

@@ -0,0 +1,97 @@
"""
KPI 管理系統 - FastAPI 主程式
"""
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.core.database import init_db
from app.api import (
auth_router,
kpi_router,
dashboard_router,
notifications_router,
llm_router,
gitea_router,
)
# 建立 FastAPI 應用程式
app = FastAPI(
title=settings.APP_NAME,
description="KPI 管理系統 API - 整合 Ollama LLM 與 Gitea 版本控制",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
# CORS 設定
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生產環境應限制
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 全域例外處理
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""全域例外處理器"""
return JSONResponse(
status_code=500,
content={
"error": {
"code": "INTERNAL_ERROR",
"message": "內部伺服器錯誤",
"details": str(exc) if settings.DEBUG else None,
}
},
)
# 註冊路由
app.include_router(auth_router)
app.include_router(kpi_router)
app.include_router(dashboard_router)
app.include_router(notifications_router)
app.include_router(llm_router)
app.include_router(gitea_router)
# 啟動事件
@app.on_event("startup")
async def startup_event():
"""應用程式啟動時初始化資料庫"""
init_db()
# 健康檢查
@app.get("/health")
def health_check():
"""健康檢查端點"""
return {"status": "healthy", "app": settings.APP_NAME}
# 根路徑
@app.get("/")
def root():
"""API 根路徑"""
return {
"app": settings.APP_NAME,
"version": "1.0.0",
"docs": "/docs",
"features": [
"KPI 管理",
"員工績效考核",
"Ollama LLM 整合",
"Gitea 版本控制",
],
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

27
app/models/__init__.py Normal file
View File

@@ -0,0 +1,27 @@
"""
SQLAlchemy Models
"""
from app.models.department import Department
from app.models.employee import Employee
from app.models.kpi_period import KPIPeriod
from app.models.kpi_template import KPITemplate, KPIPreset, KPIPresetItem
from app.models.kpi_sheet import KPISheet
from app.models.kpi_item import KPIItem
from app.models.kpi_review_log import KPIReviewLog
from app.models.notification import Notification, NotificationPreference
from app.models.dashboard_alert import DashboardAlert
__all__ = [
"Department",
"Employee",
"KPIPeriod",
"KPITemplate",
"KPIPreset",
"KPIPresetItem",
"KPISheet",
"KPIItem",
"KPIReviewLog",
"Notification",
"NotificationPreference",
"DashboardAlert",
]

View File

@@ -0,0 +1,41 @@
"""
儀表板警示 Model
"""
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Integer, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.employee import Employee
from app.models.kpi_sheet import KPISheet
class DashboardAlert(Base):
"""儀表板警示"""
__tablename__ = "KPI_D_alerts"
id: Mapped[int] = mapped_column(primary_key=True)
alert_type: Mapped[str] = mapped_column(String(50), nullable=False)
# deadline_approaching, overdue, weight_invalid
severity: Mapped[str] = mapped_column(String(20), default="warning") # info, warning, error
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
related_sheet_id: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_sheets.id", ondelete="CASCADE"))
related_employee_id: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_employees.id"))
is_resolved: Mapped[bool] = mapped_column(Boolean, default=False)
resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
resolved_by: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_employees.id"))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
related_sheet: Mapped[Optional["KPISheet"]] = relationship("KPISheet")
related_employee: Mapped[Optional["Employee"]] = relationship("Employee", foreign_keys=[related_employee_id])
resolver: Mapped[Optional["Employee"]] = relationship("Employee", foreign_keys=[resolved_by])
def __repr__(self) -> str:
return f"<DashboardAlert id={self.id} type={self.alert_type} severity={self.severity}>"

42
app/models/department.py Normal file
View File

@@ -0,0 +1,42 @@
"""
部門 Model
"""
from datetime import datetime
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.employee import Employee
class Department(Base):
"""部門"""
__tablename__ = "KPI_D_departments"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
level: Mapped[str] = mapped_column(String(20), default="DEPT") # COMPANY, BU, DEPT, TEAM
parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_departments.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
parent: Mapped[Optional["Department"]] = relationship(
"Department", remote_side=[id], back_populates="children"
)
children: Mapped[List["Department"]] = relationship(
"Department", back_populates="parent"
)
employees: Mapped[List["Employee"]] = relationship(
"Employee", back_populates="department"
)
def __repr__(self) -> str:
return f"<Department {self.code}: {self.name}>"

68
app/models/employee.py Normal file
View File

@@ -0,0 +1,68 @@
"""
員工 Model
"""
from datetime import datetime, date
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import String, Integer, Boolean, DateTime, Date, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.department import Department
from app.models.kpi_sheet import KPISheet
from app.models.notification import Notification
class Employee(Base):
"""員工"""
__tablename__ = "KPI_D_employees"
id: Mapped[int] = mapped_column(primary_key=True)
employee_no: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(200), nullable=False)
department_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_departments.id"), nullable=False)
manager_id: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_employees.id"))
job_title: Mapped[Optional[str]] = mapped_column(String(100))
role: Mapped[str] = mapped_column(String(20), default="employee") # employee, manager, admin, hr
status: Mapped[str] = mapped_column(String(20), default="active") # active, inactive, resigned
hire_date: Mapped[Optional[date]] = mapped_column(Date)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
department: Mapped["Department"] = relationship("Department", back_populates="employees")
manager: Mapped[Optional["Employee"]] = relationship(
"Employee", remote_side=[id], back_populates="subordinates"
)
subordinates: Mapped[List["Employee"]] = relationship(
"Employee", back_populates="manager"
)
kpi_sheets: Mapped[List["KPISheet"]] = relationship(
"KPISheet", back_populates="employee", foreign_keys="KPISheet.employee_id"
)
notifications: Mapped[List["Notification"]] = relationship(
"Notification", back_populates="recipient"
)
@property
def is_manager(self) -> bool:
"""是否為主管"""
return self.role in ("manager", "admin")
@property
def is_admin(self) -> bool:
"""是否為管理員"""
return self.role == "admin"
@property
def is_hr(self) -> bool:
"""是否為人資"""
return self.role == "hr"
def __repr__(self) -> str:
return f"<Employee {self.employee_no}: {self.name}>"

55
app/models/kpi_item.py Normal file
View File

@@ -0,0 +1,55 @@
"""
KPI 項目 Model
"""
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Integer, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.kpi_sheet import KPISheet
from app.models.kpi_template import KPITemplate
class KPIItem(Base):
"""KPI 項目"""
__tablename__ = "KPI_D_items"
id: Mapped[int] = mapped_column(primary_key=True)
sheet_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_sheets.id", ondelete="CASCADE"), nullable=False)
template_id: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_templates.id"))
sort_order: Mapped[int] = mapped_column(Integer, default=0)
# 項目資訊
name: Mapped[str] = mapped_column(String(200), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False) # financial, customer, internal, learning
weight: Mapped[int] = mapped_column(Integer, nullable=False) # 權重百分比 (1-100)
# 等級標準
level0_criteria: Mapped[Optional[str]] = mapped_column(Text)
level1_criteria: Mapped[Optional[str]] = mapped_column(Text)
level2_criteria: Mapped[Optional[str]] = mapped_column(Text)
level3_criteria: Mapped[Optional[str]] = mapped_column(Text)
level4_criteria: Mapped[Optional[str]] = mapped_column(Text)
# 自評
self_eval_level: Mapped[Optional[int]] = mapped_column(Integer) # 0-4
self_eval_note: Mapped[Optional[str]] = mapped_column(Text)
# 主管評核
final_level: Mapped[Optional[int]] = mapped_column(Integer) # 0-4
final_note: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
sheet: Mapped["KPISheet"] = relationship("KPISheet", back_populates="items")
template: Mapped[Optional["KPITemplate"]] = relationship("KPITemplate", back_populates="kpi_items")
def __repr__(self) -> str:
return f"<KPIItem id={self.id} name={self.name} weight={self.weight}>"

63
app/models/kpi_period.py Normal file
View File

@@ -0,0 +1,63 @@
"""
KPI 期間 Model
"""
from datetime import datetime, date
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import String, DateTime, Date
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.kpi_sheet import KPISheet
class KPIPeriod(Base):
"""KPI 期間"""
__tablename__ = "KPI_D_periods"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) # 例: 2024H1
name: Mapped[str] = mapped_column(String(100), nullable=False)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
setting_start: Mapped[date] = mapped_column(Date, nullable=False)
setting_end: Mapped[date] = mapped_column(Date, nullable=False)
self_eval_start: Mapped[Optional[date]] = mapped_column(Date)
self_eval_end: Mapped[Optional[date]] = mapped_column(Date)
manager_eval_start: Mapped[Optional[date]] = mapped_column(Date)
manager_eval_end: Mapped[Optional[date]] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(20), default="draft")
# draft, setting, approved, self_eval, manager_eval, completed
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kpi_sheets: Mapped[List["KPISheet"]] = relationship("KPISheet", back_populates="period")
@property
def is_setting_period(self) -> bool:
"""是否在設定期間"""
today = date.today()
return self.setting_start <= today <= self.setting_end
@property
def is_self_eval_period(self) -> bool:
"""是否在自評期間"""
if not self.self_eval_start or not self.self_eval_end:
return False
today = date.today()
return self.self_eval_start <= today <= self.self_eval_end
@property
def is_manager_eval_period(self) -> bool:
"""是否在主管評核期間"""
if not self.manager_eval_start or not self.manager_eval_end:
return False
today = date.today()
return self.manager_eval_start <= today <= self.manager_eval_end
def __repr__(self) -> str:
return f"<KPIPeriod {self.code}: {self.name}>"

View File

@@ -0,0 +1,36 @@
"""
KPI 審核紀錄 Model
"""
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Integer, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.kpi_sheet import KPISheet
from app.models.employee import Employee
class KPIReviewLog(Base):
"""KPI 審核紀錄"""
__tablename__ = "KPI_D_review_logs"
id: Mapped[int] = mapped_column(primary_key=True)
sheet_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_sheets.id", ondelete="CASCADE"), nullable=False)
action: Mapped[str] = mapped_column(String(50), nullable=False) # submit, approve, reject, self_eval, manager_eval
actor_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_employees.id"), nullable=False)
from_status: Mapped[Optional[str]] = mapped_column(String(20))
to_status: Mapped[Optional[str]] = mapped_column(String(20))
comment: Mapped[Optional[str]] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
sheet: Mapped["KPISheet"] = relationship("KPISheet", back_populates="review_logs")
actor: Mapped["Employee"] = relationship("Employee")
def __repr__(self) -> str:
return f"<KPIReviewLog id={self.id} action={self.action} sheet={self.sheet_id}>"

103
app/models/kpi_sheet.py Normal file
View File

@@ -0,0 +1,103 @@
"""
KPI 表單 Model
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import String, Integer, DateTime, Text, Numeric, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.employee import Employee
from app.models.kpi_period import KPIPeriod
from app.models.department import Department
from app.models.kpi_item import KPIItem
from app.models.kpi_review_log import KPIReviewLog
# KPI 表單狀態
class KPISheetStatus:
DRAFT = "draft" # 草稿
PENDING = "pending" # 待審核
APPROVED = "approved" # 已核准
SELF_EVAL = "self_eval" # 自評中
MANAGER_EVAL = "manager_eval" # 主管評核中
COMPLETED = "completed" # 已完成
SETTLED = "settled" # 已結算
# 狀態轉換規則
VALID_STATUS_TRANSITIONS = {
KPISheetStatus.DRAFT: [KPISheetStatus.PENDING],
KPISheetStatus.PENDING: [KPISheetStatus.APPROVED, KPISheetStatus.DRAFT],
KPISheetStatus.APPROVED: [KPISheetStatus.SELF_EVAL],
KPISheetStatus.SELF_EVAL: [KPISheetStatus.MANAGER_EVAL],
KPISheetStatus.MANAGER_EVAL: [KPISheetStatus.COMPLETED],
KPISheetStatus.COMPLETED: [KPISheetStatus.SETTLED],
KPISheetStatus.SETTLED: [],
}
class KPISheet(Base):
"""KPI 表單"""
__tablename__ = "KPI_D_sheets"
id: Mapped[int] = mapped_column(primary_key=True)
employee_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_employees.id"), nullable=False)
period_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_periods.id"), nullable=False)
department_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_departments.id"), nullable=False)
status: Mapped[str] = mapped_column(String(20), default=KPISheetStatus.DRAFT)
# 提交資訊
submitted_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
# 審核資訊
approved_by: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_employees.id"))
approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
approve_comment: Mapped[Optional[str]] = mapped_column(Text)
# 退回資訊
rejected_by: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_employees.id"))
rejected_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
reject_reason: Mapped[Optional[str]] = mapped_column(Text)
# 自評資訊
self_eval_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
# 主管評核資訊
manager_eval_by: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_employees.id"))
manager_eval_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
manager_eval_comment: Mapped[Optional[str]] = mapped_column(Text)
# 分數
total_score: Mapped[Optional[Decimal]] = mapped_column(Numeric(5, 4))
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
employee: Mapped["Employee"] = relationship(
"Employee", back_populates="kpi_sheets", foreign_keys=[employee_id]
)
period: Mapped["KPIPeriod"] = relationship("KPIPeriod", back_populates="kpi_sheets")
department: Mapped["Department"] = relationship("Department")
items: Mapped[List["KPIItem"]] = relationship(
"KPIItem", back_populates="sheet", cascade="all, delete-orphan"
)
review_logs: Mapped[List["KPIReviewLog"]] = relationship(
"KPIReviewLog", back_populates="sheet", cascade="all, delete-orphan"
)
approver: Mapped[Optional["Employee"]] = relationship("Employee", foreign_keys=[approved_by])
rejecter: Mapped[Optional["Employee"]] = relationship("Employee", foreign_keys=[rejected_by])
manager_evaluator: Mapped[Optional["Employee"]] = relationship("Employee", foreign_keys=[manager_eval_by])
def can_transition_to(self, new_status: str) -> bool:
"""檢查是否可以轉換到指定狀態"""
return new_status in VALID_STATUS_TRANSITIONS.get(self.status, [])
def __repr__(self) -> str:
return f"<KPISheet id={self.id} employee={self.employee_id} period={self.period_id} status={self.status}>"

View File

@@ -0,0 +1,88 @@
"""
KPI 範本 Model
"""
from datetime import datetime
from typing import Optional, List, TYPE_CHECKING
from sqlalchemy import String, Integer, Boolean, DateTime, Text, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.kpi_item import KPIItem
class KPITemplate(Base):
"""KPI 範本"""
__tablename__ = "KPI_D_templates"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False) # financial, customer, internal, learning
description: Mapped[Optional[str]] = mapped_column(Text)
default_weight: Mapped[int] = mapped_column(Integer, default=20)
level0_desc: Mapped[str] = mapped_column(Text, nullable=False)
level1_desc: Mapped[str] = mapped_column(Text, nullable=False)
level2_desc: Mapped[str] = mapped_column(Text, nullable=False)
level3_desc: Mapped[str] = mapped_column(Text, nullable=False)
level4_desc: Mapped[str] = mapped_column(Text, nullable=False)
applicable_roles: Mapped[Optional[str]] = mapped_column(JSON) # JSON array for MySQL
applicable_depts: Mapped[Optional[str]] = mapped_column(JSON) # JSON array for MySQL
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
kpi_items: Mapped[List["KPIItem"]] = relationship("KPIItem", back_populates="template")
preset_items: Mapped[List["KPIPresetItem"]] = relationship("KPIPresetItem", back_populates="template")
def __repr__(self) -> str:
return f"<KPITemplate {self.code}: {self.name}>"
class KPIPreset(Base):
"""KPI 預設組合"""
__tablename__ = "KPI_D_presets"
id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
applicable_roles: Mapped[Optional[str]] = mapped_column(JSON) # JSON array for MySQL
applicable_depts: Mapped[Optional[str]] = mapped_column(JSON) # JSON array for MySQL
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
items: Mapped[List["KPIPresetItem"]] = relationship(
"KPIPresetItem", back_populates="preset", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<KPIPreset {self.code}: {self.name}>"
class KPIPresetItem(Base):
"""KPI 預設項目"""
__tablename__ = "KPI_D_preset_items"
id: Mapped[int] = mapped_column(primary_key=True)
preset_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_presets.id", ondelete="CASCADE"), nullable=False)
template_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_templates.id"), nullable=False)
default_weight: Mapped[int] = mapped_column(Integer, nullable=False)
is_mandatory: Mapped[bool] = mapped_column(Boolean, default=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
preset: Mapped["KPIPreset"] = relationship("KPIPreset", back_populates="items")
template: Mapped["KPITemplate"] = relationship("KPITemplate", back_populates="preset_items")
def __repr__(self) -> str:
return f"<KPIPresetItem preset={self.preset_id} template={self.template_id}>"

View File

@@ -0,0 +1,58 @@
"""
通知 Model
"""
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Integer, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
if TYPE_CHECKING:
from app.models.employee import Employee
from app.models.kpi_sheet import KPISheet
class Notification(Base):
"""通知"""
__tablename__ = "KPI_D_notifications"
id: Mapped[int] = mapped_column(primary_key=True)
recipient_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_employees.id"), nullable=False)
type: Mapped[str] = mapped_column(String(50), nullable=False)
# kpi_submitted, kpi_approved, kpi_rejected, eval_reminder, etc.
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[Optional[str]] = mapped_column(Text)
related_sheet_id: Mapped[Optional[int]] = mapped_column(ForeignKey("KPI_D_sheets.id", ondelete="SET NULL"))
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
recipient: Mapped["Employee"] = relationship("Employee", back_populates="notifications")
related_sheet: Mapped[Optional["KPISheet"]] = relationship("KPISheet")
def __repr__(self) -> str:
return f"<Notification id={self.id} type={self.type} recipient={self.recipient_id}>"
class NotificationPreference(Base):
"""通知偏好"""
__tablename__ = "KPI_D_notification_preferences"
id: Mapped[int] = mapped_column(primary_key=True)
employee_id: Mapped[int] = mapped_column(ForeignKey("KPI_D_employees.id"), unique=True, nullable=False)
email_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
in_app_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
reminder_days_before: Mapped[int] = mapped_column(Integer, default=3)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
employee: Mapped["Employee"] = relationship("Employee")
def __repr__(self) -> str:
return f"<NotificationPreference employee={self.employee_id}>"

9
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
"""
Pydantic Schemas
"""
from app.schemas.auth import *
from app.schemas.employee import *
from app.schemas.kpi_sheet import *
from app.schemas.kpi_item import *
from app.schemas.notification import *
from app.schemas.common import *

44
app/schemas/auth.py Normal file
View File

@@ -0,0 +1,44 @@
"""
認證 Schemas
"""
from typing import Optional
from pydantic import BaseModel, EmailStr
class LoginRequest(BaseModel):
"""登入請求"""
employee_no: str
password: str
class TokenResponse(BaseModel):
"""Token 回應"""
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshTokenRequest(BaseModel):
"""更新 Token 請求"""
refresh_token: str
class UserInfo(BaseModel):
"""使用者資訊"""
id: int
employee_no: str
name: str
email: str
department_id: int
department_name: str
job_title: Optional[str]
role: str
is_manager: bool
is_admin: bool
class Config:
from_attributes = True

37
app/schemas/common.py Normal file
View File

@@ -0,0 +1,37 @@
"""
共用 Schemas
"""
from typing import Generic, TypeVar, Optional, List
from pydantic import BaseModel
T = TypeVar("T")
class ErrorDetail(BaseModel):
"""錯誤詳情"""
code: str
message: str
details: Optional[dict] = None
class ErrorResponse(BaseModel):
"""錯誤回應"""
error: ErrorDetail
class PaginatedResponse(BaseModel, Generic[T]):
"""分頁回應"""
items: List[T]
total: int
page: int
page_size: int
pages: int
class MessageResponse(BaseModel):
"""訊息回應"""
message: str

74
app/schemas/dashboard.py Normal file
View File

@@ -0,0 +1,74 @@
"""
儀表板 Schemas
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel
class ProgressStats(BaseModel):
"""進度統計"""
total: int
draft: int
pending: int
approved: int
self_eval: int
manager_eval: int
completed: int
class DistributionItem(BaseModel):
"""分佈項目"""
label: str
count: int
percentage: float
class TrendItem(BaseModel):
"""趨勢項目"""
period: str
average_score: float
completed_count: int
class DashboardAlertResponse(BaseModel):
"""儀表板警示回應"""
id: int
alert_type: str
severity: str
title: str
description: Optional[str]
related_sheet_id: Optional[int]
related_employee_id: Optional[int]
is_resolved: bool
created_at: datetime
class Config:
from_attributes = True
class DashboardProgressResponse(BaseModel):
"""儀表板進度回應"""
period_code: str
period_name: str
stats: ProgressStats
completion_rate: float
class DashboardDistributionResponse(BaseModel):
"""儀表板分佈回應"""
by_department: List[DistributionItem]
by_status: List[DistributionItem]
by_score_range: List[DistributionItem]
class DashboardTrendsResponse(BaseModel):
"""儀表板趨勢回應"""
trends: List[TrendItem]

87
app/schemas/employee.py Normal file
View File

@@ -0,0 +1,87 @@
"""
員工 Schemas
"""
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
class DepartmentBase(BaseModel):
"""部門基本資訊"""
id: int
code: str
name: str
level: str
class DepartmentResponse(DepartmentBase):
"""部門回應"""
parent_id: Optional[int]
is_active: bool
class Config:
from_attributes = True
class EmployeeBase(BaseModel):
"""員工基本資訊"""
id: int
employee_no: str
name: str
email: str
class EmployeeSimple(EmployeeBase):
"""員工簡要資訊"""
job_title: Optional[str]
role: str
class Config:
from_attributes = True
class EmployeeResponse(EmployeeBase):
"""員工回應"""
department_id: int
department: DepartmentBase
manager_id: Optional[int]
manager: Optional[EmployeeBase]
job_title: Optional[str]
role: str
status: str
hire_date: Optional[date]
created_at: datetime
class Config:
from_attributes = True
class EmployeeCreate(BaseModel):
"""建立員工"""
employee_no: str
name: str
email: EmailStr
password: str
department_id: int
manager_id: Optional[int] = None
job_title: Optional[str] = None
role: str = "employee"
hire_date: Optional[date] = None
class EmployeeUpdate(BaseModel):
"""更新員工"""
name: Optional[str] = None
email: Optional[EmailStr] = None
department_id: Optional[int] = None
manager_id: Optional[int] = None
job_title: Optional[str] = None
role: Optional[str] = None
status: Optional[str] = None

74
app/schemas/kpi_item.py Normal file
View File

@@ -0,0 +1,74 @@
"""
KPI 項目 Schemas
"""
from typing import Optional
from pydantic import BaseModel, Field
class KPIItemBase(BaseModel):
"""KPI 項目基本資訊"""
name: str
category: str # financial, customer, internal, learning
weight: int = Field(..., ge=1, le=100, description="權重百分比 (1-100)")
class KPIItemCreate(KPIItemBase):
"""建立 KPI 項目"""
template_id: Optional[int] = None
level0_criteria: Optional[str] = None
level1_criteria: Optional[str] = None
level2_criteria: Optional[str] = None
level3_criteria: Optional[str] = None
level4_criteria: Optional[str] = None
class KPIItemUpdate(BaseModel):
"""更新 KPI 項目"""
name: Optional[str] = None
category: Optional[str] = None
weight: Optional[int] = Field(None, ge=1, le=100)
level0_criteria: Optional[str] = None
level1_criteria: Optional[str] = None
level2_criteria: Optional[str] = None
level3_criteria: Optional[str] = None
level4_criteria: Optional[str] = None
class KPIItemResponse(KPIItemBase):
"""KPI 項目回應"""
id: int
sheet_id: int
template_id: Optional[int]
sort_order: int
level0_criteria: Optional[str]
level1_criteria: Optional[str]
level2_criteria: Optional[str]
level3_criteria: Optional[str]
level4_criteria: Optional[str]
self_eval_level: Optional[int]
self_eval_note: Optional[str]
final_level: Optional[int]
final_note: Optional[str]
class Config:
from_attributes = True
class SelfEvalItem(BaseModel):
"""自評項目"""
id: int
level: int = Field(..., ge=0, le=4, description="自評等級 (0-4)")
note: Optional[str] = None
class ManagerEvalItem(BaseModel):
"""主管評核項目"""
id: int
level: int = Field(..., ge=0, le=4, description="評核等級 (0-4)")
note: Optional[str] = None

163
app/schemas/kpi_sheet.py Normal file
View File

@@ -0,0 +1,163 @@
"""
KPI 表單 Schemas
"""
from datetime import datetime
from decimal import Decimal
from typing import Optional, List
from pydantic import BaseModel
from app.schemas.employee import EmployeeSimple
from app.schemas.kpi_item import KPIItemCreate, KPIItemResponse, SelfEvalItem, ManagerEvalItem
class KPIPeriodBase(BaseModel):
"""KPI 期間基本資訊"""
id: int
code: str
name: str
class KPIPeriodResponse(KPIPeriodBase):
"""KPI 期間回應"""
start_date: str
end_date: str
setting_start: str
setting_end: str
self_eval_start: Optional[str]
self_eval_end: Optional[str]
manager_eval_start: Optional[str]
manager_eval_end: Optional[str]
status: str
class Config:
from_attributes = True
class KPISheetCreate(BaseModel):
"""建立 KPI 表單"""
period_id: int
items: List[KPIItemCreate]
class KPISheetUpdate(BaseModel):
"""更新 KPI 表單"""
items: Optional[List[KPIItemCreate]] = None
class KPISheetResponse(BaseModel):
"""KPI 表單回應"""
id: int
employee: EmployeeSimple
period: KPIPeriodBase
department_id: int
status: str
items: List[KPIItemResponse]
# 提交資訊
submitted_at: Optional[datetime]
# 審核資訊
approved_by: Optional[int]
approved_at: Optional[datetime]
approve_comment: Optional[str]
# 退回資訊
rejected_by: Optional[int]
rejected_at: Optional[datetime]
reject_reason: Optional[str]
# 自評資訊
self_eval_at: Optional[datetime]
# 主管評核資訊
manager_eval_by: Optional[int]
manager_eval_at: Optional[datetime]
manager_eval_comment: Optional[str]
# 分數
total_score: Optional[Decimal]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class KPISheetListItem(BaseModel):
"""KPI 表單列表項目"""
id: int
employee: EmployeeSimple
period: KPIPeriodBase
status: str
total_score: Optional[Decimal]
submitted_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class ApproveRequest(BaseModel):
"""審核通過請求"""
comment: Optional[str] = None
class RejectRequest(BaseModel):
"""退回請求"""
reason: str
class SelfEvalRequest(BaseModel):
"""自評請求"""
items: List[SelfEvalItem]
class ManagerEvalRequest(BaseModel):
"""主管評核請求"""
items: List[ManagerEvalItem]
comment: Optional[str] = None
# KPI 範本相關
class KPITemplateResponse(BaseModel):
"""KPI 範本回應"""
id: int
code: str
name: str
category: str
description: Optional[str]
default_weight: int
level0_desc: str
level1_desc: str
level2_desc: str
level3_desc: str
level4_desc: str
is_active: bool
class Config:
from_attributes = True
class KPIPresetResponse(BaseModel):
"""KPI 預設組合回應"""
id: int
code: str
name: str
description: Optional[str]
items: List[KPITemplateResponse]
class Config:
from_attributes = True

View File

@@ -0,0 +1,47 @@
"""
通知 Schemas
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class NotificationResponse(BaseModel):
"""通知回應"""
id: int
type: str
title: str
content: Optional[str]
related_sheet_id: Optional[int]
is_read: bool
read_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class NotificationPreferenceResponse(BaseModel):
"""通知偏好回應"""
email_enabled: bool
in_app_enabled: bool
reminder_days_before: int
class Config:
from_attributes = True
class NotificationPreferenceUpdate(BaseModel):
"""更新通知偏好"""
email_enabled: Optional[bool] = None
in_app_enabled: Optional[bool] = None
reminder_days_before: Optional[int] = None
class UnreadCountResponse(BaseModel):
"""未讀數量回應"""
count: int

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