feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
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>
This commit is contained in:
226
backend/app/api/v1/department_members.py
Normal file
226
backend/app/api/v1/department_members.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
部門成員管理 API
|
||||
記錄員工與部門的多對多關係 (純粹組織歸屬,與 RBAC 權限無關)
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.department_member import DepartmentMember
|
||||
from app.models.employee import Employee
|
||||
from app.models.department import Department
|
||||
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
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_department_members(
|
||||
db: Session = Depends(get_db),
|
||||
employee_id: Optional[int] = Query(None, description="員工 ID 篩選"),
|
||||
department_id: Optional[int] = Query(None, description="部門 ID 篩選"),
|
||||
include_inactive: bool = False,
|
||||
):
|
||||
"""
|
||||
取得部門成員列表
|
||||
|
||||
可依員工 ID 或部門 ID 篩選
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
query = db.query(DepartmentMember).filter(DepartmentMember.tenant_id == tenant_id)
|
||||
|
||||
if employee_id:
|
||||
query = query.filter(DepartmentMember.employee_id == employee_id)
|
||||
|
||||
if department_id:
|
||||
query = query.filter(DepartmentMember.department_id == department_id)
|
||||
|
||||
if not include_inactive:
|
||||
query = query.filter(DepartmentMember.is_active == True)
|
||||
|
||||
members = query.all()
|
||||
|
||||
result = []
|
||||
for m in members:
|
||||
dept = m.department
|
||||
emp = m.employee
|
||||
result.append({
|
||||
"id": m.id,
|
||||
"employee_id": m.employee_id,
|
||||
"employee_name": emp.legal_name if emp else None,
|
||||
"employee_number": emp.employee_id if emp else None,
|
||||
"department_id": m.department_id,
|
||||
"department_name": dept.name if dept else None,
|
||||
"department_code": dept.code if dept else None,
|
||||
"department_depth": dept.depth if dept else None,
|
||||
"position": m.position,
|
||||
"membership_type": m.membership_type,
|
||||
"is_active": m.is_active,
|
||||
"joined_at": m.joined_at,
|
||||
"ended_at": m.ended_at,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||
def add_employee_to_department(
|
||||
data: dict,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
將員工加入部門
|
||||
|
||||
Body:
|
||||
{
|
||||
"employee_id": 1,
|
||||
"department_id": 3,
|
||||
"position": "資深工程師",
|
||||
"membership_type": "permanent"
|
||||
}
|
||||
"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
employee_id = data.get("employee_id")
|
||||
department_id = data.get("department_id")
|
||||
position = data.get("position")
|
||||
membership_type = data.get("membership_type", "permanent")
|
||||
|
||||
if not employee_id or not department_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="employee_id and department_id are required"
|
||||
)
|
||||
|
||||
# 驗證員工存在
|
||||
employee = db.query(Employee).filter(
|
||||
Employee.id == employee_id,
|
||||
Employee.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not employee:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Employee with id {employee_id} not found"
|
||||
)
|
||||
|
||||
# 驗證部門存在
|
||||
department = db.query(Department).filter(
|
||||
Department.id == department_id,
|
||||
Department.tenant_id == tenant_id,
|
||||
).first()
|
||||
if not department:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department with id {department_id} not found"
|
||||
)
|
||||
|
||||
# 檢查是否已存在
|
||||
existing = db.query(DepartmentMember).filter(
|
||||
DepartmentMember.employee_id == employee_id,
|
||||
DepartmentMember.department_id == department_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if existing.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Employee {employee_id} is already a member of department {department_id}"
|
||||
)
|
||||
else:
|
||||
# 重新啟用
|
||||
existing.is_active = True
|
||||
existing.position = position
|
||||
existing.membership_type = membership_type
|
||||
existing.ended_at = None
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return {
|
||||
"id": existing.id,
|
||||
"employee_id": existing.employee_id,
|
||||
"department_id": existing.department_id,
|
||||
"position": existing.position,
|
||||
"membership_type": existing.membership_type,
|
||||
"is_active": existing.is_active,
|
||||
}
|
||||
|
||||
member = DepartmentMember(
|
||||
tenant_id=tenant_id,
|
||||
employee_id=employee_id,
|
||||
department_id=department_id,
|
||||
position=position,
|
||||
membership_type=membership_type,
|
||||
)
|
||||
db.add(member)
|
||||
db.commit()
|
||||
db.refresh(member)
|
||||
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="add_department_member",
|
||||
resource_type="department_member",
|
||||
resource_id=member.id,
|
||||
details={
|
||||
"employee_id": employee_id,
|
||||
"department_id": department_id,
|
||||
"position": position,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"id": member.id,
|
||||
"employee_id": member.employee_id,
|
||||
"department_id": member.department_id,
|
||||
"position": member.position,
|
||||
"membership_type": member.membership_type,
|
||||
"is_active": member.is_active,
|
||||
"joined_at": member.joined_at,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{member_id}", response_model=MessageResponse)
|
||||
def remove_employee_from_department(
|
||||
member_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""將員工從部門移除 (軟刪除)"""
|
||||
tenant_id = get_current_tenant_id()
|
||||
|
||||
member = db.query(DepartmentMember).filter(
|
||||
DepartmentMember.id == member_id,
|
||||
DepartmentMember.tenant_id == tenant_id,
|
||||
).first()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Department member with id {member_id} not found"
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
member.is_active = False
|
||||
member.ended_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
audit_service.log_action(
|
||||
request=request,
|
||||
db=db,
|
||||
action="remove_department_member",
|
||||
resource_type="department_member",
|
||||
resource_id=member_id,
|
||||
details={
|
||||
"employee_id": member.employee_id,
|
||||
"department_id": member.department_id,
|
||||
},
|
||||
)
|
||||
|
||||
return MessageResponse(message=f"Employee removed from department successfully")
|
||||
Reference in New Issue
Block a user