Major Features: - ✅ Multi-tenant architecture (tenant isolation) - ✅ Employee CRUD with lifecycle management (onboarding/offboarding) - ✅ Department tree structure with email domain management - ✅ Company info management (single-record editing) - ✅ System functions CRUD (permission management) - ✅ Email account management (multi-account per employee) - ✅ Keycloak SSO integration (auth.lab.taipei) - ✅ Redis session storage (10.1.0.254:6379) - Solves Cookie 4KB limitation - Cross-system session sharing - Sliding expiration (8 hours) - Automatic token refresh Technical Stack: Backend: - FastAPI + SQLAlchemy - PostgreSQL 16 (10.1.0.20:5433) - Keycloak Admin API integration - Docker Mailserver integration (SSH) - Alembic migrations Frontend: - Next.js 14 (App Router) - NextAuth 4 with Keycloak Provider - Redis session storage (ioredis) - Tailwind CSS Infrastructure: - Redis 7 (10.1.0.254:6379) - Session + Cache - Keycloak 26.1.0 (auth.lab.taipei) - Docker Mailserver (10.1.0.254) Architecture Highlights: - Session管理由 Keycloak + Redis 統一控制 - 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session - Token 自動刷新,異質服務整合 - 未來可無縫遷移到雲端 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
469 lines
15 KiB
Python
469 lines
15 KiB
Python
"""
|
|
員工到職流程 API (v3.1 多租戶架構)
|
|
使用關聯表方式管理部門、角色、服務
|
|
"""
|
|
from typing import List, Optional
|
|
from datetime import datetime, date
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel
|
|
|
|
from app.db.session import get_db
|
|
from app.models.emp_resume import EmpResume
|
|
from app.models.emp_setting import EmpSetting
|
|
from app.models.department_member import DepartmentMember
|
|
from app.models.role import UserRoleAssignment
|
|
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
|
|
from app.models.personal_service import PersonalService
|
|
from app.schemas.response import MessageResponse
|
|
from app.services.audit_service import audit_service
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def get_current_tenant_id() -> int:
|
|
"""取得當前租戶 ID (暫時寫死為 1, 未來從 JWT token realm 取得)"""
|
|
return 1
|
|
|
|
|
|
def get_current_user_id() -> str:
|
|
"""取得當前使用者 ID (暫時寫死, 未來從 JWT token sub 取得)"""
|
|
return "system-admin"
|
|
|
|
|
|
# ==================== Schemas ====================
|
|
|
|
class DepartmentAssignment(BaseModel):
|
|
"""部門分配"""
|
|
department_id: int
|
|
position: Optional[str] = None
|
|
membership_type: str = "permanent" # permanent/temporary/project
|
|
|
|
|
|
class OnboardingRequest(BaseModel):
|
|
"""到職請求"""
|
|
# 人員基本資料
|
|
resume_id: int # 已存在的 tenant_emp_resumes.id
|
|
|
|
# SSO 帳號資訊
|
|
keycloak_user_id: str # Keycloak UUID
|
|
keycloak_username: str # 登入帳號
|
|
|
|
# 任用資訊
|
|
hire_date: date
|
|
|
|
# 部門分配
|
|
departments: List[DepartmentAssignment]
|
|
|
|
# 角色分配
|
|
role_ids: List[int]
|
|
|
|
# 配額設定
|
|
storage_quota_gb: int = 20
|
|
email_quota_mb: int = 5120
|
|
|
|
|
|
# ==================== API Endpoints ====================
|
|
|
|
@router.post("/onboard", status_code=status.HTTP_201_CREATED)
|
|
def onboard_employee(
|
|
data: OnboardingRequest,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
完整員工到職流程
|
|
|
|
執行項目:
|
|
1. 建立員工任用設定 (tenant_emp_settings)
|
|
2. 分配部門歸屬 (tenant_dept_members)
|
|
3. 分配使用者角色 (tenant_user_role_assignments)
|
|
4. 啟用所有個人化服務 (tenant_emp_personal_service_settings)
|
|
|
|
範例:
|
|
{
|
|
"resume_id": 1,
|
|
"keycloak_user_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"keycloak_username": "wang.ming",
|
|
"hire_date": "2026-02-20",
|
|
"departments": [
|
|
{"department_id": 9, "position": "資深工程師", "membership_type": "permanent"},
|
|
{"department_id": 12, "position": "專案經理", "membership_type": "project"}
|
|
],
|
|
"role_ids": [1, 2],
|
|
"storage_quota_gb": 20,
|
|
"email_quota_mb": 5120
|
|
}
|
|
"""
|
|
tenant_id = get_current_tenant_id()
|
|
current_user = get_current_user_id()
|
|
|
|
# Step 1: 檢查 resume 是否存在
|
|
resume = db.query(EmpResume).filter(
|
|
EmpResume.id == data.resume_id,
|
|
EmpResume.tenant_id == tenant_id
|
|
).first()
|
|
|
|
if not resume:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Resume ID {data.resume_id} not found"
|
|
)
|
|
|
|
# 檢查是否已有任用設定
|
|
existing_setting = db.query(EmpSetting).filter(
|
|
EmpSetting.tenant_id == tenant_id,
|
|
EmpSetting.tenant_resume_id == data.resume_id,
|
|
EmpSetting.is_active == True
|
|
).first()
|
|
|
|
if existing_setting:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Employee already onboarded (emp_code: {existing_setting.tenant_emp_code})"
|
|
)
|
|
|
|
# Step 2: 建立員工任用設定 (seq_no 由觸發器自動生成)
|
|
emp_setting = EmpSetting(
|
|
tenant_id=tenant_id,
|
|
# seq_no 會由觸發器自動生成
|
|
tenant_resume_id=data.resume_id,
|
|
# tenant_emp_code 會由觸發器自動生成
|
|
tenant_keycloak_user_id=data.keycloak_user_id,
|
|
tenant_keycloak_username=data.keycloak_username,
|
|
hire_at=data.hire_date,
|
|
storage_quota_gb=data.storage_quota_gb,
|
|
email_quota_mb=data.email_quota_mb,
|
|
employment_status="active",
|
|
is_active=True,
|
|
edit_by=current_user
|
|
)
|
|
|
|
db.add(emp_setting)
|
|
db.flush() # 取得自動生成的 seq_no 和 tenant_emp_code
|
|
|
|
# Step 3: 分配部門歸屬
|
|
dept_count = 0
|
|
for dept_assignment in data.departments:
|
|
dept_member = DepartmentMember(
|
|
tenant_id=tenant_id,
|
|
employee_id=data.resume_id, # 使用 resume_id 作為 employee_id
|
|
department_id=dept_assignment.department_id,
|
|
position=dept_assignment.position,
|
|
membership_type=dept_assignment.membership_type,
|
|
joined_at=datetime.utcnow(),
|
|
assigned_by=current_user,
|
|
is_active=True,
|
|
edit_by=current_user
|
|
)
|
|
db.add(dept_member)
|
|
dept_count += 1
|
|
|
|
# Step 4: 分配使用者角色
|
|
role_count = 0
|
|
for role_id in data.role_ids:
|
|
role_assignment = UserRoleAssignment(
|
|
tenant_id=tenant_id,
|
|
keycloak_user_id=data.keycloak_user_id,
|
|
role_id=role_id,
|
|
assigned_at=datetime.utcnow(),
|
|
assigned_by=current_user,
|
|
is_active=True,
|
|
edit_by=current_user
|
|
)
|
|
db.add(role_assignment)
|
|
role_count += 1
|
|
|
|
# Step 5: 啟用所有個人化服務
|
|
all_services = db.query(PersonalService).filter(
|
|
PersonalService.is_active == True
|
|
).all()
|
|
|
|
service_count = 0
|
|
for service in all_services:
|
|
# 根據服務類型設定配額
|
|
quota_gb = data.storage_quota_gb if service.service_code == "Drive" else None
|
|
quota_mb = data.email_quota_mb if service.service_code == "Email" else None
|
|
|
|
service_setting = EmpPersonalServiceSetting(
|
|
tenant_id=tenant_id,
|
|
tenant_keycloak_user_id=data.keycloak_user_id,
|
|
service_id=service.id,
|
|
quota_gb=quota_gb,
|
|
quota_mb=quota_mb,
|
|
enabled_at=datetime.utcnow(),
|
|
enabled_by=current_user,
|
|
is_active=True,
|
|
edit_by=current_user
|
|
)
|
|
db.add(service_setting)
|
|
service_count += 1
|
|
|
|
db.commit()
|
|
db.refresh(emp_setting)
|
|
|
|
# 審計日誌
|
|
audit_service.log_action(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
user_id=current_user,
|
|
action="employee_onboard",
|
|
resource_type="tenant_emp_settings",
|
|
resource_id=f"{tenant_id}-{emp_setting.seq_no}",
|
|
details=f"Onboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw}): "
|
|
f"{dept_count} departments, {role_count} roles, {service_count} services",
|
|
ip_address=request.client.host if request.client else None
|
|
)
|
|
|
|
return {
|
|
"message": "Employee onboarded successfully",
|
|
"employee": {
|
|
"tenant_id": emp_setting.tenant_id,
|
|
"seq_no": emp_setting.seq_no,
|
|
"tenant_emp_code": emp_setting.tenant_emp_code,
|
|
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
|
|
"keycloak_username": emp_setting.tenant_keycloak_username,
|
|
"name": resume.name_tw,
|
|
"hire_date": emp_setting.hire_at.isoformat(),
|
|
},
|
|
"summary": {
|
|
"departments_assigned": dept_count,
|
|
"roles_assigned": role_count,
|
|
"services_enabled": service_count,
|
|
}
|
|
}
|
|
|
|
|
|
@router.post("/{tenant_id}/{seq_no}/offboard")
|
|
def offboard_employee(
|
|
tenant_id: int,
|
|
seq_no: int,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
員工離職流程
|
|
|
|
執行項目:
|
|
1. 軟刪除所有部門歸屬
|
|
2. 撤銷所有使用者角色
|
|
3. 停用所有個人化服務
|
|
4. 設定員工狀態為 resigned
|
|
"""
|
|
current_tenant_id = get_current_tenant_id()
|
|
current_user = get_current_user_id()
|
|
|
|
# 檢查租戶權限
|
|
if tenant_id != current_tenant_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="No permission to access this tenant"
|
|
)
|
|
|
|
# 查詢員工任用設定
|
|
emp_setting = db.query(EmpSetting).filter(
|
|
EmpSetting.tenant_id == tenant_id,
|
|
EmpSetting.seq_no == seq_no
|
|
).first()
|
|
|
|
if not emp_setting:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
|
|
)
|
|
|
|
if emp_setting.employment_status == "resigned":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Employee already resigned"
|
|
)
|
|
|
|
keycloak_user_id = emp_setting.tenant_keycloak_user_id
|
|
resume_id = emp_setting.tenant_resume_id
|
|
|
|
# Step 1: 軟刪除所有部門歸屬
|
|
dept_members = db.query(DepartmentMember).filter(
|
|
DepartmentMember.tenant_id == tenant_id,
|
|
DepartmentMember.employee_id == resume_id,
|
|
DepartmentMember.is_active == True
|
|
).all()
|
|
|
|
for dm in dept_members:
|
|
dm.is_active = False
|
|
dm.ended_at = datetime.utcnow()
|
|
dm.removed_by = current_user
|
|
dm.edit_by = current_user
|
|
|
|
# Step 2: 撤銷所有使用者角色
|
|
role_assignments = db.query(UserRoleAssignment).filter(
|
|
UserRoleAssignment.tenant_id == tenant_id,
|
|
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
|
|
UserRoleAssignment.is_active == True
|
|
).all()
|
|
|
|
for ra in role_assignments:
|
|
ra.is_active = False
|
|
ra.revoked_at = datetime.utcnow()
|
|
ra.revoked_by = current_user
|
|
ra.edit_by = current_user
|
|
|
|
# Step 3: 停用所有個人化服務
|
|
service_settings = db.query(EmpPersonalServiceSetting).filter(
|
|
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
|
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
|
EmpPersonalServiceSetting.is_active == True
|
|
).all()
|
|
|
|
for ss in service_settings:
|
|
ss.is_active = False
|
|
ss.disabled_at = datetime.utcnow()
|
|
ss.disabled_by = current_user
|
|
ss.edit_by = current_user
|
|
|
|
# Step 4: 設定離職日期和狀態
|
|
emp_setting.resign_date = date.today()
|
|
emp_setting.employment_status = "resigned"
|
|
emp_setting.is_active = False
|
|
emp_setting.edit_by = current_user
|
|
|
|
db.commit()
|
|
|
|
# 審計日誌
|
|
resume = emp_setting.resume
|
|
audit_service.log_action(
|
|
db=db,
|
|
tenant_id=tenant_id,
|
|
user_id=current_user,
|
|
action="employee_offboard",
|
|
resource_type="tenant_emp_settings",
|
|
resource_id=f"{tenant_id}-{seq_no}",
|
|
details=f"Offboarded employee {emp_setting.tenant_emp_code} ({resume.name_tw if resume else 'Unknown'}): "
|
|
f"{len(dept_members)} departments removed, {len(role_assignments)} roles revoked, "
|
|
f"{len(service_settings)} services disabled",
|
|
ip_address=request.client.host if request.client else None
|
|
)
|
|
|
|
return {
|
|
"message": "Employee offboarded successfully",
|
|
"employee": {
|
|
"tenant_emp_code": emp_setting.tenant_emp_code,
|
|
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
|
|
},
|
|
"summary": {
|
|
"departments_removed": len(dept_members),
|
|
"roles_revoked": len(role_assignments),
|
|
"services_disabled": len(service_settings),
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/{tenant_id}/{seq_no}/status")
|
|
def get_employee_onboarding_status(
|
|
tenant_id: int,
|
|
seq_no: int,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
查詢員工完整的到職狀態
|
|
|
|
回傳:
|
|
- 員工基本資訊
|
|
- 部門歸屬列表
|
|
- 角色分配列表
|
|
- 個人化服務列表
|
|
"""
|
|
current_tenant_id = get_current_tenant_id()
|
|
|
|
if tenant_id != current_tenant_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="No permission to access this tenant"
|
|
)
|
|
|
|
emp_setting = db.query(EmpSetting).filter(
|
|
EmpSetting.tenant_id == tenant_id,
|
|
EmpSetting.seq_no == seq_no
|
|
).first()
|
|
|
|
if not emp_setting:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Employee (tenant_id={tenant_id}, seq_no={seq_no}) not found"
|
|
)
|
|
|
|
resume = emp_setting.resume
|
|
keycloak_user_id = emp_setting.tenant_keycloak_user_id
|
|
|
|
# 查詢部門歸屬
|
|
dept_members = db.query(DepartmentMember).filter(
|
|
DepartmentMember.tenant_id == tenant_id,
|
|
DepartmentMember.employee_id == emp_setting.tenant_resume_id,
|
|
DepartmentMember.is_active == True
|
|
).all()
|
|
|
|
departments = [
|
|
{
|
|
"department_id": dm.department_id,
|
|
"department_name": dm.department.name if dm.department else None,
|
|
"position": dm.position,
|
|
"membership_type": dm.membership_type,
|
|
"joined_at": dm.joined_at.isoformat() if dm.joined_at else None,
|
|
}
|
|
for dm in dept_members
|
|
]
|
|
|
|
# 查詢角色分配
|
|
role_assignments = db.query(UserRoleAssignment).filter(
|
|
UserRoleAssignment.tenant_id == tenant_id,
|
|
UserRoleAssignment.keycloak_user_id == keycloak_user_id,
|
|
UserRoleAssignment.is_active == True
|
|
).all()
|
|
|
|
roles = [
|
|
{
|
|
"role_id": ra.role_id,
|
|
"role_name": ra.role.role_name if ra.role else None,
|
|
"role_code": ra.role.role_code if ra.role else None,
|
|
"assigned_at": ra.assigned_at.isoformat() if ra.assigned_at else None,
|
|
}
|
|
for ra in role_assignments
|
|
]
|
|
|
|
# 查詢個人化服務
|
|
service_settings = db.query(EmpPersonalServiceSetting).filter(
|
|
EmpPersonalServiceSetting.tenant_id == tenant_id,
|
|
EmpPersonalServiceSetting.tenant_keycloak_user_id == keycloak_user_id,
|
|
EmpPersonalServiceSetting.is_active == True
|
|
).all()
|
|
|
|
services = [
|
|
{
|
|
"service_id": ss.service_id,
|
|
"service_name": ss.service.service_name if ss.service else None,
|
|
"service_code": ss.service.service_code if ss.service else None,
|
|
"quota_gb": ss.quota_gb,
|
|
"quota_mb": ss.quota_mb,
|
|
"enabled_at": ss.enabled_at.isoformat() if ss.enabled_at else None,
|
|
}
|
|
for ss in service_settings
|
|
]
|
|
|
|
return {
|
|
"employee": {
|
|
"tenant_id": emp_setting.tenant_id,
|
|
"seq_no": emp_setting.seq_no,
|
|
"tenant_emp_code": emp_setting.tenant_emp_code,
|
|
"name": resume.name_tw if resume else None,
|
|
"keycloak_user_id": emp_setting.tenant_keycloak_user_id,
|
|
"keycloak_username": emp_setting.tenant_keycloak_username,
|
|
"hire_date": emp_setting.hire_at.isoformat() if emp_setting.hire_at else None,
|
|
"resign_date": emp_setting.resign_date.isoformat() if emp_setting.resign_date else None,
|
|
"employment_status": emp_setting.employment_status,
|
|
"storage_quota_gb": emp_setting.storage_quota_gb,
|
|
"email_quota_mb": emp_setting.email_quota_mb,
|
|
},
|
|
"departments": departments,
|
|
"roles": roles,
|
|
"services": services,
|
|
}
|