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

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

92
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,92 @@
from logging.config import fileConfig
import sys
from pathlib import Path
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add the parent directory to sys.path to import app modules
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Import settings and Base
from app.core.config import settings
from app.db.base import Base
# Import all models for Alembic to detect
# 使用統一的 models import自動包含所有 Models
from app.models import * # noqa: F403, F401
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Set the SQLAlchemy URL from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,114 @@
"""add tenants table for multi-tenant support
Revision ID: 0001_5
Revises: fba4e3f40f05
Create Date: 2026-02-15 19:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0001_5'
down_revision: Union[str, None] = 'fba4e3f40f05'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 創建 tenants 表
op.create_table(
'tenants',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False, comment='租戶代碼 (唯一)'),
sa.Column('name', sa.String(length=200), nullable=False, comment='租戶名稱'),
sa.Column('status', sa.String(length=20), nullable=False, server_default='active', comment='狀態'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
# 創建索引
op.create_index('idx_tenants_code', 'tenants', ['code'], unique=True)
op.create_index('idx_tenants_status', 'tenants', ['status'])
# 添加預設租戶 (Porsche World)
# 注意: keycloak_realm 欄位在 0005 migration 才加入,這裡先不設定
op.execute("""
INSERT INTO tenants (code, name, status)
VALUES ('porscheworld', 'Porsche World', 'active')
""")
# 為現有表添加 tenant_id 欄位
op.add_column('employees', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('business_units', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('departments', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('employee_identities', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('network_drives', sa.Column('tenant_id', sa.Integer(), nullable=True))
op.add_column('audit_logs', sa.Column('tenant_id', sa.Integer(), nullable=True))
# 將所有現有記錄設定為預設租戶
op.execute("UPDATE employees SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE business_units SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE departments SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE employee_identities SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE network_drives SET tenant_id = 1 WHERE tenant_id IS NULL")
op.execute("UPDATE audit_logs SET tenant_id = 1 WHERE tenant_id IS NULL")
# 將 tenant_id 設為 NOT NULL
op.alter_column('employees', 'tenant_id', nullable=False)
op.alter_column('business_units', 'tenant_id', nullable=False)
op.alter_column('departments', 'tenant_id', nullable=False)
op.alter_column('employee_identities', 'tenant_id', nullable=False)
op.alter_column('network_drives', 'tenant_id', nullable=False)
op.alter_column('audit_logs', 'tenant_id', nullable=False)
# 添加外鍵約束
op.create_foreign_key('fk_employees_tenant', 'employees', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_business_units_tenant', 'business_units', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_departments_tenant', 'departments', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_employee_identities_tenant', 'employee_identities', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_network_drives_tenant', 'network_drives', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_audit_logs_tenant', 'audit_logs', 'tenants', ['tenant_id'], ['id'], ondelete='CASCADE')
# 添加索引
op.create_index('idx_employees_tenant', 'employees', ['tenant_id'])
op.create_index('idx_business_units_tenant', 'business_units', ['tenant_id'])
op.create_index('idx_departments_tenant', 'departments', ['tenant_id'])
op.create_index('idx_employee_identities_tenant', 'employee_identities', ['tenant_id'])
op.create_index('idx_network_drives_tenant', 'network_drives', ['tenant_id'])
op.create_index('idx_audit_logs_tenant', 'audit_logs', ['tenant_id'])
def downgrade() -> None:
# 移除索引
op.drop_index('idx_audit_logs_tenant', table_name='audit_logs')
op.drop_index('idx_network_drives_tenant', table_name='network_drives')
op.drop_index('idx_employee_identities_tenant', table_name='employee_identities')
op.drop_index('idx_departments_tenant', table_name='departments')
op.drop_index('idx_business_units_tenant', table_name='business_units')
op.drop_index('idx_employees_tenant', table_name='employees')
# 移除外鍵
op.drop_constraint('fk_audit_logs_tenant', 'audit_logs', type_='foreignkey')
op.drop_constraint('fk_network_drives_tenant', 'network_drives', type_='foreignkey')
op.drop_constraint('fk_employee_identities_tenant', 'employee_identities', type_='foreignkey')
op.drop_constraint('fk_departments_tenant', 'departments', type_='foreignkey')
op.drop_constraint('fk_business_units_tenant', 'business_units', type_='foreignkey')
op.drop_constraint('fk_employees_tenant', 'employees', type_='foreignkey')
# 移除 tenant_id 欄位
op.drop_column('audit_logs', 'tenant_id')
op.drop_column('network_drives', 'tenant_id')
op.drop_column('employee_identities', 'tenant_id')
op.drop_column('departments', 'tenant_id')
op.drop_column('business_units', 'tenant_id')
op.drop_column('employees', 'tenant_id')
# 移除 tenants 表
op.drop_index('idx_tenants_status', table_name='tenants')
op.drop_index('idx_tenants_code', table_name='tenants')
op.drop_table('tenants')

View File

@@ -0,0 +1,81 @@
"""add email_accounts and permissions tables
Revision ID: 0002
Revises: fba4e3f40f05
Create Date: 2026-02-15 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0002'
down_revision: Union[str, None] = '0001_5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 創建 email_accounts 表
op.create_table(
'email_accounts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('employee_id', sa.Integer(), nullable=False, comment='員工 ID'),
sa.Column('email_address', sa.String(length=255), nullable=False, comment='郵件地址'),
sa.Column('quota_mb', sa.Integer(), nullable=False, server_default='2048', comment='配額 (MB)'),
sa.Column('forward_to', sa.String(length=255), nullable=True, comment='轉寄地址'),
sa.Column('auto_reply', sa.Text(), nullable=True, comment='自動回覆內容'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
)
# 創建索引
op.create_index('idx_email_accounts_email', 'email_accounts', ['email_address'], unique=True)
op.create_index('idx_email_accounts_employee', 'email_accounts', ['employee_id'])
op.create_index('idx_email_accounts_tenant', 'email_accounts', ['tenant_id'])
op.create_index('idx_email_accounts_active', 'email_accounts', ['is_active'])
# 創建 permissions 表
op.create_table(
'permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('employee_id', sa.Integer(), nullable=False, comment='員工 ID'),
sa.Column('system_name', sa.String(length=100), nullable=False, comment='系統名稱'),
sa.Column('access_level', sa.String(length=50), nullable=False, server_default='user', comment='存取層級'),
sa.Column('granted_at', sa.DateTime(), nullable=False, server_default=sa.text('now()'), comment='授予時間'),
sa.Column('granted_by', sa.Integer(), nullable=True, comment='授予人'),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['granted_by'], ['employees.id'], ondelete='SET NULL'),
sa.UniqueConstraint('employee_id', 'system_name', name='uq_employee_system'),
)
# 創建索引
op.create_index('idx_permissions_employee', 'permissions', ['employee_id'])
op.create_index('idx_permissions_tenant', 'permissions', ['tenant_id'])
op.create_index('idx_permissions_system', 'permissions', ['system_name'])
def downgrade() -> None:
# 刪除 permissions 表
op.drop_index('idx_permissions_system', table_name='permissions')
op.drop_index('idx_permissions_tenant', table_name='permissions')
op.drop_index('idx_permissions_employee', table_name='permissions')
op.drop_table('permissions')
# 刪除 email_accounts 表
op.drop_index('idx_email_accounts_active', table_name='email_accounts')
op.drop_index('idx_email_accounts_tenant', table_name='email_accounts')
op.drop_index('idx_email_accounts_employee', table_name='email_accounts')
op.drop_index('idx_email_accounts_email', table_name='email_accounts')
op.drop_table('email_accounts')

View File

@@ -0,0 +1,141 @@
"""extend organization structure
Revision ID: 0003
Revises: 0002
Create Date: 2026-02-15 15:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0003'
down_revision: Union[str, None] = '0002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 擴充 business_units 表
op.add_column('business_units', sa.Column('primary_domain', sa.String(100), nullable=True))
op.add_column('business_units', sa.Column('email_address', sa.String(255), nullable=True))
op.add_column('business_units', sa.Column('email_quota_mb', sa.Integer(), server_default='10240', nullable=False))
# 擴充 departments 表
op.add_column('departments', sa.Column('email_address', sa.String(255), nullable=True))
op.add_column('departments', sa.Column('email_quota_mb', sa.Integer(), server_default='5120', nullable=False))
# 擴充 email_accounts 表 (支援組織/事業部/部門信箱)
op.add_column('email_accounts', sa.Column('account_type', sa.String(20), server_default='personal', nullable=False))
op.add_column('email_accounts', sa.Column('department_id', sa.Integer(), nullable=True))
op.add_column('email_accounts', sa.Column('business_unit_id', sa.Integer(), nullable=True))
# 添加外鍵約束
op.create_foreign_key('fk_email_accounts_department', 'email_accounts', 'departments', ['department_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_email_accounts_business_unit', 'email_accounts', 'business_units', ['business_unit_id'], ['id'], ondelete='CASCADE')
# 更新現有的 business_units 資料 (三大事業部)
op.execute("""
UPDATE business_units SET
primary_domain = 'ease.taipei',
email_address = 'business@ease.taipei'
WHERE name = '業務發展部' OR code = 'BD'
""")
op.execute("""
UPDATE business_units SET
primary_domain = 'lab.taipei',
email_address = 'tech@lab.taipei'
WHERE name = '技術發展部' OR code = 'TD'
""")
op.execute("""
UPDATE business_units SET
primary_domain = 'porscheworld.tw',
email_address = 'operations@porscheworld.tw'
WHERE name = '營運管理部' OR code = 'OM'
""")
# 插入初始部門資料
# 業務發展部 (假設 id=1)
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '玄鐵風能', 'WIND', 'wind@ease.taipei', 5120, NOW()
FROM business_units WHERE name = '業務發展部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '虛擬公司', 'VIRTUAL', 'virtual@ease.taipei', 5120, NOW()
FROM business_units WHERE name = '業務發展部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '國際碳權', 'CARBON', 'carbon@ease.taipei', 5120, NOW()
FROM business_units WHERE name = '業務發展部' LIMIT 1
""")
# 技術發展部 (假設 id=2)
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '智能研發', 'AI', 'ai@lab.taipei', 5120, NOW()
FROM business_units WHERE name = '技術發展部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '軟體開發', 'DEV', 'dev@lab.taipei', 5120, NOW()
FROM business_units WHERE name = '技術發展部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '虛擬MIS', 'MIS', 'mis@lab.taipei', 5120, NOW()
FROM business_units WHERE name = '技術發展部' LIMIT 1
""")
# 營運管理部 (假設 id=3)
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '人資', 'HR', 'hr@porscheworld.tw', 5120, NOW()
FROM business_units WHERE name = '營運管理部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '財務', 'FIN', 'finance@porscheworld.tw', 5120, NOW()
FROM business_units WHERE name = '營運管理部' LIMIT 1
""")
op.execute("""
INSERT INTO departments (tenant_id, business_unit_id, name, code, email_address, email_quota_mb, created_at)
SELECT 1, id, '總務', 'ADMIN', 'admin@porscheworld.tw', 5120, NOW()
FROM business_units WHERE name = '營運管理部' LIMIT 1
""")
def downgrade() -> None:
# 移除外鍵約束
op.drop_constraint('fk_email_accounts_business_unit', 'email_accounts', type_='foreignkey')
op.drop_constraint('fk_email_accounts_department', 'email_accounts', type_='foreignkey')
# 移除 email_accounts 擴充欄位
op.drop_column('email_accounts', 'business_unit_id')
op.drop_column('email_accounts', 'department_id')
op.drop_column('email_accounts', 'account_type')
# 移除 departments 擴充欄位
op.drop_column('departments', 'email_quota_mb')
op.drop_column('departments', 'email_address')
# 移除 business_units 擴充欄位
op.drop_column('business_units', 'email_quota_mb')
op.drop_column('business_units', 'email_address')
op.drop_column('business_units', 'primary_domain')
# 刪除部門資料 (downgrade 時)
op.execute("DELETE FROM departments WHERE tenant_id = 1")

