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