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:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View 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",
]

View 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,
)

View 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}>"

View 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

View 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

View 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}>"

View 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)

View 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}>"

View 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})>"

View 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})>"

View 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

View 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}"

View 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)

View 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}"

View 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

View 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

View 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", # 唯讀
]

View 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
View 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}>"

View 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
)

View 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})>"

View 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})>"

View 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 []

View 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

View 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
)

View 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 ""