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:
84
backend/app/models/__init__.py
Normal file
84
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Models 模組
|
||||
匯出所有資料庫模型
|
||||
"""
|
||||
# 多租戶核心模型
|
||||
from app.models.tenant import Tenant, TenantStatus
|
||||
from app.models.system_function_cache import SystemFunctionCache
|
||||
from app.models.system_function import SystemFunction
|
||||
from app.models.personal_service import PersonalService
|
||||
|
||||
# HR 組織架構
|
||||
from app.models.department import Department
|
||||
from app.models.department_member import DepartmentMember
|
||||
|
||||
# HR 員工模型
|
||||
from app.models.employee import Employee, EmployeeStatus
|
||||
from app.models.emp_resume import EmpResume
|
||||
from app.models.emp_setting import EmpSetting
|
||||
from app.models.emp_personal_service_setting import EmpPersonalServiceSetting
|
||||
|
||||
# RBAC 權限系統
|
||||
from app.models.role import UserRole, RoleRight, UserRoleAssignment
|
||||
|
||||
# 其他業務模型
|
||||
from app.models.email_account import EmailAccount
|
||||
from app.models.network_drive import NetworkDrive
|
||||
from app.models.permission import Permission
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.batch_log import BatchLog
|
||||
|
||||
# 初始化系統
|
||||
from app.models.installation import (
|
||||
InstallationSession,
|
||||
InstallationChecklistItem,
|
||||
InstallationChecklistResult,
|
||||
InstallationStep,
|
||||
InstallationLog,
|
||||
InstallationTenantInfo,
|
||||
InstallationDepartmentSetup,
|
||||
TemporaryPassword,
|
||||
InstallationAccessLog,
|
||||
InstallationEnvironmentConfig,
|
||||
InstallationSystemStatus
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 多租戶核心
|
||||
"Tenant",
|
||||
"TenantStatus",
|
||||
"SystemFunctionCache",
|
||||
"SystemFunction",
|
||||
"PersonalService",
|
||||
# 組織架構
|
||||
"Department",
|
||||
"DepartmentMember",
|
||||
# 員工模型
|
||||
"Employee",
|
||||
"EmployeeStatus",
|
||||
"EmpResume",
|
||||
"EmpSetting",
|
||||
"EmpPersonalServiceSetting",
|
||||
# RBAC 權限系統
|
||||
"UserRole",
|
||||
"RoleRight",
|
||||
"UserRoleAssignment",
|
||||
# 其他業務
|
||||
"EmailAccount",
|
||||
"NetworkDrive",
|
||||
"Permission",
|
||||
"AuditLog",
|
||||
"BatchLog",
|
||||
# 初始化系統
|
||||
"InstallationSession",
|
||||
"InstallationChecklistItem",
|
||||
"InstallationChecklistResult",
|
||||
"InstallationStep",
|
||||
"InstallationLog",
|
||||
"InstallationTenantInfo",
|
||||
"InstallationDepartmentSetup",
|
||||
"TemporaryPassword",
|
||||
"InstallationAccessLog",
|
||||
"InstallationEnvironmentConfig",
|
||||
"InstallationSystemStatus",
|
||||
]
|
||||
64
backend/app/models/audit_log.py
Normal file
64
backend/app/models/audit_log.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
審計日誌 Model
|
||||
記錄所有關鍵操作,符合 ISO 要求
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, JSON, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""審計日誌表"""
|
||||
|
||||
__tablename__ = "tenant_audit_logs"
|
||||
__table_args__ = (
|
||||
Index("idx_audit_tenant_action", "tenant_id", "action"),
|
||||
Index("idx_audit_tenant_resource", "tenant_id", "resource_type", "resource_id"),
|
||||
Index("idx_audit_tenant_time", "tenant_id", "performed_at"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
action = Column(String(50), nullable=False, index=True, comment="操作類型 (create/update/delete/login)")
|
||||
resource_type = Column(String(50), nullable=False, index=True, comment="資源類型 (employee/department/role)")
|
||||
resource_id = Column(Integer, nullable=True, index=True, comment="資源 ID")
|
||||
performed_by = Column(String(100), nullable=False, index=True, comment="操作者 SSO 帳號")
|
||||
performed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True, comment="操作時間")
|
||||
details = Column(JSONB, nullable=True, comment="詳細變更內容 (JSON)")
|
||||
ip_address = Column(String(45), nullable=True, comment="IP 位址 (IPv4/IPv6)")
|
||||
|
||||
# 通用欄位 (Note: audit_logs 不需要 is_active,只記錄不修改)
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AuditLog {self.action} {self.resource_type}:{self.resource_id} by {self.performed_by}>"
|
||||
|
||||
@classmethod
|
||||
def create_log(
|
||||
cls,
|
||||
tenant_id: int,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
performed_by: str,
|
||||
resource_id: int = None,
|
||||
details: dict = None,
|
||||
ip_address: str = None,
|
||||
) -> "AuditLog":
|
||||
"""創建審計日誌"""
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
performed_by=performed_by,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
31
backend/app/models/batch_log.py
Normal file
31
backend/app/models/batch_log.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
批次執行日誌 Model
|
||||
記錄所有批次作業的執行結果
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class BatchLog(Base):
|
||||
"""批次執行日誌表"""
|
||||
|
||||
__tablename__ = "tenant_batch_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
batch_name = Column(String(100), nullable=False, index=True, comment="批次名稱")
|
||||
status = Column(String(20), nullable=False, comment="執行狀態: success/failed/warning")
|
||||
message = Column(Text, comment="執行訊息或錯誤詳情")
|
||||
started_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True, comment="開始時間")
|
||||
finished_at = Column(DateTime, comment="完成時間")
|
||||
duration_seconds = Column(Integer, comment="執行時間 (秒)")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BatchLog {self.batch_name} [{self.status}] @ {self.started_at}>"
|
||||
49
backend/app/models/business_unit.py.deprecated
Normal file
49
backend/app/models/business_unit.py.deprecated
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
事業部 Model
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class BusinessUnit(Base):
|
||||
"""事業部表"""
|
||||
|
||||
__tablename__ = "business_units"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_tenant_bu_code"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
name = Column(String(100), nullable=False, comment="事業部名稱")
|
||||
name_en = Column(String(100), comment="英文名稱")
|
||||
code = Column(String(20), nullable=False, index=True, comment="事業部代碼 (BD, TD, OM, 租戶內唯一)")
|
||||
email_domain = Column(String(100), unique=True, nullable=False, comment="郵件網域 (ease.taipei, lab.taipei, porscheworld.tw)")
|
||||
description = Column(Text, comment="說明")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Phase 2.2 新增欄位
|
||||
primary_domain = Column(String(100), comment="主要網域 (與 email_domain 相同)")
|
||||
email_address = Column(String(255), comment="事業部信箱 (例如: business@ease.taipei)")
|
||||
email_quota_mb = Column(Integer, default=10240, nullable=False, comment="事業部信箱配額 (MB)")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="business_units")
|
||||
# departments relationship 已移除 (business_unit_id FK 已從 departments 表刪除於 migration 0005)
|
||||
employee_identities = relationship(
|
||||
"EmployeeIdentity",
|
||||
back_populates="business_unit",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BusinessUnit {self.code} - {self.name}>"
|
||||
|
||||
@property
|
||||
def sso_domain(self) -> str:
|
||||
"""SSO 帳號網域"""
|
||||
return self.email_domain
|
||||
74
backend/app/models/department.py
Normal file
74
backend/app/models/department.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
部門 Model
|
||||
統一樹狀部門結構:
|
||||
- depth=0, parent_id=NULL: 第一層部門 (原事業部),可設定 email_domain
|
||||
- depth=1+: 子部門,繼承上層 email_domain
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Department(Base):
|
||||
"""部門表 (統一樹狀結構)"""
|
||||
|
||||
__tablename__ = "tenant_departments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_dept_seq"),
|
||||
UniqueConstraint("tenant_id", "parent_id", "code", name="uq_tenant_parent_dept_code"),
|
||||
Index("idx_dept_tenant_id", "tenant_id"),
|
||||
Index("idx_departments_parent", "parent_id"),
|
||||
Index("idx_departments_depth", "depth"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
|
||||
parent_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="CASCADE"), nullable=True,
|
||||
comment="上層部門 ID (NULL=第一層,即原事業部)")
|
||||
code = Column(String(20), nullable=False, comment="部門代碼 (同層內唯一)")
|
||||
name = Column(String(100), nullable=False, comment="部門名稱")
|
||||
name_en = Column(String(100), nullable=True, comment="英文名稱")
|
||||
email_domain = Column(String(100), nullable=True,
|
||||
comment="郵件網域 (只有 depth=0 可設定,例如 ease.taipei)")
|
||||
email_address = Column(String(255), nullable=True, comment="部門信箱 (例如: wind@ease.taipei)")
|
||||
email_quota_mb = Column(Integer, default=5120, nullable=False, comment="部門信箱配額 (MB)")
|
||||
depth = Column(Integer, default=0, nullable=False, comment="層次深度 (0=第一層,1=第二層,以此類推)")
|
||||
description = Column(Text, comment="說明")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="departments")
|
||||
parent = relationship("Department", back_populates="children", remote_side="Department.id")
|
||||
children = relationship("Department", back_populates="parent", cascade="all, delete-orphan")
|
||||
members = relationship(
|
||||
"DepartmentMember",
|
||||
back_populates="department",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Department depth={self.depth} code={self.code} name={self.name}>"
|
||||
|
||||
@property
|
||||
def effective_email_domain(self) -> str | None:
|
||||
"""有效郵件網域 (第一層自身設定,子層追溯上層)"""
|
||||
if self.depth == 0:
|
||||
return self.email_domain
|
||||
if self.parent:
|
||||
return self.parent.effective_email_domain
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_top_level(self) -> bool:
|
||||
"""是否為第一層部門 (原事業部)"""
|
||||
return self.depth == 0 and self.parent_id is None
|
||||
53
backend/app/models/department_member.py
Normal file
53
backend/app/models/department_member.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
部門成員 Model
|
||||
記錄員工與部門的多對多關係 (純粹組織歸屬,與 RBAC 權限無關)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class DepartmentMember(Base):
|
||||
"""部門成員表"""
|
||||
|
||||
__tablename__ = "tenant_dept_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "department_id", name="uq_employee_department"),
|
||||
Index("idx_dept_members_tenant", "tenant_id"),
|
||||
Index("idx_dept_members_employee", "employee_id"),
|
||||
Index("idx_dept_members_department", "department_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
employee_id = Column(Integer, ForeignKey("tenant_employees.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="員工 ID")
|
||||
department_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="部門 ID")
|
||||
position = Column(String(100), nullable=True, comment="在該部門的職稱")
|
||||
membership_type = Column(String(50), default="permanent", nullable=False,
|
||||
comment="成員類型: permanent/temporary/project")
|
||||
|
||||
# 時間記錄(審計追蹤)
|
||||
joined_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="加入時間")
|
||||
ended_at = Column(DateTime, nullable=True, comment="離開時間(軟刪除)")
|
||||
|
||||
# 審計欄位
|
||||
assigned_by = Column(String(36), nullable=True, comment="分配者 keycloak_user_id")
|
||||
removed_by = Column(String(36), nullable=True, comment="移除者 keycloak_user_id")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
employee = relationship("Employee", back_populates="department_memberships")
|
||||
department = relationship("Department", back_populates="members")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DepartmentMember employee_id={self.employee_id} department_id={self.department_id}>"
|
||||
123
backend/app/models/email_account.py
Normal file
123
backend/app/models/email_account.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
郵件帳號 Model
|
||||
支援員工在不同網域擁有多個郵件帳號,並管理配額
|
||||
符合設計文件規範: HR Portal設計文件.md
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmailAccount(Base):
|
||||
"""郵件帳號表 (一個員工可以有多個郵件帳號)"""
|
||||
|
||||
__tablename__ = "tenant_email_accounts"
|
||||
__table_args__ = (
|
||||
# 郵件地址必須唯一
|
||||
Index("idx_email_accounts_email", "email_address", unique=True),
|
||||
# 員工索引
|
||||
Index("idx_email_accounts_employee", "employee_id"),
|
||||
# 租戶索引
|
||||
Index("idx_email_accounts_tenant", "tenant_id"),
|
||||
# 狀態索引 (快速查詢啟用的帳號)
|
||||
Index("idx_email_accounts_active", "is_active"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="租戶 ID"
|
||||
)
|
||||
|
||||
# 支援個人/部門信箱
|
||||
account_type = Column(
|
||||
String(20),
|
||||
default='personal',
|
||||
nullable=False,
|
||||
comment="帳號類型: personal(個人), department(部門)"
|
||||
)
|
||||
employee_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_employees.id", ondelete="CASCADE"),
|
||||
nullable=True, # 部門信箱不需要 employee_id
|
||||
index=True,
|
||||
comment="員工 ID (僅 personal 類型需要)"
|
||||
)
|
||||
department_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_departments.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="部門 ID (僅 department 類型需要)"
|
||||
)
|
||||
|
||||
# 郵件設定
|
||||
email_address = Column(
|
||||
String(255),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="郵件地址 (例如: porsche.chen@lab.taipei)"
|
||||
)
|
||||
quota_mb = Column(
|
||||
Integer,
|
||||
default=2048,
|
||||
nullable=False,
|
||||
comment="配額 (MB),依職級: Junior=2048, Mid=3072, Senior=5120, Manager=10240"
|
||||
)
|
||||
|
||||
# 進階功能
|
||||
forward_to = Column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
comment="轉寄地址 (可選,例如外部郵箱)"
|
||||
)
|
||||
auto_reply = Column(
|
||||
Text,
|
||||
nullable=True,
|
||||
comment="自動回覆內容 (可選,例如休假通知)"
|
||||
)
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
employee = relationship("Employee", back_populates="email_accounts")
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmailAccount {self.email_address} (配額:{self.quota_mb}MB)>"
|
||||
|
||||
@property
|
||||
def local_part(self) -> str:
|
||||
"""郵件前綴 (@ 之前的部分)"""
|
||||
return self.email_address.split('@')[0] if '@' in self.email_address else self.email_address
|
||||
|
||||
@property
|
||||
def domain_part(self) -> str:
|
||||
"""網域部分 (@ 之後的部分)"""
|
||||
return self.email_address.split('@')[1] if '@' in self.email_address else ""
|
||||
|
||||
@property
|
||||
def quota_gb(self) -> float:
|
||||
"""配額 (GB,用於顯示)"""
|
||||
return round(self.quota_mb / 1024, 2)
|
||||
|
||||
@classmethod
|
||||
def get_default_quota_by_level(cls, job_level: str) -> int:
|
||||
"""根據職級取得預設配額 (MB)"""
|
||||
quota_map = {
|
||||
"Junior": 2048,
|
||||
"Mid": 3072,
|
||||
"Senior": 5120,
|
||||
"Manager": 10240,
|
||||
}
|
||||
return quota_map.get(job_level, 2048)
|
||||
50
backend/app/models/emp_personal_service_setting.py
Normal file
50
backend/app/models/emp_personal_service_setting.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
員工個人化服務設定 Model
|
||||
記錄員工啟用的個人化服務(SSO, Email, Calendar, Drive, Office)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmpPersonalServiceSetting(Base):
|
||||
"""員工個人化服務設定表"""
|
||||
|
||||
__tablename__ = "tenant_emp_personal_service_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "keycloak_user_id", "service_id", name="uq_emp_service"),
|
||||
Index("idx_emp_service_tenant", "tenant_id"),
|
||||
Index("idx_emp_service_user", "keycloak_user_id"),
|
||||
Index("idx_emp_service_service", "service_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
keycloak_user_id = Column(String(36), nullable=False, comment="Keycloak User UUID")
|
||||
service_id = Column(Integer, ForeignKey("personal_services.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="個人化服務 ID")
|
||||
|
||||
# 服務配額設定(依服務類型不同)
|
||||
quota_gb = Column(Integer, nullable=True, comment="儲存配額 (GB),適用於 Drive")
|
||||
quota_mb = Column(Integer, nullable=True, comment="郵件配額 (MB),適用於 Email")
|
||||
|
||||
# 審計欄位(完整記錄)
|
||||
enabled_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="啟用時間")
|
||||
enabled_by = Column(String(36), nullable=True, comment="啟用者 keycloak_user_id")
|
||||
disabled_at = Column(DateTime, nullable=True, comment="停用時間(軟刪除)")
|
||||
disabled_by = Column(String(36), nullable=True, comment="停用者 keycloak_user_id")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
service = relationship("PersonalService")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmpPersonalServiceSetting user={self.keycloak_user_id} service={self.service_id}>"
|
||||
69
backend/app/models/emp_resume.py
Normal file
69
backend/app/models/emp_resume.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
員工履歷資料 Model (人員基本檔)
|
||||
記錄員工的個人資料、教育背景等(與任用無關的基本資料)
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmpResume(Base):
|
||||
"""員工履歷表(人員基本檔)"""
|
||||
|
||||
__tablename__ = "tenant_emp_resumes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_resume_seq"),
|
||||
UniqueConstraint("tenant_id", "id_number", name="uq_tenant_id_number"),
|
||||
Index("idx_emp_resume_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
|
||||
|
||||
# 個人基本資料
|
||||
legal_name = Column(String(100), nullable=False, comment="法定姓名")
|
||||
english_name = Column(String(100), nullable=True, comment="英文名稱")
|
||||
id_number = Column(String(20), nullable=False, comment="身分證字號/護照號碼")
|
||||
birth_date = Column(Date, nullable=True, comment="出生日期")
|
||||
gender = Column(String(10), nullable=True, comment="性別: M/F/Other")
|
||||
marital_status = Column(String(20), nullable=True, comment="婚姻狀況: single/married/divorced/widowed")
|
||||
nationality = Column(String(50), nullable=True, comment="國籍")
|
||||
|
||||
# 聯絡資訊
|
||||
phone = Column(String(20), nullable=True, comment="聯絡電話")
|
||||
mobile = Column(String(20), nullable=True, comment="手機")
|
||||
personal_email = Column(String(255), nullable=True, comment="個人郵箱")
|
||||
address = Column(Text, nullable=True, comment="通訊地址")
|
||||
emergency_contact = Column(String(100), nullable=True, comment="緊急聯絡人")
|
||||
emergency_phone = Column(String(20), nullable=True, comment="緊急聯絡電話")
|
||||
|
||||
# 教育背景
|
||||
education_level = Column(String(50), nullable=True, comment="學歷: high_school/bachelor/master/phd")
|
||||
school_name = Column(String(200), nullable=True, comment="畢業學校")
|
||||
major = Column(String(100), nullable=True, comment="主修科系")
|
||||
graduation_year = Column(Integer, nullable=True, comment="畢業年份")
|
||||
|
||||
# 備註
|
||||
notes = Column(Text, nullable=True, comment="備註")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
employment_setting = relationship(
|
||||
"EmpSetting",
|
||||
back_populates="resume",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmpResume {self.legal_name} ({self.id_number})>"
|
||||
86
backend/app/models/emp_setting.py
Normal file
86
backend/app/models/emp_setting.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
員工任用設定 Model (員工任用資料檔)
|
||||
記錄員工的任用資訊、職務、薪資等(與組織任用相關的資料)
|
||||
使用複合主鍵 (tenant_id, seq_no)
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmpSetting(Base):
|
||||
"""員工任用設定表(複合主鍵)"""
|
||||
|
||||
__tablename__ = "tenant_emp_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "tenant_resume_id", name="uq_tenant_resume_setting"),
|
||||
UniqueConstraint("tenant_id", "tenant_emp_code", name="uq_tenant_emp_code"),
|
||||
Index("idx_emp_setting_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
# 複合主鍵
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, primary_key=True, comment="租戶內序號 (觸發器自動生成)")
|
||||
|
||||
# 關聯人員基本檔
|
||||
tenant_resume_id = Column(Integer, ForeignKey("tenant_emp_resumes.id", ondelete="RESTRICT"), nullable=False,
|
||||
comment="人員基本檔 ID(一個人只有一筆任用設定)")
|
||||
|
||||
# 員工編號(自動生成)
|
||||
tenant_emp_code = Column(String(20), nullable=False, index=True,
|
||||
comment="員工編號(自動生成,格式: prefix + seq_no,例如 PWD0001)")
|
||||
|
||||
# SSO 整合
|
||||
tenant_keycloak_user_id = Column(String(36), unique=True, nullable=True, index=True,
|
||||
comment="Keycloak User UUID (唯一 SSO 識別碼,永久不變)")
|
||||
tenant_keycloak_username = Column(String(100), unique=True, nullable=True,
|
||||
comment="Keycloak 登入帳號")
|
||||
|
||||
# 任用資訊
|
||||
hire_at = Column(Date, nullable=False, comment="到職日期")
|
||||
resign_date = Column(Date, nullable=True, comment="離職日期")
|
||||
job_title = Column(String(100), nullable=True, comment="職稱")
|
||||
employment_type = Column(String(50), nullable=False, default="full_time",
|
||||
comment="任用類型: full_time/part_time/contractor/intern")
|
||||
|
||||
# 薪資資訊(加密儲存)
|
||||
salary_amount = Column(Integer, nullable=True, comment="月薪(加密)")
|
||||
salary_currency = Column(String(10), default="TWD", comment="薪資幣別")
|
||||
|
||||
# 主要部門(員工可屬於多個部門,但有一個主要部門)
|
||||
primary_dept_id = Column(Integer, ForeignKey("tenant_departments.id", ondelete="SET NULL"), nullable=True,
|
||||
comment="主要部門 ID")
|
||||
|
||||
# 個人化服務配額設定
|
||||
storage_quota_gb = Column(Integer, default=20, nullable=False, comment="儲存配額 (GB) - Drive 使用")
|
||||
email_quota_mb = Column(Integer, default=5120, nullable=False, comment="郵件配額 (MB) - Email 使用")
|
||||
|
||||
# 狀態
|
||||
employment_status = Column(String(20), default="active", nullable=False,
|
||||
comment="任用狀態: active/on_leave/resigned/terminated")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
resume = relationship("EmpResume", back_populates="employment_setting")
|
||||
primary_department = relationship("Department", foreign_keys=[primary_dept_id])
|
||||
|
||||
# 關聯:部門歸屬(多對多)- 透過 resume 的 employee 關聯
|
||||
# department_memberships 在 Employee Model 中定義
|
||||
|
||||
# 關聯:角色分配(多對多)- 透過 keycloak_user_id 查詢
|
||||
# user_role_assignments 在 UserRoleAssignment Model 中定義
|
||||
|
||||
# 關聯:個人化服務設定(多對多)- 透過 keycloak_user_id 查詢
|
||||
# personal_service_settings 在 EmpPersonalServiceSetting Model 中定義
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmpSetting {self.tenant_emp_code} (tenant_id={self.tenant_id}, seq_no={self.seq_no})>"
|
||||
85
backend/app/models/employee.py
Normal file
85
backend/app/models/employee.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
員工基本資料 Model
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Enum, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmployeeStatus(str, enum.Enum):
|
||||
"""員工狀態"""
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
TERMINATED = "terminated"
|
||||
|
||||
|
||||
class Employee(Base):
|
||||
"""員工基本資料表"""
|
||||
|
||||
__tablename__ = "tenant_employees"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", name="uq_tenant_employee_id"),
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_seq_no"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (自動從1開始)")
|
||||
employee_id = Column(String(20), nullable=False, index=True, comment="員工編號 (EMP001, 租戶內唯一,永久不變)")
|
||||
keycloak_user_id = Column(String(36), unique=True, nullable=True, index=True,
|
||||
comment="Keycloak User UUID (唯一 SSO 識別碼,永久不變,一個員工只有一個)")
|
||||
username_base = Column(String(50), unique=True, nullable=False, index=True, comment="基礎帳號名稱 (全系統唯一)")
|
||||
legal_name = Column(String(100), nullable=False, comment="法定姓名")
|
||||
english_name = Column(String(100), comment="英文名稱")
|
||||
phone = Column(String(20), comment="電話")
|
||||
mobile = Column(String(20), comment="手機")
|
||||
hire_date = Column(Date, nullable=False, comment="到職日期")
|
||||
status = Column(
|
||||
String(20),
|
||||
default=EmployeeStatus.ACTIVE,
|
||||
nullable=False,
|
||||
comment="狀態 (active/inactive/terminated)"
|
||||
)
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="employees")
|
||||
department_memberships = relationship(
|
||||
"DepartmentMember",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
email_accounts = relationship(
|
||||
"EmailAccount",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
permissions = relationship(
|
||||
"Permission",
|
||||
foreign_keys="Permission.employee_id",
|
||||
back_populates="employee",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
network_drive = relationship(
|
||||
"NetworkDrive",
|
||||
back_populates="employee",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Employee {self.employee_id} - {self.legal_name}>"
|
||||
|
||||
# is_active 已改為資料庫欄位,移除 @property
|
||||
66
backend/app/models/employee_identity.py.deprecated
Normal file
66
backend/app/models/employee_identity.py.deprecated
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
員工身份 Model
|
||||
一個員工可以在多個事業部任職,每個事業部對應一個身份
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class EmployeeIdentity(Base):
|
||||
"""員工身份表"""
|
||||
|
||||
__tablename__ = "employee_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "employee_id", "business_unit_id", name="uq_tenant_emp_bu"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True, comment="租戶 ID")
|
||||
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# SSO 帳號 (= 郵件地址)
|
||||
username = Column(String(100), unique=True, nullable=False, index=True, comment="SSO 帳號 (porsche.chen@lab.taipei)")
|
||||
keycloak_id = Column(String(100), unique=True, nullable=False, index=True, comment="Keycloak UUID")
|
||||
|
||||
# 組織與職務
|
||||
business_unit_id = Column(Integer, ForeignKey("business_units.id"), nullable=False, index=True)
|
||||
department_id = Column(Integer, ForeignKey("departments.id"), nullable=True, index=True)
|
||||
job_title = Column(String(100), nullable=False, comment="職稱")
|
||||
job_level = Column(String(20), nullable=False, comment="職級 (Junior/Mid/Senior/Manager)")
|
||||
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要身份")
|
||||
|
||||
# 郵件配額
|
||||
email_quota_mb = Column(Integer, nullable=False, comment="郵件配額 (MB)")
|
||||
|
||||
# 時間記錄
|
||||
started_at = Column(Date, nullable=False, comment="開始日期")
|
||||
ended_at = Column(Date, nullable=True, comment="結束日期")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
employee = relationship("Employee", back_populates="identities")
|
||||
business_unit = relationship("BusinessUnit", back_populates="employee_identities")
|
||||
department = relationship("Department") # back_populates 已移除 (employee_identities 廢棄)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmployeeIdentity {self.username}>"
|
||||
|
||||
@property
|
||||
def email(self) -> str:
|
||||
"""郵件地址 (= SSO 帳號)"""
|
||||
return self.username
|
||||
|
||||
@property
|
||||
def is_cross_department(self) -> bool:
|
||||
"""是否跨部門任職 (檢查同一員工是否有其他身份)"""
|
||||
return len(self.employee.identities) > 1
|
||||
|
||||
def generate_username(self, username_base: str, email_domain: str) -> str:
|
||||
"""生成 SSO 帳號"""
|
||||
return f"{username_base}@{email_domain}"
|
||||
362
backend/app/models/installation.py
Normal file
362
backend/app/models/installation.py
Normal file
@@ -0,0 +1,362 @@
|
||||
"""
|
||||
Installation System Models
|
||||
初始化系統資料模型
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Boolean, Text, TIMESTAMP, ForeignKey, ARRAY
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class InstallationSession(Base):
|
||||
"""安裝會話"""
|
||||
__tablename__ = "installation_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
session_name = Column(String(200))
|
||||
environment = Column(String(20)) # development/testing/production
|
||||
|
||||
# 狀態追蹤
|
||||
started_at = Column(TIMESTAMP, default=datetime.now)
|
||||
completed_at = Column(TIMESTAMP)
|
||||
status = Column(String(20), default='in_progress') # in_progress/completed/failed/paused
|
||||
|
||||
# 進度統計
|
||||
total_checklist_items = Column(Integer)
|
||||
passed_checklist_items = Column(Integer, default=0)
|
||||
failed_checklist_items = Column(Integer, default=0)
|
||||
total_steps = Column(Integer)
|
||||
completed_steps = Column(Integer, default=0)
|
||||
failed_steps = Column(Integer, default=0)
|
||||
|
||||
executed_by = Column(String(100))
|
||||
|
||||
# 存取控制
|
||||
is_locked = Column(Boolean, default=False)
|
||||
locked_at = Column(TIMESTAMP)
|
||||
locked_by = Column(String(100))
|
||||
lock_reason = Column(String(200))
|
||||
|
||||
is_unlocked = Column(Boolean, default=False)
|
||||
unlocked_at = Column(TIMESTAMP)
|
||||
unlocked_by = Column(String(100))
|
||||
unlock_reason = Column(String(200))
|
||||
unlock_expires_at = Column(TIMESTAMP)
|
||||
|
||||
last_viewed_at = Column(TIMESTAMP)
|
||||
last_viewed_by = Column(String(100))
|
||||
view_count = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
tenant_info = relationship("InstallationTenantInfo", back_populates="session", uselist=False)
|
||||
department_setups = relationship("InstallationDepartmentSetup", back_populates="session")
|
||||
temporary_passwords = relationship("TemporaryPassword", back_populates="session")
|
||||
access_logs = relationship("InstallationAccessLog", back_populates="session")
|
||||
checklist_results = relationship("InstallationChecklistResult", back_populates="session")
|
||||
installation_logs = relationship("InstallationLog", back_populates="session")
|
||||
|
||||
|
||||
class InstallationChecklistItem(Base):
|
||||
"""檢查項目定義(系統級)"""
|
||||
__tablename__ = "installation_checklist_items"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category = Column(String(50), nullable=False) # hardware/network/software/container/security
|
||||
item_code = Column(String(100), unique=True, nullable=False)
|
||||
item_name = Column(String(200), nullable=False)
|
||||
check_type = Column(String(50), nullable=False) # command/api/config/manual
|
||||
check_command = Column(Text) # 自動檢查命令
|
||||
expected_value = Column(Text)
|
||||
min_requirement = Column(Text)
|
||||
recommended_value = Column(Text)
|
||||
is_required = Column(Boolean, default=True)
|
||||
sequence_order = Column(Integer, nullable=False)
|
||||
description = Column(Text)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
results = relationship("InstallationChecklistResult", back_populates="checklist_item")
|
||||
|
||||
|
||||
class InstallationChecklistResult(Base):
|
||||
"""檢查結果(租戶級)"""
|
||||
__tablename__ = "installation_checklist_results"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
|
||||
checklist_item_id = Column(Integer, ForeignKey("installation_checklist_items.id", ondelete="CASCADE"), nullable=False)
|
||||
status = Column(String(20), nullable=False) # pass/fail/warning/pending/skip
|
||||
actual_value = Column(Text)
|
||||
checked_at = Column(TIMESTAMP)
|
||||
checked_by = Column(String(100))
|
||||
auto_checked = Column(Boolean, default=False)
|
||||
remarks = Column(Text)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
session = relationship("InstallationSession", back_populates="checklist_results")
|
||||
checklist_item = relationship("InstallationChecklistItem", back_populates="results")
|
||||
|
||||
|
||||
class InstallationStep(Base):
|
||||
"""安裝步驟定義(系統級)"""
|
||||
__tablename__ = "installation_steps"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
step_code = Column(String(50), unique=True, nullable=False)
|
||||
step_name = Column(String(200), nullable=False)
|
||||
phase = Column(String(20), nullable=False) # phase1/phase2/...
|
||||
sequence_order = Column(Integer, nullable=False)
|
||||
description = Column(Text)
|
||||
execution_type = Column(String(50)) # auto/manual/script
|
||||
execution_script = Column(Text)
|
||||
depends_on_steps = Column(ARRAY(String)) # 依賴的步驟代碼
|
||||
is_required = Column(Boolean, default=True)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
logs = relationship("InstallationLog", back_populates="step")
|
||||
|
||||
|
||||
class InstallationLog(Base):
|
||||
"""安裝執行記錄(租戶級)"""
|
||||
__tablename__ = "installation_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
step_id = Column(Integer, ForeignKey("installation_steps.id", ondelete="CASCADE"), nullable=False)
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
|
||||
status = Column(String(20), nullable=False) # pending/running/success/failed/skipped
|
||||
started_at = Column(TIMESTAMP)
|
||||
completed_at = Column(TIMESTAMP)
|
||||
executed_by = Column(String(100))
|
||||
execution_method = Column(String(50)) # manual/auto/api/script
|
||||
result_data = Column(JSONB)
|
||||
error_message = Column(Text)
|
||||
retry_count = Column(Integer, default=0)
|
||||
remarks = Column(Text)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
step = relationship("InstallationStep", back_populates="logs")
|
||||
session = relationship("InstallationSession", back_populates="installation_logs")
|
||||
|
||||
|
||||
class InstallationTenantInfo(Base):
|
||||
"""租戶初始化資訊"""
|
||||
__tablename__ = "installation_tenant_info"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True, unique=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
|
||||
|
||||
# 公司基本資訊
|
||||
company_name = Column(String(200))
|
||||
company_name_en = Column(String(200))
|
||||
tenant_code = Column(String(50)) # 租戶代碼 = Keycloak Realm
|
||||
tenant_prefix = Column(String(10)) # 員工編號前綴
|
||||
tax_id = Column(String(50))
|
||||
industry = Column(String(100))
|
||||
company_size = Column(String(20)) # small/medium/large
|
||||
|
||||
# 聯絡資訊
|
||||
tel = Column(String(20)) # 公司電話(對應 tenants.tel)
|
||||
phone = Column(String(50))
|
||||
fax = Column(String(50))
|
||||
email = Column(String(200))
|
||||
website = Column(String(200))
|
||||
add = Column(Text) # 公司地址(對應 tenants.add)
|
||||
address = Column(Text)
|
||||
address_en = Column(Text)
|
||||
|
||||
# 郵件網域設定
|
||||
domain_set = Column(Integer, default=2) # 1=組織網域, 2=部門網域
|
||||
domain = Column(String(100)) # 組織網域(domain_set=1 時使用)
|
||||
|
||||
# 負責人資訊
|
||||
representative_name = Column(String(100))
|
||||
representative_title = Column(String(100))
|
||||
representative_email = Column(String(200))
|
||||
representative_phone = Column(String(50))
|
||||
|
||||
# 系統管理員資訊
|
||||
admin_employee_id = Column(String(50))
|
||||
admin_username = Column(String(100))
|
||||
admin_legal_name = Column(String(100))
|
||||
admin_english_name = Column(String(100))
|
||||
admin_email = Column(String(200))
|
||||
admin_phone = Column(String(50))
|
||||
|
||||
# 初始設定
|
||||
default_language = Column(String(10), default='zh-TW')
|
||||
timezone = Column(String(50), default='Asia/Taipei')
|
||||
date_format = Column(String(20), default='YYYY-MM-DD')
|
||||
currency = Column(String(10), default='TWD')
|
||||
|
||||
# 狀態追蹤
|
||||
is_completed = Column(Boolean, default=False)
|
||||
completed_at = Column(TIMESTAMP)
|
||||
completed_by = Column(String(100))
|
||||
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
session = relationship("InstallationSession", back_populates="tenant_info")
|
||||
|
||||
|
||||
class InstallationDepartmentSetup(Base):
|
||||
"""部門架構設定"""
|
||||
__tablename__ = "installation_department_setup"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"))
|
||||
department_code = Column(String(50), nullable=False)
|
||||
department_name = Column(String(200), nullable=False)
|
||||
department_name_en = Column(String(200))
|
||||
email_domain = Column(String(100))
|
||||
parent_code = Column(String(50))
|
||||
depth = Column(Integer, default=0)
|
||||
manager_name = Column(String(100))
|
||||
is_created = Column(Boolean, default=False)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant relationship,因為沒有 FK constraint
|
||||
session = relationship("InstallationSession", back_populates="department_setups")
|
||||
|
||||
|
||||
class TemporaryPassword(Base):
|
||||
"""臨時密碼"""
|
||||
__tablename__ = "temporary_passwords"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 tenants 表可能不存在
|
||||
employee_id = Column(Integer, nullable=True) # 不設外鍵,初始化時 employees 表可能不存在
|
||||
username = Column(String(100), nullable=False)
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
|
||||
|
||||
# 密碼資訊
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
plain_password = Column(String(100)) # 明文密碼(僅初始化階段)
|
||||
password_method = Column(String(20)) # auto/manual
|
||||
is_temporary = Column(Boolean, default=True)
|
||||
must_change_on_login = Column(Boolean, default=True)
|
||||
|
||||
# 有效期限
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
expires_at = Column(TIMESTAMP)
|
||||
|
||||
# 使用狀態
|
||||
is_used = Column(Boolean, default=False)
|
||||
used_at = Column(TIMESTAMP)
|
||||
first_login_at = Column(TIMESTAMP)
|
||||
password_changed_at = Column(TIMESTAMP)
|
||||
|
||||
# 查看控制
|
||||
is_viewable = Column(Boolean, default=True)
|
||||
viewable_until = Column(TIMESTAMP)
|
||||
view_count = Column(Integer, default=0)
|
||||
last_viewed_at = Column(TIMESTAMP)
|
||||
first_viewed_at = Column(TIMESTAMP)
|
||||
|
||||
# 明文密碼清除記錄
|
||||
plain_password_cleared_at = Column(TIMESTAMP)
|
||||
cleared_reason = Column(String(100))
|
||||
|
||||
# Relationships
|
||||
# 不定義 tenant 和 employee relationship,因為沒有 FK constraint
|
||||
session = relationship("InstallationSession", back_populates="temporary_passwords")
|
||||
|
||||
|
||||
class InstallationAccessLog(Base):
|
||||
"""存取審計日誌"""
|
||||
__tablename__ = "installation_access_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="CASCADE"), nullable=False)
|
||||
action = Column(String(50), nullable=False) # lock/unlock/view/download_pdf
|
||||
action_by = Column(String(100))
|
||||
action_method = Column(String(50)) # database/api/system
|
||||
ip_address = Column(String(50))
|
||||
user_agent = Column(Text)
|
||||
access_granted = Column(Boolean)
|
||||
deny_reason = Column(String(200))
|
||||
sensitive_data_accessed = Column(ARRAY(String))
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
session = relationship("InstallationSession", back_populates="access_logs")
|
||||
|
||||
|
||||
class InstallationEnvironmentConfig(Base):
|
||||
"""環境配置記錄"""
|
||||
__tablename__ = "installation_environment_config"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_id = Column(Integer, ForeignKey("installation_sessions.id", ondelete="SET NULL"))
|
||||
config_key = Column(String(100), unique=True, nullable=False, index=True)
|
||||
config_value = Column(Text)
|
||||
config_category = Column(String(50), nullable=False, index=True) # redis/database/keycloak/mailserver/nextcloud/traefik
|
||||
is_sensitive = Column(Boolean, default=False) # 是否為敏感資訊(密碼等)
|
||||
is_configured = Column(Boolean, default=False)
|
||||
configured_at = Column(TIMESTAMP)
|
||||
configured_by = Column(String(100))
|
||||
description = Column(Text)
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
session = relationship("InstallationSession")
|
||||
|
||||
|
||||
class InstallationSystemStatus(Base):
|
||||
"""系統狀態記錄(三階段:Initialization/Operational/Transition)"""
|
||||
__tablename__ = "installation_system_status"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
current_phase = Column(String(20), nullable=False, index=True) # initialization/operational/transition
|
||||
previous_phase = Column(String(20))
|
||||
phase_changed_at = Column(TIMESTAMP)
|
||||
phase_changed_by = Column(String(100))
|
||||
phase_change_reason = Column(Text)
|
||||
|
||||
# Initialization 階段資訊
|
||||
initialized_at = Column(TIMESTAMP)
|
||||
initialized_by = Column(String(100))
|
||||
initialization_completed = Column(Boolean, default=False)
|
||||
|
||||
# Operational 階段資訊
|
||||
last_health_check_at = Column(TIMESTAMP)
|
||||
health_check_status = Column(String(20)) # healthy/degraded/unhealthy
|
||||
operational_since = Column(TIMESTAMP)
|
||||
|
||||
# Transition 階段資訊
|
||||
transition_started_at = Column(TIMESTAMP)
|
||||
transition_approved_by = Column(String(100))
|
||||
env_db_consistent = Column(Boolean)
|
||||
consistency_checked_at = Column(TIMESTAMP)
|
||||
inconsistencies = Column(Text) # JSON 格式
|
||||
|
||||
# 系統鎖定
|
||||
is_locked = Column(Boolean, default=False)
|
||||
locked_at = Column(TIMESTAMP)
|
||||
locked_by = Column(String(100))
|
||||
lock_reason = Column(String(200))
|
||||
|
||||
created_at = Column(TIMESTAMP, default=datetime.now)
|
||||
updated_at = Column(TIMESTAMP, default=datetime.now, onupdate=datetime.now)
|
||||
107
backend/app/models/invoice.py
Normal file
107
backend/app/models/invoice.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
發票記錄 Model
|
||||
管理租戶的帳單和發票
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class InvoiceStatus(str, enum.Enum):
|
||||
"""發票狀態"""
|
||||
PENDING = "pending" # 待付款
|
||||
PAID = "paid" # 已付款
|
||||
OVERDUE = "overdue" # 逾期未付
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
|
||||
|
||||
class Invoice(Base):
|
||||
"""發票記錄表"""
|
||||
|
||||
__tablename__ = "invoices"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 發票資訊
|
||||
invoice_number = Column(String(50), unique=True, nullable=False, index=True, comment="發票號碼 (INV-2026-03-001)")
|
||||
issue_date = Column(Date, nullable=False, comment="開立日期")
|
||||
due_date = Column(Date, nullable=False, comment="到期日")
|
||||
|
||||
# 金額
|
||||
amount = Column(Numeric(10, 2), nullable=False, comment="金額 (未稅)")
|
||||
tax = Column(Numeric(10, 2), default=0, nullable=False, comment="稅額")
|
||||
total = Column(Numeric(10, 2), nullable=False, comment="總計 (含稅)")
|
||||
|
||||
# 狀態
|
||||
status = Column(String(20), default=InvoiceStatus.PENDING, nullable=False, comment="狀態")
|
||||
|
||||
# 付款資訊
|
||||
paid_at = Column(DateTime, nullable=True, comment="付款時間")
|
||||
payment_method = Column(String(20), nullable=True, comment="付款方式 (credit_card/wire_transfer)")
|
||||
|
||||
# 發票明細 (JSON 格式)
|
||||
line_items = Column(JSONB, nullable=True, comment="發票明細")
|
||||
# 範例:
|
||||
# [
|
||||
# {"description": "標準方案 (20 人)", "quantity": 1, "unit_price": 10000, "amount": 10000},
|
||||
# {"description": "超額用戶 (2 人)", "quantity": 2, "unit_price": 500, "amount": 1000}
|
||||
# ]
|
||||
|
||||
# PDF 檔案
|
||||
pdf_path = Column(String(200), nullable=True, comment="發票 PDF 路徑")
|
||||
|
||||
# 備註
|
||||
notes = Column(Text, nullable=True, comment="備註")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant")
|
||||
payments = relationship(
|
||||
"Payment",
|
||||
back_populates="invoice",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Invoice {self.invoice_number} - NT$ {self.total} ({self.status})>"
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
"""是否已付款"""
|
||||
return self.status == InvoiceStatus.PAID
|
||||
|
||||
@property
|
||||
def is_overdue(self) -> bool:
|
||||
"""是否逾期"""
|
||||
return (
|
||||
self.status in [InvoiceStatus.PENDING, InvoiceStatus.OVERDUE] and
|
||||
date.today() > self.due_date
|
||||
)
|
||||
|
||||
@property
|
||||
def days_overdue(self) -> int:
|
||||
"""逾期天數"""
|
||||
if not self.is_overdue:
|
||||
return 0
|
||||
return (date.today() - self.due_date).days
|
||||
|
||||
def mark_as_paid(self, payment_method: str = None):
|
||||
"""標記為已付款"""
|
||||
self.status = InvoiceStatus.PAID
|
||||
self.paid_at = datetime.utcnow()
|
||||
if payment_method:
|
||||
self.payment_method = payment_method
|
||||
|
||||
@classmethod
|
||||
def generate_invoice_number(cls, year: int, month: int, sequence: int) -> str:
|
||||
"""生成發票號碼"""
|
||||
return f"INV-{year:04d}-{month:02d}-{sequence:03d}"
|
||||
68
backend/app/models/network_drive.py
Normal file
68
backend/app/models/network_drive.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
網路硬碟 Model
|
||||
一個員工對應一個 NAS 帳號
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class NetworkDrive(Base):
|
||||
"""網路硬碟表"""
|
||||
|
||||
__tablename__ = "tenant_network_drives"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", name="uq_network_drive_employee"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
employee_id = Column(Integer, ForeignKey("tenant_employees.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||
|
||||
# 一個員工只有一個 NAS 帳號
|
||||
drive_name = Column(String(100), unique=True, nullable=False, comment="NAS 帳號名稱 (與 username_base 相同)")
|
||||
quota_gb = Column(Integer, nullable=False, comment="配額 (GB),取所有身份中的最高職級")
|
||||
|
||||
# 訪問路徑
|
||||
webdav_url = Column(String(255), comment="WebDAV 路徑")
|
||||
smb_url = Column(String(255), comment="SMB 路徑")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
employee = relationship("Employee", back_populates="network_drive")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NetworkDrive {self.drive_name} - {self.quota_gb}GB>"
|
||||
|
||||
@property
|
||||
def webdav_path(self) -> str:
|
||||
"""WebDAV 完整路徑"""
|
||||
return self.webdav_url or f"https://nas.lab.taipei/webdav/{self.drive_name}"
|
||||
|
||||
@property
|
||||
def smb_path(self) -> str:
|
||||
"""SMB 完整路徑"""
|
||||
return self.smb_url or f"\\\\10.1.0.30\\{self.drive_name}"
|
||||
|
||||
def update_quota_from_job_level(self, job_level: str) -> None:
|
||||
"""根據職級更新配額"""
|
||||
from app.core.config import settings
|
||||
|
||||
quota_mapping = {
|
||||
"Junior": settings.NAS_QUOTA_JUNIOR,
|
||||
"Mid": settings.NAS_QUOTA_MID,
|
||||
"Senior": settings.NAS_QUOTA_SENIOR,
|
||||
"Manager": settings.NAS_QUOTA_MANAGER,
|
||||
}
|
||||
|
||||
new_quota = quota_mapping.get(job_level, settings.NAS_QUOTA_JUNIOR)
|
||||
|
||||
# 只在配額增加時更新 (不降低配額)
|
||||
if new_quota > self.quota_gb:
|
||||
self.quota_gb = new_quota
|
||||
51
backend/app/models/payment.py
Normal file
51
backend/app/models/payment.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
付款記錄 Model
|
||||
記錄所有付款交易
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PaymentStatus(str, enum.Enum):
|
||||
"""付款狀態"""
|
||||
SUCCESS = "success" # 成功
|
||||
FAILED = "failed" # 失敗
|
||||
PENDING = "pending" # 處理中
|
||||
REFUNDED = "refunded" # 已退款
|
||||
|
||||
|
||||
class Payment(Base):
|
||||
"""付款記錄表"""
|
||||
|
||||
__tablename__ = "payments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
invoice_id = Column(Integer, ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 付款資訊
|
||||
amount = Column(Numeric(10, 2), nullable=False, comment="付款金額")
|
||||
payment_method = Column(String(20), nullable=False, comment="付款方式 (credit_card/wire_transfer/cash)")
|
||||
transaction_id = Column(String(100), nullable=True, comment="金流交易編號")
|
||||
status = Column(String(20), default=PaymentStatus.PENDING, nullable=False, comment="狀態")
|
||||
|
||||
# 時間記錄
|
||||
paid_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="付款時間")
|
||||
|
||||
# 備註
|
||||
notes = Column(Text, nullable=True, comment="備註")
|
||||
|
||||
# 關聯
|
||||
invoice = relationship("Invoice", back_populates="payments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Payment NT$ {self.amount} - {self.status}>"
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
"""是否付款成功"""
|
||||
return self.status == PaymentStatus.SUCCESS
|
||||
112
backend/app/models/permission.py
Normal file
112
backend/app/models/permission.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
系統權限 Model
|
||||
管理員工在各系統的存取權限 (Gitea, Portainer, etc.)
|
||||
符合設計文件規範: HR Portal設計文件.md
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Index, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""系統權限表"""
|
||||
|
||||
__tablename__ = "tenant_permissions"
|
||||
__table_args__ = (
|
||||
# 同一員工在同一系統只能有一個權限記錄
|
||||
UniqueConstraint("employee_id", "system_name", name="uq_employee_system"),
|
||||
# 索引
|
||||
Index("idx_permissions_employee", "employee_id"),
|
||||
Index("idx_permissions_tenant", "tenant_id"),
|
||||
Index("idx_permissions_system", "system_name"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="租戶 ID"
|
||||
)
|
||||
employee_id = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_employees.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="員工 ID"
|
||||
)
|
||||
|
||||
# 權限設定
|
||||
system_name = Column(
|
||||
String(100),
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="系統名稱 (gitea, portainer, traefik, keycloak)"
|
||||
)
|
||||
access_level = Column(
|
||||
String(50),
|
||||
default='user',
|
||||
nullable=False,
|
||||
comment="存取層級 (admin/user/readonly)"
|
||||
)
|
||||
|
||||
# 授予資訊
|
||||
granted_at = Column(
|
||||
DateTime,
|
||||
default=datetime.utcnow,
|
||||
nullable=False,
|
||||
comment="授予時間"
|
||||
)
|
||||
granted_by = Column(
|
||||
Integer,
|
||||
ForeignKey("tenant_employees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
comment="授予人 (員工 ID)"
|
||||
)
|
||||
|
||||
# 通用欄位 (Note: Permission 表不需要 is_active,依靠 granted_at 判斷)
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
employee = relationship(
|
||||
"Employee",
|
||||
foreign_keys=[employee_id],
|
||||
back_populates="permissions"
|
||||
)
|
||||
granted_by_employee = relationship(
|
||||
"Employee",
|
||||
foreign_keys=[granted_by]
|
||||
)
|
||||
granter = relationship(
|
||||
"Employee",
|
||||
foreign_keys=[granted_by],
|
||||
viewonly=True,
|
||||
)
|
||||
tenant = relationship("Tenant")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission {self.system_name}:{self.access_level}>"
|
||||
|
||||
@classmethod
|
||||
def get_available_systems(cls) -> list[str]:
|
||||
"""取得可用的系統清單"""
|
||||
return [
|
||||
"gitea", # Git 代碼託管
|
||||
"portainer", # 容器管理
|
||||
"traefik", # 反向代理管理
|
||||
"keycloak", # SSO 管理
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def get_available_access_levels(cls) -> list[str]:
|
||||
"""取得可用的存取層級"""
|
||||
return [
|
||||
"admin", # 管理員 (完整控制)
|
||||
"user", # 一般使用者
|
||||
"readonly", # 唯讀
|
||||
]
|
||||
31
backend/app/models/personal_service.py
Normal file
31
backend/app/models/personal_service.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
個人化服務 Model
|
||||
定義可為員工啟用的個人服務(SSO、Email、Calendar、Drive、Office)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PersonalService(Base):
|
||||
"""個人化服務表"""
|
||||
|
||||
__tablename__ = "personal_services"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("service_code", name="uq_personal_service_code"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
service_code = Column(String(20), unique=True, nullable=False, comment="服務代碼: SSO/Email/Calendar/Drive/Office")
|
||||
service_name = Column(String(100), nullable=False, comment="服務名稱")
|
||||
description = Column(String(500), nullable=True, comment="服務說明")
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
|
||||
# 通用欄位
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PersonalService {self.service_code} - {self.service_name}>"
|
||||
120
backend/app/models/role.py
Normal file
120
backend/app/models/role.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
RBAC 角色相關 Models
|
||||
- UserRole: 租戶層級角色 (不綁定部門)
|
||||
- RoleRight: 角色對系統功能的 CRUD 權限
|
||||
- UserRoleAssignment: 使用者角色分配 (直接對人,跨部門有效)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""角色表 (租戶層級,不綁定部門)"""
|
||||
|
||||
__tablename__ = "tenant_user_roles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "seq_no", name="uq_tenant_role_seq"),
|
||||
UniqueConstraint("tenant_id", "role_code", name="uq_tenant_role_code"),
|
||||
Index("idx_roles_tenant", "tenant_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
seq_no = Column(Integer, nullable=False, comment="租戶內序號 (觸發器自動生成)")
|
||||
role_code = Column(String(100), nullable=False, comment="角色代碼 (租戶內唯一,例如 HR_ADMIN)")
|
||||
role_name = Column(String(200), nullable=False, comment="角色名稱")
|
||||
description = Column(Text, nullable=True, comment="角色說明")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="user_roles")
|
||||
rights = relationship("RoleRight", back_populates="role", cascade="all, delete-orphan", lazy="selectin")
|
||||
user_assignments = relationship("UserRoleAssignment", back_populates="role", cascade="all, delete-orphan",
|
||||
lazy="dynamic")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole {self.role_code} - {self.role_name}>"
|
||||
|
||||
|
||||
class RoleRight(Base):
|
||||
"""角色功能權限表 (Role and System Right)"""
|
||||
|
||||
__tablename__ = "tenant_role_rights"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("role_id", "function_id", name="uq_role_function"),
|
||||
Index("idx_role_rights_role", "role_id"),
|
||||
Index("idx_role_rights_function", "function_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role_id = Column(Integer, ForeignKey("tenant_user_roles.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="角色 ID")
|
||||
function_id = Column(Integer, ForeignKey("system_functions_cache.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="系統功能 ID")
|
||||
can_read = Column(Boolean, default=False, nullable=False, comment="查詢權限")
|
||||
can_create = Column(Boolean, default=False, nullable=False, comment="新增權限")
|
||||
can_update = Column(Boolean, default=False, nullable=False, comment="修改權限")
|
||||
can_delete = Column(Boolean, default=False, nullable=False, comment="刪除權限")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
role = relationship("UserRole", back_populates="rights")
|
||||
function = relationship("SystemFunctionCache")
|
||||
|
||||
def __repr__(self):
|
||||
perms = []
|
||||
if self.can_read: perms.append("R")
|
||||
if self.can_create: perms.append("C")
|
||||
if self.can_update: perms.append("U")
|
||||
if self.can_delete: perms.append("D")
|
||||
return f"<RoleRight role={self.role_id} fn={self.function_id} [{','.join(perms)}]>"
|
||||
|
||||
|
||||
class UserRoleAssignment(Base):
|
||||
"""使用者角色分配表 (直接對人,跨部門有效)"""
|
||||
|
||||
__tablename__ = "tenant_user_role_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("keycloak_user_id", "role_id", name="uq_user_role"),
|
||||
Index("idx_user_roles_tenant", "tenant_id"),
|
||||
Index("idx_user_roles_keycloak", "keycloak_user_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="租戶 ID")
|
||||
keycloak_user_id = Column(String(36), nullable=False, comment="Keycloak User UUID (永久識別碼)")
|
||||
role_id = Column(Integer, ForeignKey("tenant_user_roles.id", ondelete="CASCADE"), nullable=False,
|
||||
comment="角色 ID")
|
||||
|
||||
# 審計欄位(完整記錄)
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="分配時間")
|
||||
assigned_by = Column(String(36), nullable=True, comment="分配者 keycloak_user_id")
|
||||
revoked_at = Column(DateTime, nullable=True, comment="撤銷時間(軟刪除)")
|
||||
revoked_by = Column(String(36), nullable=True, comment="撤銷者 keycloak_user_id")
|
||||
|
||||
# 通用欄位
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
edit_by = Column(String(36), nullable=True, comment="最後編輯者 keycloak_user_id")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
role = relationship("UserRole", back_populates="user_assignments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRoleAssignment user={self.keycloak_user_id} role={self.role_id}>"
|
||||
77
backend/app/models/subscription.py
Normal file
77
backend/app/models/subscription.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
訂閱記錄 Model
|
||||
管理租戶的訂閱狀態和歷史
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, Boolean, ForeignKey, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SubscriptionStatus(str, enum.Enum):
|
||||
"""訂閱狀態"""
|
||||
ACTIVE = "active" # 進行中
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
EXPIRED = "expired" # 已過期
|
||||
|
||||
|
||||
class Subscription(Base):
|
||||
"""訂閱記錄表"""
|
||||
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 方案資訊
|
||||
plan_id = Column(String(50), nullable=False, comment="方案 ID (starter/standard/enterprise)")
|
||||
start_date = Column(Date, nullable=False, comment="開始日期")
|
||||
end_date = Column(Date, nullable=False, comment="結束日期")
|
||||
status = Column(String(20), default=SubscriptionStatus.ACTIVE, nullable=False, comment="狀態")
|
||||
|
||||
# 自動續約
|
||||
auto_renew = Column(Boolean, default=True, nullable=False, comment="是否自動續約")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
cancelled_at = Column(DateTime, nullable=True, comment="取消時間")
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="subscriptions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subscription {self.plan_id} for Tenant#{self.tenant_id} ({self.start_date} ~ {self.end_date})>"
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
"""是否為活躍訂閱"""
|
||||
today = date.today()
|
||||
return (
|
||||
self.status == SubscriptionStatus.ACTIVE and
|
||||
self.start_date <= today <= self.end_date
|
||||
)
|
||||
|
||||
@property
|
||||
def days_remaining(self) -> int:
|
||||
"""剩餘天數"""
|
||||
if not self.is_active:
|
||||
return 0
|
||||
return (self.end_date - date.today()).days
|
||||
|
||||
def renew(self, months: int = 1) -> "Subscription":
|
||||
"""續約 (創建新的訂閱記錄)"""
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
new_start = self.end_date + relativedelta(days=1)
|
||||
new_end = new_start + relativedelta(months=months) - relativedelta(days=1)
|
||||
|
||||
return Subscription(
|
||||
tenant_id=self.tenant_id,
|
||||
plan_id=self.plan_id,
|
||||
start_date=new_start,
|
||||
end_date=new_end,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
auto_renew=self.auto_renew
|
||||
)
|
||||
111
backend/app/models/system_function.py
Normal file
111
backend/app/models/system_function.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
SystemFunction Model
|
||||
系統功能明細檔
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SystemFunction(Base):
|
||||
"""系統功能明細"""
|
||||
__tablename__ = "system_functions"
|
||||
|
||||
# 1. 資料編號 (PK, 自動編號從 10 開始, 1~9 為功能設定編號)
|
||||
id = Column(Integer, primary_key=True, index=True, comment="資料編號")
|
||||
|
||||
# 2. 系統功能代碼/功能英文名稱
|
||||
code = Column(String(200), nullable=False, index=True, comment="系統功能代碼/功能英文名稱")
|
||||
|
||||
# 3. 上層功能代碼 (0 為初始層)
|
||||
upper_function_id = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
index=True,
|
||||
comment="上層功能代碼 (0為初始層)"
|
||||
)
|
||||
|
||||
# 4. 系統功能中文名稱
|
||||
name = Column(String(200), nullable=False, comment="系統功能中文名稱")
|
||||
|
||||
# 5. 系統功能類型 (1:node, 2:function)
|
||||
function_type = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="系統功能類型 (1:node, 2:function)"
|
||||
)
|
||||
|
||||
# 6. 系統功能次序
|
||||
order = Column(Integer, nullable=False, comment="系統功能次序")
|
||||
|
||||
# 7. 功能圖示
|
||||
function_icon = Column(
|
||||
String(200),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
comment="功能圖示"
|
||||
)
|
||||
|
||||
# 8. 功能模組名稱 (function_type=2 必填)
|
||||
module_code = Column(
|
||||
String(200),
|
||||
nullable=True,
|
||||
comment="功能模組名稱 (function_type=2 必填)"
|
||||
)
|
||||
|
||||
# 9. 模組項目 (JSON: [View, Create, Read, Update, Delete, Print, File])
|
||||
module_functions = Column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
server_default="[]",
|
||||
comment="模組項目 (View,Create,Read,Update,Delete,Print,File)"
|
||||
)
|
||||
|
||||
# 10. 說明 (富文本格式)
|
||||
description = Column(
|
||||
Text,
|
||||
nullable=False,
|
||||
server_default="",
|
||||
comment="說明 (富文本格式)"
|
||||
)
|
||||
|
||||
# 11. 系統管理
|
||||
is_mana = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="true",
|
||||
comment="系統管理"
|
||||
)
|
||||
|
||||
# 12. 啟用
|
||||
is_active = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="true",
|
||||
index=True,
|
||||
comment="啟用"
|
||||
)
|
||||
|
||||
# 13. 資料建立者
|
||||
edit_by = Column(Integer, nullable=False, comment="資料建立者")
|
||||
|
||||
# 14. 資料最新建立時間
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
comment="資料最新建立時間"
|
||||
)
|
||||
|
||||
# 15. 資料最新修改時間
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
onupdate=func.now(),
|
||||
comment="資料最新修改時間"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemFunction(id={self.id}, code={self.code}, name={self.name})>"
|
||||
31
backend/app/models/system_function_cache.py
Normal file
31
backend/app/models/system_function_cache.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
系統功能快取 Model
|
||||
從 System Admin 服務同步的系統功能定義 (只讀副本)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, UniqueConstraint, Index
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class SystemFunctionCache(Base):
|
||||
"""系統功能快取表"""
|
||||
|
||||
__tablename__ = "system_functions_cache"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("function_code", name="uq_function_code"),
|
||||
Index("idx_func_cache_service", "service_code"),
|
||||
Index("idx_func_cache_category", "function_category"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, comment="與 System Admin 的 id 一致")
|
||||
service_code = Column(String(50), nullable=False, comment="服務代碼: hr/erp/mail/ai")
|
||||
function_code = Column(String(100), nullable=False, comment="功能代碼: HR_EMPLOYEE_VIEW")
|
||||
function_name = Column(String(200), nullable=False, comment="功能名稱")
|
||||
function_category = Column(String(50), nullable=True,
|
||||
comment="功能分類: query/manage/approve/report")
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
synced_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="最後同步時間")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemFunctionCache {self.function_code} ({self.service_code})>"
|
||||
114
backend/app/models/tenant.py
Normal file
114
backend/app/models/tenant.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
租戶 Model
|
||||
多租戶 SaaS 的核心 - 每個客戶公司對應一個租戶
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Date, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class TenantStatus(str, enum.Enum):
|
||||
"""租戶狀態"""
|
||||
TRIAL = "trial" # 試用中
|
||||
ACTIVE = "active" # 正常使用
|
||||
SUSPENDED = "suspended" # 暫停 (逾期未付款)
|
||||
DELETED = "deleted" # 已刪除
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""租戶表 (客戶組織)"""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
# 基本欄位
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True, comment="租戶代碼 (英文,例如 porscheworld)")
|
||||
name = Column(String(200), nullable=False, comment="公司名稱")
|
||||
name_eng = Column(String(200), nullable=True, comment="公司英文名稱")
|
||||
|
||||
# SSO 整合
|
||||
keycloak_realm = Column(String(100), unique=True, nullable=True, index=True,
|
||||
comment="Keycloak Realm 名稱 (等同 code,每個組織一個獨立 Realm)")
|
||||
|
||||
# 公司資訊
|
||||
tax_id = Column(String(20), nullable=True, comment="統一編號")
|
||||
prefix = Column(String(10), nullable=False, default="ORG", comment="員工編號前綴 (例如 PWD → PWD0001)")
|
||||
domain = Column(String(100), nullable=True, comment="主網域 (例如 porscheworld.tw)")
|
||||
domain_set = Column(Text, nullable=True, comment="網域集合 (JSON Array,例如 [\"ease.taipei\", \"lab.taipei\"])")
|
||||
tel = Column(String(50), nullable=True, comment="公司電話")
|
||||
add = Column(String(500), nullable=True, comment="公司地址")
|
||||
url = Column(String(200), nullable=True, comment="公司網站")
|
||||
|
||||
# 訂閱與方案
|
||||
plan_id = Column(String(50), nullable=False, default="starter", comment="方案 ID (starter/standard/enterprise)")
|
||||
max_users = Column(Integer, nullable=False, default=5, comment="最大用戶數")
|
||||
storage_quota_gb = Column(Integer, nullable=False, default=100, comment="總儲存配額 (GB)")
|
||||
|
||||
# 狀態管理
|
||||
status = Column(String(20), default=TenantStatus.TRIAL, nullable=False, comment="狀態")
|
||||
is_sysmana = Column(Boolean, default=False, nullable=False, comment="是否為系統管理公司 (管理其他租戶)")
|
||||
is_active = Column(Boolean, default=True, nullable=False, comment="是否啟用")
|
||||
|
||||
# 初始化狀態
|
||||
is_initialized = Column(Boolean, default=False, nullable=False, comment="是否已完成初始化設定")
|
||||
initialized_at = Column(DateTime, nullable=True, comment="初始化完成時間")
|
||||
initialized_by = Column(String(255), nullable=True, comment="執行初始化的使用者名稱")
|
||||
|
||||
# 時間記錄(通用欄位)
|
||||
edit_by = Column(String(100), nullable=True, comment="最後編輯者")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, comment="建立時間")
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False, comment="更新時間")
|
||||
|
||||
# 關聯
|
||||
departments = relationship(
|
||||
"Department",
|
||||
back_populates="tenant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
employees = relationship(
|
||||
"Employee",
|
||||
back_populates="tenant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
user_roles = relationship(
|
||||
"UserRole",
|
||||
back_populates="tenant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Tenant {self.code} - {self.name}>"
|
||||
|
||||
# is_active 已改為資料庫欄位,移除 @property
|
||||
|
||||
@property
|
||||
def is_trial(self) -> bool:
|
||||
"""是否為試用狀態"""
|
||||
return self.status == TenantStatus.TRIAL
|
||||
|
||||
@property
|
||||
def total_users(self) -> int:
|
||||
"""總用戶數"""
|
||||
return self.employees.count()
|
||||
|
||||
@property
|
||||
def is_over_user_limit(self) -> bool:
|
||||
"""是否超過用戶數限制"""
|
||||
return self.total_users > self.max_users
|
||||
|
||||
@property
|
||||
def domains(self):
|
||||
"""網域列表(從 domain_set JSON 解析)"""
|
||||
if not self.domain_set:
|
||||
return []
|
||||
import json
|
||||
try:
|
||||
return json.loads(self.domain_set)
|
||||
except:
|
||||
return []
|
||||
119
backend/app/models/tenant_domain.py
Normal file
119
backend/app/models/tenant_domain.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
租戶網域 Model
|
||||
支援單一租戶使用多個網域 (多品牌/國際化)
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
import enum
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class DomainStatus(str, enum.Enum):
|
||||
"""網域狀態"""
|
||||
PENDING = "pending" # 待驗證
|
||||
ACTIVE = "active" # 啟用中
|
||||
DISABLED = "disabled" # 已停用
|
||||
|
||||
|
||||
class TenantDomain(Base):
|
||||
"""租戶網域表 (一個租戶可以有多個網域)"""
|
||||
|
||||
__tablename__ = "tenant_domains"
|
||||
__table_args__ = (
|
||||
# 每個租戶只能有一個主要網域
|
||||
Index("idx_tenant_primary_domain", "tenant_id", unique=True, postgresql_where=Column("is_primary") == True),
|
||||
# 一般索引
|
||||
Index("idx_tenant_domains_tenant", "tenant_id"),
|
||||
Index("idx_tenant_domains_status", "status"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
domain = Column(String(100), unique=True, nullable=False, index=True, comment="網域名稱 (abc.com.tw)")
|
||||
|
||||
# 網域屬性
|
||||
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要網域")
|
||||
status = Column(String(20), default=DomainStatus.PENDING, nullable=False, comment="狀態")
|
||||
verified = Column(Boolean, default=False, nullable=False, comment="DNS 驗證狀態")
|
||||
|
||||
# DNS 驗證
|
||||
verification_token = Column(String(100), nullable=True, comment="驗證 Token")
|
||||
verified_at = Column(DateTime, nullable=True, comment="驗證時間")
|
||||
|
||||
# 服務啟用狀態
|
||||
enable_email = Column(Boolean, default=True, nullable=False, comment="啟用郵件服務")
|
||||
enable_webmail = Column(Boolean, default=True, nullable=False, comment="啟用 WebMail")
|
||||
enable_drive = Column(Boolean, default=True, nullable=False, comment="啟用雲端硬碟")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
tenant = relationship("Tenant", back_populates="domains")
|
||||
email_aliases = relationship(
|
||||
"UserEmailAlias",
|
||||
back_populates="domain",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TenantDomain {self.domain} ({'主要' if self.is_primary else '次要'})>"
|
||||
|
||||
@property
|
||||
def is_verified(self) -> bool:
|
||||
"""是否已驗證"""
|
||||
return self.verified and self.status == DomainStatus.ACTIVE
|
||||
|
||||
def generate_dns_records(self) -> list:
|
||||
"""生成 DNS 驗證記錄指引"""
|
||||
records = []
|
||||
|
||||
# TXT 記錄 - 網域所有權驗證
|
||||
records.append({
|
||||
"type": "TXT",
|
||||
"name": "@",
|
||||
"value": f"porsche-cloud-verify={self.verification_token}",
|
||||
"purpose": "網域所有權驗證"
|
||||
})
|
||||
|
||||
if self.enable_email:
|
||||
# MX 記錄 - 郵件伺服器
|
||||
records.append({
|
||||
"type": "MX",
|
||||
"name": "@",
|
||||
"value": "mail.porschecloud.tw",
|
||||
"priority": 10,
|
||||
"purpose": "郵件伺服器"
|
||||
})
|
||||
|
||||
# SPF 記錄 - 防止郵件偽造
|
||||
records.append({
|
||||
"type": "TXT",
|
||||
"name": "@",
|
||||
"value": "v=spf1 include:porschecloud.tw ~all",
|
||||
"purpose": "郵件 SPF 記錄"
|
||||
})
|
||||
|
||||
if self.enable_webmail:
|
||||
# CNAME - WebMail
|
||||
records.append({
|
||||
"type": "CNAME",
|
||||
"name": "mail",
|
||||
"value": f"tenant-{self.tenant.tenant_code}.porschecloud.tw",
|
||||
"purpose": "WebMail 訪問"
|
||||
})
|
||||
|
||||
if self.enable_drive:
|
||||
# CNAME - 雲端硬碟
|
||||
records.append({
|
||||
"type": "CNAME",
|
||||
"name": "drive",
|
||||
"value": f"tenant-{self.tenant.tenant_code}.porschecloud.tw",
|
||||
"purpose": "雲端硬碟訪問"
|
||||
})
|
||||
|
||||
return records
|
||||
56
backend/app/models/usage_log.py
Normal file
56
backend/app/models/usage_log.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
使用量記錄 Model
|
||||
記錄租戶和用戶的資源使用情況
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UsageLog(Base):
|
||||
"""使用量記錄表 (每日統計)"""
|
||||
|
||||
__tablename__ = "usage_logs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "user_id", "date", name="uq_usage_tenant_user_date"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
date = Column(Date, nullable=False, index=True, comment="統計日期")
|
||||
|
||||
# 郵件使用量
|
||||
email_storage_gb = Column(Numeric(10, 2), default=0, nullable=False, comment="郵件儲存 (GB)")
|
||||
emails_sent = Column(Integer, default=0, nullable=False, comment="發送郵件數")
|
||||
emails_received = Column(Integer, default=0, nullable=False, comment="接收郵件數")
|
||||
|
||||
# 雲端硬碟使用量
|
||||
drive_storage_gb = Column(Numeric(10, 2), default=0, nullable=False, comment="硬碟儲存 (GB)")
|
||||
files_uploaded = Column(Integer, default=0, nullable=False, comment="上傳檔案數")
|
||||
files_downloaded = Column(Integer, default=0, nullable=False, comment="下載檔案數")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UsageLog Tenant#{self.tenant_id} User#{self.user_id} {self.date}>"
|
||||
|
||||
@property
|
||||
def total_storage_gb(self) -> float:
|
||||
"""總儲存使用量 (GB)"""
|
||||
return float(self.email_storage_gb) + float(self.drive_storage_gb)
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, tenant_id: int, user_id: int = None, log_date: date = None):
|
||||
"""獲取或創建當日記錄"""
|
||||
if log_date is None:
|
||||
log_date = date.today()
|
||||
|
||||
return cls(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
date=log_date
|
||||
)
|
||||
51
backend/app/models/user_email_alias.py
Normal file
51
backend/app/models/user_email_alias.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
用戶郵件別名 Model
|
||||
支援員工在不同網域擁有多個郵件地址
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UserEmailAlias(Base):
|
||||
"""用戶郵件別名表 (一個用戶可以有多個郵件地址)"""
|
||||
|
||||
__tablename__ = "user_email_aliases"
|
||||
__table_args__ = (
|
||||
# 每個用戶只能有一個主要郵件
|
||||
Index("idx_user_primary_email", "user_id", unique=True, postgresql_where=Column("is_primary") == True),
|
||||
# 一般索引
|
||||
Index("idx_email_aliases_user", "user_id"),
|
||||
Index("idx_email_aliases_tenant", "tenant_id"),
|
||||
Index("idx_email_aliases_domain", "domain_id"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
domain_id = Column(Integer, ForeignKey("tenant_domains.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
email = Column(String(150), unique=True, nullable=False, index=True, comment="郵件地址 (sales@brand-a.com)")
|
||||
is_primary = Column(Boolean, default=False, nullable=False, comment="是否為主要郵件地址")
|
||||
|
||||
# 時間記錄
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# 關聯
|
||||
user = relationship("Employee", back_populates="email_aliases")
|
||||
domain = relationship("TenantDomain", back_populates="email_aliases")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserEmailAlias {self.email} ({'主要' if self.is_primary else '別名'})>"
|
||||
|
||||
@property
|
||||
def local_part(self) -> str:
|
||||
"""郵件前綴 (@ 之前的部分)"""
|
||||
return self.email.split('@')[0] if '@' in self.email else self.email
|
||||
|
||||
@property
|
||||
def domain_part(self) -> str:
|
||||
"""網域部分 (@ 之後的部分)"""
|
||||
return self.email.split('@')[1] if '@' in self.email else ""
|
||||
Reference in New Issue
Block a user