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:
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
92
backend/alembic/env.py
Normal file
92
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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"}
|
||||
114
backend/alembic/versions/0001_5_add_tenants_table.py
Normal file
114
backend/alembic/versions/0001_5_add_tenants_table.py
Normal 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')
|
||||
@@ -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')
|
||||
141
backend/alembic/versions/0003_extend_organization_structure.py
Normal file
141
backend/alembic/versions/0003_extend_organization_structure.py
Normal 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")
|
||||
42
backend/alembic/versions/0004_add_batch_logs_table.py
Normal file
42
backend/alembic/versions/0004_add_batch_logs_table.py
Normal 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')
|
||||
343
backend/alembic/versions/0005_multi_tenant_refactor.py
Normal file
343
backend/alembic/versions/0005_multi_tenant_refactor.py
Normal 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'])
|
||||
28
backend/alembic/versions/0006_add_name_en_to_departments.py
Normal file
28
backend/alembic/versions/0006_add_name_en_to_departments.py
Normal 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')
|
||||
@@ -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')
|
||||
71
backend/alembic/versions/0008_add_is_active_to_all_tables.py
Normal file
71
backend/alembic/versions/0008_add_is_active_to_all_tables.py
Normal 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')
|
||||
144
backend/alembic/versions/0009_add_tenant_scoped_sequences.py
Normal file
144
backend/alembic/versions/0009_add_tenant_scoped_sequences.py
Normal 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')
|
||||
341
backend/alembic/versions/0010_refactor_to_final_architecture.py
Normal file
341
backend/alembic/versions/0010_refactor_to_final_architecture.py
Normal 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')
|
||||
115
backend/alembic/versions/0011_cleanup_and_finalize.py
Normal file
115
backend/alembic/versions/0011_cleanup_and_finalize.py
Normal 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', ...)
|
||||
@@ -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 不刪除此表
|
||||
@@ -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')
|
||||
347
backend/alembic/versions/0014_add_installation_system.py
Normal file
347
backend/alembic/versions/0014_add_installation_system.py
Normal 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
@@ -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")
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
30
backend/alembic/versions/fba4e3f40f05_initial_schema.py
Normal file
30
backend/alembic/versions/fba4e3f40f05_initial_schema.py
Normal 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 ###
|
||||
Reference in New Issue
Block a user