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>
227 lines
6.7 KiB
Python
227 lines
6.7 KiB
Python
"""
|
|
部門成員管理 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")
|