View File

@@ -0,0 +1,42 @@
"""add batch_logs table
Revision ID: 0004
Revises: 0003
Create Date: 2026-02-18 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0004'
down_revision: Union[str, None] = '0003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'batch_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('batch_name', sa.String(length=100), nullable=False, comment='批次名稱'),
sa.Column('status', sa.String(length=20), nullable=False, comment='執行狀態: success/failed/warning'),
sa.Column('message', sa.Text(), nullable=True, comment='執行訊息或錯誤詳情'),
sa.Column('started_at', sa.DateTime(), nullable=False, comment='開始時間'),
sa.Column('finished_at', sa.DateTime(), nullable=True, comment='完成時間'),
sa.Column('duration_seconds', sa.Integer(), nullable=True, comment='執行時間 (秒)'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_batch_logs_id', 'batch_logs', ['id'], unique=False)
op.create_index('ix_batch_logs_batch_name', 'batch_logs', ['batch_name'], unique=False)
op.create_index('ix_batch_logs_started_at', 'batch_logs', ['started_at'], unique=False)
def downgrade() -> None:
op.drop_index('ix_batch_logs_started_at', table_name='batch_logs')
op.drop_index('ix_batch_logs_batch_name', table_name='batch_logs')
op.drop_index('ix_batch_logs_id', table_name='batch_logs')
op.drop_table('batch_logs')

View File

@@ -0,0 +1,343 @@
"""multi-tenant architecture refactor
Revision ID: 0005
Revises: 0004
Create Date: 2026-02-19 00:00:00.000000
重構內容:
1. departments 改為統一樹狀結構 (新增 parent_id, email_domain, depth)
2. business_units 資料遷移為 departments 第一層節點,廢棄 business_units 表
3. 新增 department_members 表 (員工多部門歸屬)
4. employee_identities 資料遷移至 department_members標記廢棄
5. employees 新增 keycloak_user_id (唯一 SSO 識別碼)
6. tenants 新增 keycloak_realm (= tenant_code)
7. 新增 RBAC: system_functions_cache, roles, role_rights, user_role_assignments
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0005'
down_revision: Union[str, None] = '0004'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# =========================================================
# A-1: 修改 departments 表 - 新增樹狀結構欄位
# =========================================================
op.add_column('departments', sa.Column('parent_id', sa.Integer(), nullable=True))
op.add_column('departments', sa.Column('email_domain', sa.String(100), nullable=True))
op.add_column('departments', sa.Column('depth', sa.Integer(), server_default='1', nullable=False))
# 新增自我參照外鍵 (parent_id → departments.id)
op.create_foreign_key(
'fk_departments_parent',
'departments', 'departments',
['parent_id'], ['id'],
ondelete='CASCADE'
)
# 新增索引
op.create_index('idx_departments_parent', 'departments', ['parent_id'])
op.create_index('idx_departments_depth', 'departments', ['depth'])
# =========================================================
# A-2: 將 business_units 資料遷移為 departments 第一層節點
# =========================================================
# 先將 business_unit_id 改為 nullable (新插入的第一層節點不需要此欄位)
op.alter_column('departments', 'business_unit_id', nullable=True)
# 先將現有 departments 的 depth 設為 1 (它們都是原本的子部門)
op.execute("UPDATE departments SET depth = 1")
# 將 business_units 插入 departments 為第一層節點 (depth=0, parent_id=NULL)
op.execute("""
INSERT INTO departments (tenant_id, code, name, email_domain, parent_id, depth, is_active, created_at, email_address, email_quota_mb)
SELECT
bu.tenant_id,
bu.code,
bu.name,
bu.email_domain,
NULL,
0,
bu.is_active,
bu.created_at,
bu.email_address,
bu.email_quota_mb
FROM business_units bu
""")
# 更新原有子部門parent_id 指向剛插入的第一層節點
op.execute("""
UPDATE departments AS d
SET parent_id = top.id
FROM departments AS top
JOIN business_units bu ON bu.code = top.code AND bu.tenant_id = top.tenant_id
WHERE d.business_unit_id = bu.id
AND top.depth = 0
AND d.depth = 1
""")
# 更新 unique constraint (移除舊的,建立新的)
op.drop_constraint('uq_department_bu_code', 'departments', type_='unique')
op.create_unique_constraint(
'uq_tenant_parent_dept_code',
'departments',
['tenant_id', 'parent_id', 'code']
)
# 移除 departments.business_unit_id 外鍵和欄位
op.drop_constraint('departments_business_unit_id_fkey', 'departments', type_='foreignkey')
op.drop_index('ix_departments_business_unit_id', table_name='departments')
op.drop_column('departments', 'business_unit_id')
# 重建 tenant 索引 (原名 idx_departments_tenant 已存在,建立新名稱)
op.create_index('idx_dept_tenant_id', 'departments', ['tenant_id'])
# =========================================================
# A-3: 新增 department_members 表
# =========================================================
op.create_table(
'department_members',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('employee_id', sa.Integer(), nullable=False, comment='員工 ID'),
sa.Column('department_id', sa.Integer(), nullable=False, comment='部門 ID'),
sa.Column('position', sa.String(100), nullable=True, comment='在該部門的職稱'),
sa.Column('membership_type', sa.String(50), server_default='permanent', nullable=False,
comment='成員類型: permanent/temporary/project'),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('joined_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('ended_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('employee_id', 'department_id', name='uq_employee_department'),
)
op.create_index('idx_dept_members_tenant', 'department_members', ['tenant_id'])
op.create_index('idx_dept_members_employee', 'department_members', ['employee_id'])
op.create_index('idx_dept_members_department', 'department_members', ['department_id'])
# =========================================================
# A-4: 遷移 employee_identities → department_members
# =========================================================
op.execute("""
INSERT INTO department_members (tenant_id, employee_id, department_id, position, is_active, joined_at)
SELECT
ei.tenant_id,
ei.employee_id,
ei.department_id,
ei.job_title,
ei.is_active,
COALESCE(ei.started_at, ei.created_at)
FROM employee_identities ei
WHERE ei.department_id IS NOT NULL
ON CONFLICT (employee_id, department_id) DO NOTHING
""")
# 標記 employee_identities 為廢棄
op.add_column('employee_identities', sa.Column(
'deprecated_at', sa.DateTime(),
server_default=sa.text('now()'),
nullable=True,
comment='廢棄標記 - 資料已遷移至 department_members'
))
# =========================================================
# A-5: employees 新增 keycloak_user_id
# =========================================================
op.add_column('employees', sa.Column(
'keycloak_user_id', sa.String(36),
nullable=True,
unique=True,
comment='Keycloak User UUID (唯一 SSO 識別碼,永久不變)'
))
op.create_index('idx_employees_keycloak', 'employees', ['keycloak_user_id'])
# =========================================================
# A-6: tenants 新增 keycloak_realm
# =========================================================
op.add_column('tenants', sa.Column(
'keycloak_realm', sa.String(100),
nullable=True,
unique=True,
comment='Keycloak Realm 名稱 (等同 tenant_code)'
))
op.create_index('idx_tenants_realm', 'tenants', ['keycloak_realm'])
# 為現有租戶設定 keycloak_realm = tenant_code
op.execute("UPDATE tenants SET keycloak_realm = code WHERE keycloak_realm IS NULL")
# =========================================================
# A-7: 新增 RBAC 表
# =========================================================
# system_functions_cache (從 System Admin 同步的系統功能定義)
op.create_table(
'system_functions_cache',
sa.Column('id', sa.Integer(), nullable=False, comment='與 System Admin 的 id 一致'),
sa.Column('service_code', sa.String(50), nullable=False, comment='服務代碼: hr/erp/mail/ai'),
sa.Column('function_code', sa.String(100), nullable=False, comment='功能代碼: HR_EMPLOYEE_VIEW'),
sa.Column('function_name', sa.String(200), nullable=False, comment='功能名稱'),
sa.Column('function_category', sa.String(50), nullable=True, comment='功能分類: query/manage/approve/report'),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('synced_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False,
comment='最後同步時間'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('function_code', name='uq_function_code'),
)
op.create_index('idx_func_cache_service', 'system_functions_cache', ['service_code'])
op.create_index('idx_func_cache_category', 'system_functions_cache', ['function_category'])
# roles (租戶層級角色)
op.create_table(
'roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('role_code', sa.String(100), nullable=False, comment='角色代碼 (租戶內唯一)'),
sa.Column('role_name', sa.String(200), nullable=False, comment='角色名稱'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tenant_id', 'role_code', name='uq_tenant_role_code'),
)
op.create_index('idx_roles_tenant', 'roles', ['tenant_id'])
# role_rights (角色 → 系統功能 CRUD 權限)
op.create_table(
'role_rights',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('function_id', sa.Integer(), nullable=False),
sa.Column('can_read', sa.Boolean(), server_default='false', nullable=False),
sa.Column('can_create', sa.Boolean(), server_default='false', nullable=False),
sa.Column('can_update', sa.Boolean(), server_default='false', nullable=False),
sa.Column('can_delete', sa.Boolean(), server_default='false', nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['function_id'], ['system_functions_cache.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('role_id', 'function_id', name='uq_role_function'),
)
op.create_index('idx_role_rights_role', 'role_rights', ['role_id'])
op.create_index('idx_role_rights_function', 'role_rights', ['function_id'])
# user_role_assignments (使用者角色分配,直接對人不對部門)
op.create_table(
'user_role_assignments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False, comment='租戶 ID'),
sa.Column('keycloak_user_id', sa.String(36), nullable=False, comment='Keycloak User UUID'),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('assigned_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('assigned_by', sa.String(36), nullable=True, comment='分配者 keycloak_user_id'),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('keycloak_user_id', 'role_id', name='uq_user_role'),
)
op.create_index('idx_user_roles_tenant', 'user_role_assignments', ['tenant_id'])
op.create_index('idx_user_roles_keycloak', 'user_role_assignments', ['keycloak_user_id'])
# =========================================================
# A-8: Seed data - HR 系統功能 + 初始角色
# =========================================================
op.execute("""
INSERT INTO system_functions_cache (id, service_code, function_code, function_name, function_category) VALUES
(1, 'hr', 'HR_EMPLOYEE_VIEW', '員工查詢', 'query'),
(2, 'hr', 'HR_EMPLOYEE_MANAGE', '員工管理', 'manage'),
(3, 'hr', 'HR_DEPT_MANAGE', '部門管理', 'manage'),
(4, 'hr', 'HR_ROLE_MANAGE', '角色管理', 'manage'),
(5, 'hr', 'HR_AUDIT_VIEW', '審計日誌查詢', 'query')
""")
op.execute("""
INSERT INTO roles (tenant_id, role_code, role_name, description) VALUES
(1, 'HR_ADMIN', '人資管理員', '可管理員工資料、組織架構、角色分配'),
(1, 'HR_VIEWER', '人資查詢者', '只能查詢員工資料'),
(1, 'SYSTEM_ADMIN', '系統管理員', '擁有所有 HR 系統功能權限')
""")
# HR_ADMIN 擁有所有 HR 功能的完整權限
op.execute("""
INSERT INTO role_rights (role_id, function_id, can_read, can_create, can_update, can_delete)
SELECT r.id, f.id,
true,
CASE WHEN f.function_category = 'manage' THEN true ELSE false END,
CASE WHEN f.function_category IN ('manage', 'approve') THEN true ELSE false END,
CASE WHEN f.function_category = 'manage' THEN true ELSE false END
FROM roles r, system_functions_cache f
WHERE r.role_code = 'HR_ADMIN' AND r.tenant_id = 1
""")
# HR_VIEWER 只有查詢權限
op.execute("""
INSERT INTO role_rights (role_id, function_id, can_read, can_create, can_update, can_delete)
SELECT r.id, f.id, true, false, false, false
FROM roles r, system_functions_cache f
WHERE r.role_code = 'HR_VIEWER' AND r.tenant_id = 1
AND f.function_category = 'query'
""")
# SYSTEM_ADMIN 擁有所有功能的完整 CRUD
op.execute("""
INSERT INTO role_rights (role_id, function_id, can_read, can_create, can_update, can_delete)
SELECT r.id, f.id, true, true, true, true
FROM roles r, system_functions_cache f
WHERE r.role_code = 'SYSTEM_ADMIN' AND r.tenant_id = 1
""")
def downgrade() -> None:
# 移除 RBAC 表
op.drop_table('user_role_assignments')
op.drop_table('role_rights')
op.drop_table('roles')
op.drop_table('system_functions_cache')
# 移除 keycloak 欄位
op.drop_index('idx_tenants_realm', table_name='tenants')
op.drop_column('tenants', 'keycloak_realm')
op.drop_index('idx_employees_keycloak', table_name='employees')
op.drop_column('employees', 'keycloak_user_id')
# 移除廢棄標記
op.drop_column('employee_identities', 'deprecated_at')
# 移除 department_members
op.drop_table('department_members')
# 還原 departments (重新加回 business_unit_id) - 注意: 資料無法完全還原
op.add_column('departments', sa.Column('business_unit_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_departments_business_unit',
'departments', 'business_units',
['business_unit_id'], ['id'],
ondelete='CASCADE'
)
op.drop_constraint('uq_tenant_parent_dept_code', 'departments', type_='unique')
op.create_unique_constraint(
'uq_tenant_bu_dept_code',
'departments',
['tenant_id', 'business_unit_id', 'code']
)
op.drop_index('idx_dept_tenant_id', table_name='departments')
op.drop_index('idx_departments_parent', table_name='departments')
op.drop_index('idx_departments_depth', table_name='departments')
op.drop_constraint('fk_departments_parent', 'departments', type_='foreignkey')
op.drop_column('departments', 'parent_id')
op.drop_column('departments', 'email_domain')
op.drop_column('departments', 'depth')
op.create_index('idx_dept_tenant', 'departments', ['tenant_id'])

View File

@@ -0,0 +1,28 @@
"""add name_en to departments
Revision ID: 0006
Revises: 0005
Create Date: 2026-02-19 09:50:17.913124
補充 migration 0005 遺漏的欄位:
- departments.name_en (英文名稱)
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0006'
down_revision: Union[str, None] = '0005'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('departments', sa.Column('name_en', sa.String(100), nullable=True, comment='英文名稱'))
def downgrade() -> None:
op.drop_column('departments', 'name_en')

View File

@@ -0,0 +1,77 @@
"""rename tables to match org schema
Revision ID: 0007
Revises: 0006
Create Date: 2026-02-20 12:00:33.776853
重新命名資料表以符合組織架構規格:
- tenants → organizes
- departments → org_departments
- roles → org_user_roles
- employees → org_employees
- department_members → org_dept_members
- user_role_assignments → org_user_role_assignments
- role_rights → org_role_rights
- email_accounts → org_email_accounts
- network_drives → org_network_drives
- permissions → org_permissions
- audit_logs → org_audit_logs
- batch_logs → org_batch_logs
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0007'
down_revision: Union[str, None] = '0006'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# =========================================================
# 重新命名資料表(按照依賴順序,從子表到父表)
# =========================================================
# 1. 批次作業和日誌表(無外鍵依賴)
op.rename_table('batch_logs', 'org_batch_logs')
op.rename_table('audit_logs', 'org_audit_logs')
# 2. 員工相關子表(依賴 employees
op.rename_table('permissions', 'org_permissions')
op.rename_table('network_drives', 'org_network_drives')
op.rename_table('email_accounts', 'org_email_accounts')
# 3. 角色權限相關(依賴 roles
op.rename_table('role_rights', 'org_role_rights')
op.rename_table('user_role_assignments', 'org_user_role_assignments')
# 4. 部門成員(依賴 departments, employees
op.rename_table('department_members', 'org_dept_members')
# 5. 主要業務表
op.rename_table('roles', 'org_user_roles')
op.rename_table('departments', 'org_departments')
op.rename_table('employees', 'org_employees')
# 6. 租戶/組織表(最後,因為其他表都依賴它)
op.rename_table('tenants', 'organizes')
def downgrade() -> None:
# 反向操作:從父表到子表
op.rename_table('organizes', 'tenants')
op.rename_table('org_employees', 'employees')
op.rename_table('org_departments', 'departments')
op.rename_table('org_user_roles', 'roles')
op.rename_table('org_dept_members', 'department_members')
op.rename_table('org_user_role_assignments', 'user_role_assignments')
op.rename_table('org_role_rights', 'role_rights')
op.rename_table('org_email_accounts', 'email_accounts')
op.rename_table('org_network_drives', 'network_drives')
op.rename_table('org_permissions', 'permissions')
op.rename_table('org_audit_logs', 'audit_logs')
op.rename_table('org_batch_logs', 'batch_logs')

View File

@@ -0,0 +1,71 @@
"""add is_active to all tables
Revision ID: 0008
Revises: 0007
Create Date: 2026-02-20
統一為所有資料表新增 is_active 布林欄位
- true: 資料啟用
- false: 資料不啟用
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0008'
down_revision = '0007'
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. organizes (目前只有 status新增 is_active預設從 status 轉換)
op.add_column('organizes', sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否啟用'))
op.execute("""
UPDATE organizes
SET is_active = CASE
WHEN status = 'active' THEN true
WHEN status = 'trial' THEN true
ELSE false
END
""")
op.alter_column('organizes', 'is_active', nullable=False)
# 2. org_employees (目前只有 status新增 is_active)
op.add_column('org_employees', sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否啟用'))
op.execute("""
UPDATE org_employees
SET is_active = CASE
WHEN status = 'active' THEN true
ELSE false
END
""")
op.alter_column('org_employees', 'is_active', nullable=False)
# 3. org_batch_logs (目前只有 status但這是執行狀態不是啟用狀態仍新增 is_active)
op.add_column('org_batch_logs', sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'))
# 4. 其他已有 is_active 的表不需處理
# - org_departments (已有 is_active)
# - business_units (已有 is_active)
# - org_dept_members (已有 is_active)
# - org_email_accounts (已有 is_active)
# - org_network_drives (已有 is_active)
# - org_user_roles (已有 is_active)
# - org_user_role_assignments (已有 is_active)
# - system_functions_cache (已有 is_active)
# 5. 檢查其他表是否需要 is_active (根據業務邏輯)
# - org_audit_logs: 審計日誌不需要 is_active (不可停用)
# - org_permissions: 已有 is_active (透過 ended_at 判斷)
#
# 注意: tenant_domains, subscriptions, invoices, payments 等表可能不存在於目前的資料庫
# 這些表屬於多租戶進階功能,將在後續 migration 中建立
# 暫時跳過這些表的 is_active 處理
def downgrade() -> None:
op.drop_column('org_batch_logs', 'is_active')
op.drop_column('org_employees', 'is_active')
op.drop_column('organizes', 'is_active')

View File

@@ -0,0 +1,144 @@
"""add tenant scoped sequences
Revision ID: 0009
Revises: 0008
Create Date: 2026-02-20
為每個租戶建立獨立的序號生成器
- 每個租戶的資料從 1 開始編號
- 保證租戶內序號連續性
"""
from alembic import op
import sqlalchemy as sa
revision = '0009'
down_revision = '0008'
branch_labels = None
depends_on = None
def upgrade() -> None:
# =========================================================
# 方案:為主要實體表新增租戶內序號欄位
# =========================================================
# 1. org_employees: 新增 seq_no (租戶內序號)
op.add_column('org_employees', sa.Column('seq_no', sa.Integer(), nullable=True, comment='租戶內序號'))
# 為現有資料填充 seq_no
op.execute("""
WITH numbered AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) as seq
FROM org_employees
)
UPDATE org_employees
SET seq_no = numbered.seq
FROM numbered
WHERE org_employees.id = numbered.id
""")
# 設為 NOT NULL
op.alter_column('org_employees', 'seq_no', nullable=False)
# 建立唯一索引tenant_id + seq_no
op.create_index(
'idx_org_employees_tenant_seq',
'org_employees',
['tenant_id', 'seq_no'],
unique=True
)
# 2. org_departments: 新增 seq_no
op.add_column('org_departments', sa.Column('seq_no', sa.Integer(), nullable=True, comment='租戶內序號'))
op.execute("""
WITH numbered AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) as seq
FROM org_departments
)
UPDATE org_departments
SET seq_no = numbered.seq
FROM numbered
WHERE org_departments.id = numbered.id
""")
op.alter_column('org_departments', 'seq_no', nullable=False)
op.create_index(
'idx_org_departments_tenant_seq',
'org_departments',
['tenant_id', 'seq_no'],
unique=True
)
# 3. org_user_roles: 新增 seq_no
op.add_column('org_user_roles', sa.Column('seq_no', sa.Integer(), nullable=True, comment='租戶內序號'))
op.execute("""
WITH numbered AS (
SELECT id,
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) as seq
FROM org_user_roles
)
UPDATE org_user_roles
SET seq_no = numbered.seq
FROM numbered
WHERE org_user_roles.id = numbered.id
""")
op.alter_column('org_user_roles', 'seq_no', nullable=False)
op.create_index(
'idx_org_user_roles_tenant_seq',
'org_user_roles',
['tenant_id', 'seq_no'],
unique=True
)
# 4. 建立自動生成序號的觸發器函數
op.execute("""
CREATE OR REPLACE FUNCTION generate_tenant_seq_no()
RETURNS TRIGGER AS $$
BEGIN
-- 如果 seq_no 未提供,自動生成
IF NEW.seq_no IS NULL THEN
SELECT COALESCE(MAX(seq_no), 0) + 1
INTO NEW.seq_no
FROM org_employees
WHERE tenant_id = NEW.tenant_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
""")
# 5. 為各表建立觸發器
for table_name in ['org_employees', 'org_departments', 'org_user_roles']:
trigger_name = f'trigger_{table_name}_seq_no'
op.execute(f"""
CREATE TRIGGER {trigger_name}
BEFORE INSERT ON {table_name}
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
def downgrade() -> None:
# 移除觸發器
for table_name in ['org_employees', 'org_departments', 'org_user_roles']:
trigger_name = f'trigger_{table_name}_seq_no'
op.execute(f"DROP TRIGGER IF EXISTS {trigger_name} ON {table_name}")
# 移除函數
op.execute("DROP FUNCTION IF EXISTS generate_tenant_seq_no()")
# 移除索引和欄位
op.drop_index('idx_org_user_roles_tenant_seq', table_name='org_user_roles')
op.drop_column('org_user_roles', 'seq_no')
op.drop_index('idx_org_departments_tenant_seq', table_name='org_departments')
op.drop_column('org_departments', 'seq_no')
op.drop_index('idx_org_employees_tenant_seq', table_name='org_employees')
op.drop_column('org_employees', 'seq_no')

View File

@@ -0,0 +1,341 @@
"""refactor to final architecture
Revision ID: 0010
Revises: 0009
Create Date: 2026-02-20
完整架構重構:
1. 統一表名前綴org_* → tenant_*organizes → tenants
2. 統一通用欄位is_active, edit_by, created_at, updated_at
3. 擴充 tenants 表業務欄位
4. 新增 personal_services 表
5. 新增 tenant_emp_resumes 表(人員基本資料)
6. 新增 tenant_emp_setting 表(員工任用設定,使用複合主鍵)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = '0010'
down_revision = '0009'
branch_labels = None
depends_on = None
def upgrade() -> None:
# =========================================================
# Phase 1: 統一表名前綴org_* → tenant_*
# =========================================================
# 1.1 核心表
op.rename_table('organizes', 'tenants')
op.rename_table('org_departments', 'tenant_departments')
op.rename_table('org_employees', 'tenant_employees')
op.rename_table('org_user_roles', 'tenant_user_roles')
# 1.2 關聯表
op.rename_table('org_dept_members', 'tenant_dept_members')
op.rename_table('org_email_accounts', 'tenant_email_accounts')
op.rename_table('org_network_drives', 'tenant_network_drives')
op.rename_table('org_permissions', 'tenant_permissions')
op.rename_table('org_role_rights', 'tenant_role_rights')
op.rename_table('org_user_role_assignments', 'tenant_user_role_assignments')
# 1.3 系統表
op.rename_table('org_audit_logs', 'tenant_audit_logs')
op.rename_table('org_batch_logs', 'tenant_batch_logs')
# =========================================================
# Phase 2: 統一通用欄位(所有表)
# =========================================================
# 需要更新的所有表(包含新命名的)
tables_to_update = [
'business_units',
'employee_identities',
'system_functions_cache',
'tenant_audit_logs',
'tenant_batch_logs',
'tenant_departments',
'tenant_dept_members',
'tenant_email_accounts',
'tenant_employees',
'tenant_network_drives',
'tenant_permissions',
'tenant_role_rights',
'tenant_user_role_assignments',
'tenant_user_roles',
'tenants',
]
for table_name in tables_to_update:
# 2.1 新增 edit_by使用 op.add_column安全處理已存在情況
try:
op.add_column(table_name, sa.Column('edit_by', sa.String(100), comment='資料編輯者'))
except:
pass # 已存在則跳過
# 2.2 確保有 created_at
# 先檢查是否有 created_at 或 create_at
result = op.get_bind().execute(sa.text(f"""
SELECT column_name FROM information_schema.columns
WHERE table_name = '{table_name}'
AND column_name IN ('created_at', 'create_at')
"""))
existing_cols = [row[0] for row in result]
if 'create_at' in existing_cols and 'created_at' not in existing_cols:
op.alter_column(table_name, 'create_at', new_column_name='created_at')
elif not existing_cols:
op.add_column(table_name, sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')))
# 2.3 確保有 updated_at
result2 = op.get_bind().execute(sa.text(f"""
SELECT column_name FROM information_schema.columns
WHERE table_name = '{table_name}'
AND column_name IN ('updated_at', 'edit_at')
"""))
existing_cols2 = [row[0] for row in result2]
if 'edit_at' in existing_cols2 and 'updated_at' not in existing_cols2:
op.alter_column(table_name, 'edit_at', new_column_name='updated_at')
elif not existing_cols2:
op.add_column(table_name, sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')))
# =========================================================
# Phase 3: 擴充 tenants 表
# =========================================================
# 3.1 移除重複欄位
op.drop_column('tenants', 'keycloak_realm') # 與 code 重複
# 3.2 欄位已在 migration 0007 重新命名,跳過
# tenant_code → code (已完成)
# company_name → name (已完成)
# 3.3 新增業務欄位
op.add_column('tenants', sa.Column('name_eng', sa.String(200), comment='公司英文名稱'))
op.add_column('tenants', sa.Column('tax_id', sa.String(20), comment='公司統編'))
op.add_column('tenants', sa.Column('prefix', sa.String(10), nullable=False, server_default='ORG', comment='公司簡稱(員工編號前綴)'))
op.add_column('tenants', sa.Column('domain_set', sa.Integer, nullable=False, server_default='2', comment='網域條件1=組織網域2=部門網域'))
op.add_column('tenants', sa.Column('domain', sa.String(100), comment='組織網域domain_set=1時使用'))
op.add_column('tenants', sa.Column('tel', sa.String(20), comment='公司代表號'))
op.add_column('tenants', sa.Column('add', sa.Text, comment='公司登記地址'))
op.add_column('tenants', sa.Column('fax', sa.String(20), comment='公司傳真電話'))
op.add_column('tenants', sa.Column('contact', sa.String(100), comment='公司聯絡人'))
op.add_column('tenants', sa.Column('contact_email', sa.String(255), comment='公司聯絡人電子郵件'))
op.add_column('tenants', sa.Column('contact_mobil', sa.String(20), comment='公司聯絡人行動電話'))
op.add_column('tenants', sa.Column('is_sysmana', sa.Boolean, nullable=False, server_default='false', comment='是否為系統管理公司'))
op.add_column('tenants', sa.Column('quota', sa.Integer, nullable=False, server_default='100', comment='組織配額GB'))
# 3.4 更新現有租戶 1 的資料
op.execute("""
UPDATE tenants
SET
name_eng = 'Porscheworld',
tax_id = '82871784',
prefix = 'PWD',
domain_set = 2,
tel = '0226262026',
add = '新北市淡水區北新路197號7樓',
contact = 'porsche.chen',
contact_email = 'porsche.chen@gmail.com',
contact_mobil = '0910326333',
is_sysmana = true,
quota = 100
WHERE id = 1
""")
# =========================================================
# Phase 4: 新增 personal_services 表
# =========================================================
op.create_table(
'personal_services',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('service_name', sa.String(100), nullable=False, comment='服務名稱'),
sa.Column('service_code', sa.String(50), unique=True, nullable=False, comment='服務代碼'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'),
sa.Column('edit_by', sa.String(100), comment='資料編輯者'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
)
# 插入預設資料
op.execute("""
INSERT INTO personal_services (service_name, service_code, is_active) VALUES
('單一簽入', 'SSO', true),
('電子郵件', 'Email', true),
('日曆', 'Calendar', true),
('網路硬碟', 'Drive', true),
('線上office工具', 'Office', true)
""")
# =========================================================
# Phase 5: 新增 tenant_emp_resumes 表(人員基本資料)
# =========================================================
op.create_table(
'tenant_emp_resumes',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('seq_no', sa.Integer(), nullable=False, comment='租戶內序號'),
sa.Column('name_tw', sa.String(100), nullable=False, comment='中文姓名'),
sa.Column('name_eng', sa.String(100), comment='英文姓名'),
sa.Column('personal_id', sa.String(20), comment='身份證號/護照號碼'),
sa.Column('personal_tel', sa.String(20), comment='通訊電話'),
sa.Column('personal_add', sa.Text, comment='通訊地址'),
sa.Column('emergency_contact', sa.String(100), comment='緊急聯絡人'),
sa.Column('emergency_tel', sa.String(20), comment='緊急聯絡人電話'),
sa.Column('academic_qualification', sa.String(200), comment='最高學歷'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('edit_by', sa.String(100)),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.UniqueConstraint('tenant_id', 'seq_no', name='uq_tenant_resume_seq'),
)
# 建立序號觸發器
op.execute("""
CREATE TRIGGER trigger_tenant_emp_resumes_seq_no
BEFORE INSERT ON tenant_emp_resumes
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
# =========================================================
# Phase 6: 新增 tenant_emp_setting 表(員工任用設定)
# 使用複合主鍵:(tenant_id, seq_no)
# =========================================================
op.create_table(
'tenant_emp_setting',
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False, comment='租戶 ID'),
sa.Column('seq_no', sa.Integer(), nullable=False, comment='租戶內序號'),
sa.Column('tenant_resume_id', sa.Integer(), sa.ForeignKey('tenant_emp_resumes.id', ondelete='CASCADE'), nullable=False, comment='人員基本資料 ID'),
sa.Column('tenant_emp_code', sa.String(20), nullable=False, comment='員工工號自動生成prefix + seq_no'),
sa.Column('hire_at', sa.Date(), nullable=False, comment='到職日期'),
sa.Column('tenant_dep_ids', postgresql.ARRAY(sa.Integer()), comment='部門設定(陣列)'),
sa.Column('tenant_user_roles_ids', postgresql.ARRAY(sa.Integer()), comment='使用者角色(陣列)'),
sa.Column('tenant_user_quota', sa.Integer(), nullable=False, server_default='20', comment='配額設定GB'),
sa.Column('tenant_user_personal_services', postgresql.ARRAY(sa.Integer()), comment='個人化服務設定(陣列)'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('edit_by', sa.String(100)),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
# 複合主鍵
sa.PrimaryKeyConstraint('tenant_id', 'seq_no', name='pk_tenant_emp_setting'),
# 其他唯一約束
sa.UniqueConstraint('tenant_id', 'tenant_emp_code', name='uq_tenant_emp_code'),
sa.UniqueConstraint('tenant_id', 'tenant_resume_id', name='uq_tenant_resume'), # 一人一筆任用檔
)
# 建立索引
op.create_index('idx_tenant_emp_setting_tenant', 'tenant_emp_setting', ['tenant_id'])
op.create_index('idx_tenant_emp_setting_resume', 'tenant_emp_setting', ['tenant_resume_id'])
# 建立序號觸發器(針對複合主鍵表調整)
op.execute("""
CREATE OR REPLACE FUNCTION generate_tenant_emp_setting_seq()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.seq_no IS NULL THEN
SELECT COALESCE(MAX(seq_no), 0) + 1
INTO NEW.seq_no
FROM tenant_emp_setting
WHERE tenant_id = NEW.tenant_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_tenant_emp_setting_seq_no
BEFORE INSERT ON tenant_emp_setting
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_setting_seq();
""")
# 建立員工工號自動生成觸發器
op.execute("""
CREATE OR REPLACE FUNCTION generate_tenant_emp_code()
RETURNS TRIGGER AS $$
DECLARE
tenant_prefix VARCHAR(10);
BEGIN
IF NEW.tenant_emp_code IS NULL OR NEW.tenant_emp_code = '' THEN
-- 取得租戶的 prefix
SELECT prefix INTO tenant_prefix
FROM tenants
WHERE id = NEW.tenant_id;
-- 生成工號prefix + LPAD(seq_no, 4, '0')
-- 例如PWD + 0001 = PWD0001
NEW.tenant_emp_code := tenant_prefix || LPAD(NEW.seq_no::TEXT, 4, '0');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_generate_emp_code
BEFORE INSERT ON tenant_emp_setting
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_code();
""")
# =========================================================
# Phase 7: 更新現有觸發器函數(支援新表)
# =========================================================
# 更新原有的 generate_tenant_seq_no 函數,使其支援不同表
op.execute("""
CREATE OR REPLACE FUNCTION generate_tenant_seq_no()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.seq_no IS NULL THEN
-- 動態根據 TG_TABLE_NAME 決定查詢哪個表
EXECUTE format('
SELECT COALESCE(MAX(seq_no), 0) + 1
FROM %I
WHERE tenant_id = $1
', TG_TABLE_NAME)
INTO NEW.seq_no
USING NEW.tenant_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
""")
def downgrade() -> None:
# 移除新建的觸發器和函數
op.execute("DROP TRIGGER IF EXISTS trigger_generate_emp_code ON tenant_emp_setting")
op.execute("DROP FUNCTION IF EXISTS generate_tenant_emp_code()")
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_emp_setting_seq_no ON tenant_emp_setting")
op.execute("DROP FUNCTION IF EXISTS generate_tenant_emp_setting_seq()")
op.drop_index('idx_tenant_emp_setting_resume', table_name='tenant_emp_setting')
op.drop_index('idx_tenant_emp_setting_tenant', table_name='tenant_emp_setting')
op.drop_table('tenant_emp_setting')
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_emp_resumes_seq_no ON tenant_emp_resumes")
op.drop_table('tenant_emp_resumes')
op.drop_table('personal_services')
# 恢復表名tenant_* → org_*
op.rename_table('tenant_batch_logs', 'org_batch_logs')
op.rename_table('tenant_audit_logs', 'org_audit_logs')
op.rename_table('tenant_user_role_assignments', 'org_user_role_assignments')
op.rename_table('tenant_role_rights', 'org_role_rights')
op.rename_table('tenant_permissions', 'org_permissions')
op.rename_table('tenant_network_drives', 'org_network_drives')
op.rename_table('tenant_email_accounts', 'org_email_accounts')
op.rename_table('tenant_dept_members', 'org_dept_members')
op.rename_table('tenant_user_roles', 'org_user_roles')
op.rename_table('tenant_employees', 'org_employees')
op.rename_table('tenant_departments', 'org_departments')
op.rename_table('tenants', 'organizes')

View File

@@ -0,0 +1,115 @@
"""cleanup and finalize
Revision ID: 0011
Revises: 0010
Create Date: 2026-02-20
架構收尾與清理:
1. 移除廢棄表business_units, employee_identities
2. 為 tenant_role_rights 新增 is_active
3. 重新命名觸發器org_* → tenant_*
"""
from alembic import op
import sqlalchemy as sa
revision = '0011'
down_revision = '0010'
branch_labels = None
depends_on = None
def upgrade() -> None:
# =========================================================
# Phase 1: 移除廢棄表(先移除外鍵約束)
# =========================================================
# 1.1 先移除依賴 business_units 的外鍵
# employee_identities.business_unit_id FK
op.drop_constraint('employee_identities_business_unit_id_fkey', 'employee_identities', type_='foreignkey')
# tenant_email_accounts.business_unit_id FK如果存在
try:
op.drop_constraint('fk_email_accounts_business_unit', 'tenant_email_accounts', type_='foreignkey')
except:
pass # 可能不存在
# 1.2 移除 employee_identities 表(已被 tenant_emp_setting 取代)
op.drop_table('employee_identities')
# 1.3 移除 business_units 表(已被 tenant_departments 取代)
op.drop_table('business_units')
# =========================================================
# Phase 2: 為 tenant_role_rights 新增 is_active
# =========================================================
op.add_column('tenant_role_rights', sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'))
# =========================================================
# Phase 3: 重新命名觸發器org_* → tenant_*
# =========================================================
# 3.1 tenant_departments
op.execute("""
DROP TRIGGER IF EXISTS trigger_org_departments_seq_no ON tenant_departments;
CREATE TRIGGER trigger_tenant_departments_seq_no
BEFORE INSERT ON tenant_departments
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
# 3.2 tenant_employees
op.execute("""
DROP TRIGGER IF EXISTS trigger_org_employees_seq_no ON tenant_employees;
CREATE TRIGGER trigger_tenant_employees_seq_no
BEFORE INSERT ON tenant_employees
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
# 3.3 tenant_user_roles
op.execute("""
DROP TRIGGER IF EXISTS trigger_org_user_roles_seq_no ON tenant_user_roles;
CREATE TRIGGER trigger_tenant_user_roles_seq_no
BEFORE INSERT ON tenant_user_roles
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no();
""")
def downgrade() -> None:
# 恢復觸發器名稱
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_user_roles_seq_no ON tenant_user_roles")
op.execute("""
CREATE TRIGGER trigger_org_user_roles_seq_no
BEFORE INSERT ON tenant_user_roles
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no()
""")
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_employees_seq_no ON tenant_employees")
op.execute("""
CREATE TRIGGER trigger_org_employees_seq_no
BEFORE INSERT ON tenant_employees
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no()
""")
op.execute("DROP TRIGGER IF EXISTS trigger_tenant_departments_seq_no ON tenant_departments")
op.execute("""
CREATE TRIGGER trigger_org_departments_seq_no
BEFORE INSERT ON tenant_departments
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_seq_no()
""")
# 移除 is_active
op.drop_column('tenant_role_rights', 'is_active')
# 恢復廢棄表不實現downgrade 不支援重建複雜資料)
# op.create_table('employee_identities', ...)
# op.create_table('business_units', ...)

View File

@@ -0,0 +1,192 @@
"""refactor emp settings to relational
Revision ID: 0012
Revises: 0011
Create Date: 2026-02-20
調整 tenant_emp_setting 表結構:
1. 表名改為複數tenant_emp_setting → tenant_emp_settings
2. 移除 ARRAY 欄位(改用關聯表)
3. 新增 Keycloak 欄位
4. 調整配額欄位命名
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = '0012'
down_revision = '0011'
branch_labels = None
depends_on = None
def upgrade() -> None:
# =========================================================
# Phase 1: 重新命名表(單數 → 複數)
# =========================================================
op.rename_table('tenant_emp_setting', 'tenant_emp_settings')
# =========================================================
# Phase 2: 新增 Keycloak 欄位
# =========================================================
op.add_column('tenant_emp_settings', sa.Column(
'tenant_keycloak_user_id',
sa.String(36),
nullable=True, # 先設為 nullable稍後更新資料後改為 NOT NULL
comment='Keycloak User UUID'
))
op.add_column('tenant_emp_settings', sa.Column(
'tenant_keycloak_username',
sa.String(100),
nullable=True,
comment='Keycloak 使用者帳號'
))
# =========================================================
# Phase 3: 新增郵件配額欄位
# =========================================================
op.add_column('tenant_emp_settings', sa.Column(
'email_quota_mb',
sa.Integer(),
nullable=False,
server_default='5120',
comment='郵件配額 (MB)'
))
# =========================================================
# Phase 4: 重新命名配額欄位
# =========================================================
op.alter_column('tenant_emp_settings', 'tenant_user_quota',
new_column_name='storage_quota_gb')
# =========================================================
# Phase 5: 移除 ARRAY 欄位(改用關聯表)
# =========================================================
op.drop_column('tenant_emp_settings', 'tenant_dep_ids')
op.drop_column('tenant_emp_settings', 'tenant_user_roles_ids')
op.drop_column('tenant_emp_settings', 'tenant_user_personal_services')
# =========================================================
# Phase 6: 更新觸發器名稱(配合表名變更)
# =========================================================
op.execute("""
DROP TRIGGER IF EXISTS trigger_tenant_emp_setting_seq_no ON tenant_emp_settings;
DROP TRIGGER IF EXISTS trigger_generate_emp_code ON tenant_emp_settings;
CREATE TRIGGER trigger_tenant_emp_settings_seq_no
BEFORE INSERT ON tenant_emp_settings
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_setting_seq();
CREATE TRIGGER trigger_generate_emp_code
BEFORE INSERT ON tenant_emp_settings
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_code();
""")
# =========================================================
# Phase 7: 更新索引名稱(配合表名變更)
# =========================================================
op.execute("""
ALTER INDEX IF EXISTS idx_tenant_emp_setting_tenant
RENAME TO idx_tenant_emp_settings_tenant;
ALTER INDEX IF EXISTS idx_tenant_emp_setting_resume
RENAME TO idx_tenant_emp_settings_resume;
""")
# =========================================================
# Phase 8: 建立 tenant_emp_personal_service_settings 表(如果不存在)
# =========================================================
# 檢查表是否存在
connection = op.get_bind()
result = connection.execute(sa.text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'tenant_emp_personal_service_settings'
);
"""))
table_exists = result.fetchone()[0]
if not table_exists:
op.create_table(
'tenant_emp_personal_service_settings',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('tenant_id', sa.Integer(), sa.ForeignKey('tenants.id', ondelete='CASCADE'),
nullable=False, comment='租戶 ID'),
sa.Column('tenant_keycloak_user_id', sa.String(36), nullable=False,
comment='Keycloak User UUID'),
sa.Column('service_id', sa.Integer(), sa.ForeignKey('personal_services.id', ondelete='CASCADE'),
nullable=False, comment='個人化服務 ID'),
sa.Column('quota_gb', sa.Integer(), nullable=True, comment='儲存配額 (GB),適用於 Drive'),
sa.Column('quota_mb', sa.Integer(), nullable=True, comment='郵件配額 (MB),適用於 Email'),
sa.Column('enabled_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'),
comment='啟用時間'),
sa.Column('enabled_by', sa.String(36), nullable=True, comment='啟用者 keycloak_user_id'),
sa.Column('disabled_at', sa.DateTime(), nullable=True, comment='停用時間(軟刪除)'),
sa.Column('disabled_by', sa.String(36), nullable=True, comment='停用者 keycloak_user_id'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='是否啟用'),
sa.Column('edit_by', sa.String(36), nullable=True, comment='最後編輯者 keycloak_user_id'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.UniqueConstraint('tenant_id', 'tenant_keycloak_user_id', 'service_id', name='uq_emp_service'),
sa.Index('idx_emp_service_tenant', 'tenant_id'),
sa.Index('idx_emp_service_user', 'tenant_keycloak_user_id'),
sa.Index('idx_emp_service_service', 'service_id'),
)
# =========================================================
# Phase 9: 更新現有觸發器支援 tenant_keycloak_user_id
# =========================================================
# DepartmentMember 已經使用 employee_id不需要調整
# UserRoleAssignment 已經使用 keycloak_user_id不需要調整
def downgrade() -> None:
# 恢復 ARRAY 欄位
op.add_column('tenant_emp_settings', sa.Column('tenant_dep_ids', postgresql.ARRAY(sa.Integer()), comment='部門設定(陣列)'))
op.add_column('tenant_emp_settings', sa.Column('tenant_user_roles_ids', postgresql.ARRAY(sa.Integer()), comment='使用者角色(陣列)'))
op.add_column('tenant_emp_settings', sa.Column('tenant_user_personal_services', postgresql.ARRAY(sa.Integer()), comment='個人化服務設定(陣列)'))
# 恢復配額欄位名稱
op.alter_column('tenant_emp_settings', 'storage_quota_gb', new_column_name='tenant_user_quota')
# 移除郵件配額欄位
op.drop_column('tenant_emp_settings', 'email_quota_mb')
# 移除 Keycloak 欄位
op.drop_column('tenant_emp_settings', 'tenant_keycloak_username')
op.drop_column('tenant_emp_settings', 'tenant_keycloak_user_id')
# 恢復索引名稱
op.execute("""
ALTER INDEX IF EXISTS idx_tenant_emp_settings_tenant
RENAME TO idx_tenant_emp_setting_tenant;
ALTER INDEX IF EXISTS idx_tenant_emp_settings_resume
RENAME TO idx_tenant_emp_setting_resume;
""")
# 恢復觸發器名稱
op.execute("""
DROP TRIGGER IF EXISTS trigger_tenant_emp_settings_seq_no ON tenant_emp_settings;
DROP TRIGGER IF EXISTS trigger_generate_emp_code ON tenant_emp_settings;
CREATE TRIGGER trigger_tenant_emp_setting_seq_no
BEFORE INSERT ON tenant_emp_settings
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_setting_seq();
CREATE TRIGGER trigger_generate_emp_code
BEFORE INSERT ON tenant_emp_settings
FOR EACH ROW
EXECUTE FUNCTION generate_tenant_emp_code();
""")
# 恢復表名
op.rename_table('tenant_emp_settings', 'tenant_emp_setting')
# 移除 tenant_emp_personal_service_settings 表(如果是這個 migration 建立的)
# 為了安全downgrade 不刪除此表

View File

@@ -0,0 +1,53 @@
"""add tenant initialization fields
Revision ID: 0013
Revises: 0526fc6e6496
Create Date: 2026-02-21
新增租戶初始化相關欄位:
1. is_initialized - 租戶是否已完成初始化
2. initialized_at - 初始化完成時間
3. initialized_by - 執行初始化的使用者名稱
"""
from alembic import op
import sqlalchemy as sa
revision = '0013'
down_revision = '0526fc6e6496' # 基於最新的 migration
branch_labels = None
depends_on = None
def upgrade() -> None:
# 新增初始化狀態欄位
op.add_column('tenants', sa.Column(
'is_initialized',
sa.Boolean(),
nullable=False,
server_default='false',
comment='是否已完成初始化設定'
))
# 新增初始化時間欄位
op.add_column('tenants', sa.Column(
'initialized_at',
sa.DateTime(),
nullable=True,
comment='初始化完成時間'
))
# 新增初始化執行者欄位
op.add_column('tenants', sa.Column(
'initialized_by',
sa.String(255),
nullable=True,
comment='執行初始化的使用者名稱'
))
def downgrade() -> None:
# 移除初始化欄位
op.drop_column('tenants', 'initialized_by')
op.drop_column('tenants', 'initialized_at')
op.drop_column('tenants', 'is_initialized')

View File

@@ -0,0 +1,347 @@
"""add installation system tables
Revision ID: 0014
Revises: 0013
Create Date: 2026-02-22 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0014'
down_revision = '0013'
branch_labels = None
depends_on = None
def upgrade():
"""建立初始化系統相關資料表"""
# 1. installation_sessions (安裝會話)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_sessions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL初始化時可能還沒有 tenant
sa.Column('session_name', sa.String(200), nullable=True),
sa.Column('environment', sa.String(20), nullable=True),
# 狀態追蹤
sa.Column('started_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('completed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('status', sa.String(20), server_default='in_progress'),
# 進度統計
sa.Column('total_checklist_items', sa.Integer(), nullable=True),
sa.Column('passed_checklist_items', sa.Integer(), server_default='0'),
sa.Column('failed_checklist_items', sa.Integer(), server_default='0'),
sa.Column('total_steps', sa.Integer(), nullable=True),
sa.Column('completed_steps', sa.Integer(), server_default='0'),
sa.Column('failed_steps', sa.Integer(), server_default='0'),
sa.Column('executed_by', sa.String(100), nullable=True),
# 存取控制
sa.Column('is_locked', sa.Boolean(), server_default='false'),
sa.Column('locked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('locked_by', sa.String(100), nullable=True),
sa.Column('lock_reason', sa.String(200), nullable=True),
sa.Column('is_unlocked', sa.Boolean(), server_default='false'),
sa.Column('unlocked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('unlocked_by', sa.String(100), nullable=True),
sa.Column('unlock_reason', sa.String(200), nullable=True),
sa.Column('unlock_expires_at', sa.TIMESTAMP(), nullable=True),
sa.Column('last_viewed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('last_viewed_by', sa.String(100), nullable=True),
sa.Column('view_count', sa.Integer(), server_default='0'),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
# 移除外鍵約束,因為初始化時 tenants 表可能還不存在
)
op.create_index('idx_installation_sessions_tenant', 'installation_sessions', ['tenant_id'])
op.create_index('idx_installation_sessions_status', 'installation_sessions', ['status'])
# 2. installation_checklist_items (檢查項目定義)
op.create_table(
'installation_checklist_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('category', sa.String(50), nullable=False),
sa.Column('item_code', sa.String(100), unique=True, nullable=False),
sa.Column('item_name', sa.String(200), nullable=False),
sa.Column('check_type', sa.String(50), nullable=False),
sa.Column('check_command', sa.Text(), nullable=True),
sa.Column('expected_value', sa.Text(), nullable=True),
sa.Column('min_requirement', sa.Text(), nullable=True),
sa.Column('recommended_value', sa.Text(), nullable=True),
sa.Column('is_required', sa.Boolean(), server_default='true'),
sa.Column('sequence_order', sa.Integer(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_checklist_items_category', 'installation_checklist_items', ['category'])
op.create_index('idx_checklist_items_order', 'installation_checklist_items', ['sequence_order'])
# 3. installation_checklist_results (檢查結果)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_checklist_results',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
sa.Column('session_id', sa.Integer(), nullable=True),
sa.Column('checklist_item_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(20), nullable=False),
sa.Column('actual_value', sa.Text(), nullable=True),
sa.Column('checked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('checked_by', sa.String(100), nullable=True),
sa.Column('auto_checked', sa.Boolean(), server_default='false'),
sa.Column('remarks', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 外鍵約束
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['checklist_item_id'], ['installation_checklist_items.id'], ondelete='CASCADE')
)
op.create_index('idx_checklist_results_session', 'installation_checklist_results', ['session_id'])
op.create_index('idx_checklist_results_status', 'installation_checklist_results', ['status'])
# 4. installation_steps (安裝步驟定義)
op.create_table(
'installation_steps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('step_code', sa.String(50), unique=True, nullable=False),
sa.Column('step_name', sa.String(200), nullable=False),
sa.Column('phase', sa.String(20), nullable=False),
sa.Column('sequence_order', sa.Integer(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('execution_type', sa.String(50), nullable=True),
sa.Column('execution_script', sa.Text(), nullable=True),
sa.Column('depends_on_steps', postgresql.ARRAY(sa.String()), nullable=True),
sa.Column('is_required', sa.Boolean(), server_default='true'),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_installation_steps_phase', 'installation_steps', ['phase'])
op.create_index('idx_installation_steps_order', 'installation_steps', ['sequence_order'])
# 5. installation_logs (安裝執行記錄)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
sa.Column('step_id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=True),
sa.Column('status', sa.String(20), nullable=False),
sa.Column('started_at', sa.TIMESTAMP(), nullable=True),
sa.Column('completed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('executed_by', sa.String(100), nullable=True),
sa.Column('execution_method', sa.String(50), nullable=True),
sa.Column('result_data', postgresql.JSONB(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('retry_count', sa.Integer(), server_default='0'),
sa.Column('remarks', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 外鍵約束
sa.ForeignKeyConstraint(['step_id'], ['installation_steps.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE')
)
op.create_index('idx_installation_logs_session', 'installation_logs', ['session_id'])
op.create_index('idx_installation_logs_status', 'installation_logs', ['status'])
# 6. installation_tenant_info (租戶初始化資訊)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_tenant_info',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), unique=True, nullable=True), # 允許 NULL
sa.Column('session_id', sa.Integer(), nullable=True),
# 公司基本資訊
sa.Column('company_name', sa.String(200), nullable=True),
sa.Column('company_name_en', sa.String(200), nullable=True),
sa.Column('tax_id', sa.String(50), nullable=True),
sa.Column('industry', sa.String(100), nullable=True),
sa.Column('company_size', sa.String(20), nullable=True),
# 聯絡資訊
sa.Column('phone', sa.String(50), nullable=True),
sa.Column('fax', sa.String(50), nullable=True),
sa.Column('email', sa.String(200), nullable=True),
sa.Column('website', sa.String(200), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('address_en', sa.Text(), nullable=True),
# 負責人資訊
sa.Column('representative_name', sa.String(100), nullable=True),
sa.Column('representative_title', sa.String(100), nullable=True),
sa.Column('representative_email', sa.String(200), nullable=True),
sa.Column('representative_phone', sa.String(50), nullable=True),
# 系統管理員資訊
sa.Column('admin_employee_id', sa.String(50), nullable=True),
sa.Column('admin_username', sa.String(100), nullable=True),
sa.Column('admin_legal_name', sa.String(100), nullable=True),
sa.Column('admin_english_name', sa.String(100), nullable=True),
sa.Column('admin_email', sa.String(200), nullable=True),
sa.Column('admin_phone', sa.String(50), nullable=True),
# 初始設定
sa.Column('default_language', sa.String(10), server_default='zh-TW'),
sa.Column('timezone', sa.String(50), server_default='Asia/Taipei'),
sa.Column('date_format', sa.String(20), server_default='YYYY-MM-DD'),
sa.Column('currency', sa.String(10), server_default='TWD'),
# 狀態追蹤
sa.Column('is_completed', sa.Boolean(), server_default='false'),
sa.Column('completed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('completed_by', sa.String(100), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 外鍵約束
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='SET NULL')
)
# 7. installation_department_setup (部門架構設定)
# 注意tenant_id 不設外鍵,因為初始化時 tenants 表可能還不存在
op.create_table(
'installation_department_setup',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
sa.Column('session_id', sa.Integer(), nullable=True),
sa.Column('department_code', sa.String(50), nullable=False),
sa.Column('department_name', sa.String(200), nullable=False),
sa.Column('department_name_en', sa.String(200), nullable=True),
sa.Column('email_domain', sa.String(100), nullable=True),
sa.Column('parent_code', sa.String(50), nullable=True),
sa.Column('depth', sa.Integer(), server_default='0'),
sa.Column('manager_name', sa.String(100), nullable=True),
sa.Column('is_created', sa.Boolean(), server_default='false'),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 外鍵約束
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE')
)
op.create_index('idx_dept_setup_session', 'installation_department_setup', ['session_id'])
# 8. temporary_passwords (臨時密碼)
# 注意tenant_id 和 employee_id 不設外鍵,因為初始化時這些表可能還不存在
op.create_table(
'temporary_passwords',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=True), # 允許 NULL
sa.Column('employee_id', sa.Integer(), nullable=True), # 允許 NULL不設外鍵
sa.Column('username', sa.String(100), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=True),
# 密碼資訊
sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('plain_password', sa.String(100), nullable=True),
sa.Column('password_method', sa.String(20), nullable=True),
sa.Column('is_temporary', sa.Boolean(), server_default='true'),
sa.Column('must_change_on_login', sa.Boolean(), server_default='true'),
# 有效期限
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('expires_at', sa.TIMESTAMP(), nullable=True),
# 使用狀態
sa.Column('is_used', sa.Boolean(), server_default='false'),
sa.Column('used_at', sa.TIMESTAMP(), nullable=True),
sa.Column('first_login_at', sa.TIMESTAMP(), nullable=True),
sa.Column('password_changed_at', sa.TIMESTAMP(), nullable=True),
# 查看控制
sa.Column('is_viewable', sa.Boolean(), server_default='true'),
sa.Column('viewable_until', sa.TIMESTAMP(), nullable=True),
sa.Column('view_count', sa.Integer(), server_default='0'),
sa.Column('last_viewed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('first_viewed_at', sa.TIMESTAMP(), nullable=True),
# 明文密碼清除記錄
sa.Column('plain_password_cleared_at', sa.TIMESTAMP(), nullable=True),
sa.Column('cleared_reason', sa.String(100), nullable=True),
sa.PrimaryKeyConstraint('id'),
# 移除 tenant_id 和 employee_id 的外鍵約束
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='SET NULL')
)
op.create_index('idx_temp_pwd_username', 'temporary_passwords', ['username'])
op.create_index('idx_temp_pwd_session', 'temporary_passwords', ['session_id'])
# 9. installation_access_logs (存取審計日誌)
op.create_table(
'installation_access_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=False),
sa.Column('action', sa.String(50), nullable=False),
sa.Column('action_by', sa.String(100), nullable=True),
sa.Column('action_method', sa.String(50), nullable=True),
sa.Column('ip_address', sa.String(50), nullable=True),
sa.Column('user_agent', sa.Text(), nullable=True),
sa.Column('access_granted', sa.Boolean(), nullable=True),
sa.Column('deny_reason', sa.String(200), nullable=True),
sa.Column('sensitive_data_accessed', postgresql.ARRAY(sa.String()), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='CASCADE')
)
op.create_index('idx_access_logs_session', 'installation_access_logs', ['session_id'])
op.create_index('idx_access_logs_action', 'installation_access_logs', ['action'])
op.create_index('idx_access_logs_created', 'installation_access_logs', ['created_at'])
def downgrade():
"""移除初始化系統相關資料表"""
op.drop_index('idx_access_logs_created', 'installation_access_logs')
op.drop_index('idx_access_logs_action', 'installation_access_logs')
op.drop_index('idx_access_logs_session', 'installation_access_logs')
op.drop_table('installation_access_logs')
op.drop_index('idx_temp_pwd_session', 'temporary_passwords')
op.drop_index('idx_temp_pwd_username', 'temporary_passwords')
op.drop_table('temporary_passwords')
op.drop_index('idx_dept_setup_session', 'installation_department_setup')
op.drop_table('installation_department_setup')
op.drop_table('installation_tenant_info')
op.drop_index('idx_installation_logs_status', 'installation_logs')
op.drop_index('idx_installation_logs_session', 'installation_logs')
op.drop_table('installation_logs')
op.drop_index('idx_installation_steps_order', 'installation_steps')
op.drop_index('idx_installation_steps_phase', 'installation_steps')
op.drop_table('installation_steps')
op.drop_index('idx_checklist_results_status', 'installation_checklist_results')
op.drop_index('idx_checklist_results_session', 'installation_checklist_results')
op.drop_table('installation_checklist_results')
op.drop_index('idx_checklist_items_order', 'installation_checklist_items')
op.drop_index('idx_checklist_items_category', 'installation_checklist_items')
op.drop_table('installation_checklist_items')
op.drop_index('idx_installation_sessions_status', 'installation_sessions')
op.drop_index('idx_installation_sessions_tenant', 'installation_sessions')
op.drop_table('installation_sessions')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
"""remove_deprecated_tenant_employees_table
Revision ID: 5e95bf5ff0af
Revises: 844ac73765a3
Create Date: 2026-02-23 01:07:49.244754
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5e95bf5ff0af'
down_revision: Union[str, None] = '844ac73765a3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 警告:這個 migration 會刪除所有相關的測試資料
# 確保在開發環境執行,不要在正式環境執行
# Step 1: 刪除所有參照 tenant_employees 的外鍵約束
op.drop_constraint('department_members_employee_id_fkey', 'tenant_dept_members', type_='foreignkey')
op.drop_constraint('email_accounts_employee_id_fkey', 'tenant_email_accounts', type_='foreignkey')
op.drop_constraint('network_drives_employee_id_fkey', 'tenant_network_drives', type_='foreignkey')
op.drop_constraint('permissions_employee_id_fkey', 'tenant_permissions', type_='foreignkey')
op.drop_constraint('permissions_granted_by_fkey', 'tenant_permissions', type_='foreignkey')
# Step 2: 清空相關表的測試資料(因為外鍵已被移除)
op.execute('TRUNCATE TABLE tenant_dept_members CASCADE')
op.execute('TRUNCATE TABLE tenant_email_accounts CASCADE')
op.execute('TRUNCATE TABLE tenant_network_drives CASCADE')
op.execute('TRUNCATE TABLE tenant_permissions CASCADE')
# Step 3: 刪除 tenant_employees 表
op.drop_table('tenant_employees')
def downgrade() -> None:
# 不支援 downgrade無法復原已刪除的資料
raise NotImplementedError("Cannot downgrade from removing tenant_employees table")

View File

@@ -0,0 +1,42 @@
"""add tenant code prefix and domain fields to installation_tenant_info
Revision ID: 844ac73765a3
Revises: ba655ef20407
Create Date: 2026-02-23 00:33:56.554891
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '844ac73765a3'
down_revision: Union[str, None] = 'ba655ef20407'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 新增租戶代碼和員工編號前綴欄位
op.add_column('installation_tenant_info', sa.Column('tenant_code', sa.String(50), nullable=True))
op.add_column('installation_tenant_info', sa.Column('tenant_prefix', sa.String(10), nullable=True))
# 新增郵件網域相關欄位
op.add_column('installation_tenant_info', sa.Column('domain_set', sa.Integer, nullable=True, server_default='2'))
op.add_column('installation_tenant_info', sa.Column('domain', sa.String(100), nullable=True))
# 新增公司聯絡資訊欄位(對應 tenants 表)
op.add_column('installation_tenant_info', sa.Column('tel', sa.String(20), nullable=True))
op.add_column('installation_tenant_info', sa.Column('add', sa.Text, nullable=True))
def downgrade() -> None:
# 移除欄位
op.drop_column('installation_tenant_info', 'add')
op.drop_column('installation_tenant_info', 'tel')
op.drop_column('installation_tenant_info', 'domain')
op.drop_column('installation_tenant_info', 'domain_set')
op.drop_column('installation_tenant_info', 'tenant_prefix')
op.drop_column('installation_tenant_info', 'tenant_code')

View File

@@ -0,0 +1,78 @@
"""create_system_functions_table
Revision ID: a1b2c3d4e5f6
Revises: 5e95bf5ff0af
Create Date: 2026-02-23 10:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '5e95bf5ff0af'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 建立 system_functions 資料表
op.create_table(
'system_functions',
sa.Column('id', sa.Integer(), nullable=False, comment='資料編號'),
sa.Column('code', sa.String(length=200), nullable=False, comment='系統功能代碼/功能英文名稱'),
sa.Column('upper_function_id', sa.Integer(), nullable=False, server_default='0', comment='上層功能代碼 (0為初始層)'),
sa.Column('name', sa.String(length=200), nullable=False, comment='系統功能中文名稱'),
sa.Column('function_type', sa.Integer(), nullable=False, comment='系統功能類型 (1:node, 2:function)'),
sa.Column('order', sa.Integer(), nullable=False, comment='系統功能次序'),
sa.Column('function_icon', sa.String(length=200), nullable=False, server_default='', comment='功能圖示'),
sa.Column('module_code', sa.String(length=200), nullable=True, comment='功能模組名稱 (function_type=2 必填)'),
sa.Column('module_functions', postgresql.JSON(), nullable=False, server_default='[]', comment='模組項目 (View,Create,Read,Update,Delete,Print,File)'),
sa.Column('description', sa.Text(), nullable=False, server_default='', comment='說明 (富文本格式)'),
sa.Column('is_mana', sa.Boolean(), nullable=False, server_default='true', comment='系統管理'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='啟用'),
sa.Column('edit_by', sa.Integer(), nullable=False, comment='資料建立者'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), comment='資料最新建立時間'),
sa.Column('updated_at', sa.DateTime(), nullable=True, onupdate=sa.func.now(), comment='資料最新修改時間'),
sa.PrimaryKeyConstraint('id')
)
# 建立索引
op.create_index('ix_system_functions_code', 'system_functions', ['code'])
op.create_index('ix_system_functions_upper_function_id', 'system_functions', ['upper_function_id'])
op.create_index('ix_system_functions_function_type', 'system_functions', ['function_type'])
op.create_index('ix_system_functions_is_active', 'system_functions', ['is_active'])
# 建立自動編號序列 (從 10 開始)
op.execute("""
CREATE SEQUENCE system_functions_id_seq
START WITH 10
INCREMENT BY 1
MINVALUE 10
NO MAXVALUE
CACHE 1;
""")
# 設定 id 欄位使用序列
op.execute("""
ALTER TABLE system_functions
ALTER COLUMN id SET DEFAULT nextval('system_functions_id_seq');
""")
# 將序列所有權給予資料表
op.execute("""
ALTER SEQUENCE system_functions_id_seq
OWNED BY system_functions.id;
""")
def downgrade() -> None:
op.drop_index('ix_system_functions_is_active', table_name='system_functions')
op.drop_index('ix_system_functions_function_type', table_name='system_functions')
op.drop_index('ix_system_functions_upper_function_id', table_name='system_functions')
op.drop_index('ix_system_functions_code', table_name='system_functions')
op.execute('DROP SEQUENCE IF EXISTS system_functions_id_seq CASCADE')
op.drop_table('system_functions')

View File

@@ -0,0 +1,78 @@
"""add system status table
Revision ID: ba655ef20407
Revises: ddbf7bb95812
Create Date: 2026-02-22 20:47:37.691492
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ba655ef20407'
down_revision: Union[str, None] = 'ddbf7bb95812'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""建立系統狀態記錄表"""
# installation_system_status (系統狀態記錄)
# 用途:記錄系統當前所處的階段 (Initialization/Operational/Transition)
op.create_table(
'installation_system_status',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('current_phase', sa.String(20), nullable=False), # initialization/operational/transition
sa.Column('previous_phase', sa.String(20), nullable=True),
sa.Column('phase_changed_at', sa.TIMESTAMP(), nullable=True),
sa.Column('phase_changed_by', sa.String(100), nullable=True),
sa.Column('phase_change_reason', sa.Text(), nullable=True),
# Initialization 階段資訊
sa.Column('initialized_at', sa.TIMESTAMP(), nullable=True),
sa.Column('initialized_by', sa.String(100), nullable=True),
sa.Column('initialization_completed', sa.Boolean(), server_default='false'),
# Operational 階段資訊
sa.Column('last_health_check_at', sa.TIMESTAMP(), nullable=True),
sa.Column('health_check_status', sa.String(20), nullable=True), # healthy/degraded/unhealthy
sa.Column('operational_since', sa.TIMESTAMP(), nullable=True),
# Transition 階段資訊
sa.Column('transition_started_at', sa.TIMESTAMP(), nullable=True),
sa.Column('transition_approved_by', sa.String(100), nullable=True),
sa.Column('env_db_consistent', sa.Boolean(), nullable=True),
sa.Column('consistency_checked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('inconsistencies', sa.Text(), nullable=True), # JSON 格式記錄不一致項目
# 系統鎖定(防止誤操作)
sa.Column('is_locked', sa.Boolean(), server_default='false'),
sa.Column('locked_at', sa.TIMESTAMP(), nullable=True),
sa.Column('locked_by', sa.String(100), nullable=True),
sa.Column('lock_reason', sa.String(200), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_system_status_phase', 'installation_system_status', ['current_phase'])
# 插入預設記錄(初始階段)
op.execute("""
INSERT INTO installation_system_status
(current_phase, initialization_completed, is_locked)
VALUES ('initialization', false, false)
""")
def downgrade() -> None:
"""移除系統狀態記錄表"""
op.drop_index('idx_system_status_phase', 'installation_system_status')
op.drop_table('installation_system_status')

View File

@@ -0,0 +1,56 @@
"""add environment config table
Revision ID: ddbf7bb95812
Revises: 0014
Create Date: 2026-02-22 20:32:58.070446
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ddbf7bb95812'
down_revision: Union[str, None] = '0014'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""建立環境配置資料表"""
# installation_environment_config (環境配置記錄)
# 用途:記錄所有環境配置資訊,供初始化檢查使用
op.create_table(
'installation_environment_config',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('session_id', sa.Integer(), nullable=True),
sa.Column('config_key', sa.String(100), nullable=False, unique=True),
sa.Column('config_value', sa.Text(), nullable=True),
sa.Column('config_category', sa.String(50), nullable=False), # redis/database/keycloak/mailserver/nextcloud/traefik
sa.Column('is_sensitive', sa.Boolean(), server_default='false'),
sa.Column('is_configured', sa.Boolean(), server_default='false'),
sa.Column('configured_at', sa.TIMESTAMP(), nullable=True),
sa.Column('configured_by', sa.String(100), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP')),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['session_id'], ['installation_sessions.id'], ondelete='SET NULL')
)
op.create_index('idx_env_config_key', 'installation_environment_config', ['config_key'])
op.create_index('idx_env_config_category', 'installation_environment_config', ['config_category'])
op.create_index('idx_env_config_session', 'installation_environment_config', ['session_id'])
def downgrade() -> None:
"""移除環境配置資料表"""
op.drop_index('idx_env_config_session', 'installation_environment_config')
op.drop_index('idx_env_config_category', 'installation_environment_config')
op.drop_index('idx_env_config_key', 'installation_environment_config')
op.drop_table('installation_environment_config')

View File

@@ -0,0 +1,30 @@
"""Initial schema
Revision ID: fba4e3f40f05
Revises:
Create Date: 2026-02-12 11:42:34.613474
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'fba4e3f40f05'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###