feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage

Major Features:
-  Multi-tenant architecture (tenant isolation)
-  Employee CRUD with lifecycle management (onboarding/offboarding)
-  Department tree structure with email domain management
-  Company info management (single-record editing)
-  System functions CRUD (permission management)
-  Email account management (multi-account per employee)
-  Keycloak SSO integration (auth.lab.taipei)
-  Redis session storage (10.1.0.254:6379)
  - Solves Cookie 4KB limitation
  - Cross-system session sharing
  - Sliding expiration (8 hours)
  - Automatic token refresh

Technical Stack:
Backend:
- FastAPI + SQLAlchemy
- PostgreSQL 16 (10.1.0.20:5433)
- Keycloak Admin API integration
- Docker Mailserver integration (SSH)
- Alembic migrations

Frontend:
- Next.js 14 (App Router)
- NextAuth 4 with Keycloak Provider
- Redis session storage (ioredis)
- Tailwind CSS

Infrastructure:
- Redis 7 (10.1.0.254:6379) - Session + Cache
- Keycloak 26.1.0 (auth.lab.taipei)
- Docker Mailserver (10.1.0.254)

Architecture Highlights:
- Session管理由 Keycloak + Redis 統一控制
- 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session
- Token 自動刷新,異質服務整合
- 未來可無縫遷移到雲端

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 20:12:43 +08:00
commit 360533393f
386 changed files with 70353 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
"""
發票記錄 Model
管理租戶的帳單和發票
"""
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, Date, DateTime, Numeric, ForeignKey, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
import enum
from app.db.base import Base
class InvoiceStatus(str, enum.Enum):
"""發票狀態"""
PENDING = "pending" # 待付款
PAID = "paid" # 已付款
OVERDUE = "overdue" # 逾期未付
CANCELLED = "cancelled" # 已取消
class Invoice(Base):
"""發票記錄表"""
__tablename__ = "invoices"
id = Column(Integer, primary_key=True, index=True)
tenant_id = Column(Integer, ForeignKey("organizes.id", ondelete="CASCADE"), nullable=False, index=True)
# 發票資訊
invoice_number = Column(String(50), unique=True, nullable=False, index=True, comment="發票號碼 (INV-2026-03-001)")
issue_date = Column(Date, nullable=False, comment="開立日期")
due_date = Column(Date, nullable=False, comment="到期日")
# 金額
amount = Column(Numeric(10, 2), nullable=False, comment="金額 (未稅)")
tax = Column(Numeric(10, 2), default=0, nullable=False, comment="稅額")
total = Column(Numeric(10, 2), nullable=False, comment="總計 (含稅)")
# 狀態
status = Column(String(20), default=InvoiceStatus.PENDING, nullable=False, comment="狀態")
# 付款資訊
paid_at = Column(DateTime, nullable=True, comment="付款時間")
payment_method = Column(String(20), nullable=True, comment="付款方式 (credit_card/wire_transfer)")
# 發票明細 (JSON 格式)
line_items = Column(JSONB, nullable=True, comment="發票明細")
# 範例:
# [
# {"description": "標準方案 (20 人)", "quantity": 1, "unit_price": 10000, "amount": 10000},
# {"description": "超額用戶 (2 人)", "quantity": 2, "unit_price": 500, "amount": 1000}
# ]
# PDF 檔案
pdf_path = Column(String(200), nullable=True, comment="發票 PDF 路徑")
# 備註
notes = Column(Text, nullable=True, comment="備註")
# 時間記錄
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# 關聯
tenant = relationship("Tenant")
payments = relationship(
"Payment",
back_populates="invoice",
cascade="all, delete-orphan",
lazy="selectin"
)
def __repr__(self):
return f"<Invoice {self.invoice_number} - NT$ {self.total} ({self.status})>"
@property
def is_paid(self) -> bool:
"""是否已付款"""
return self.status == InvoiceStatus.PAID
@property
def is_overdue(self) -> bool:
"""是否逾期"""
return (
self.status in [InvoiceStatus.PENDING, InvoiceStatus.OVERDUE] and
date.today() > self.due_date
)
@property
def days_overdue(self) -> int:
"""逾期天數"""
if not self.is_overdue:
return 0
return (date.today() - self.due_date).days
def mark_as_paid(self, payment_method: str = None):
"""標記為已付款"""
self.status = InvoiceStatus.PAID
self.paid_at = datetime.utcnow()
if payment_method:
self.payment_method = payment_method
@classmethod
def generate_invoice_number(cls, year: int, month: int, sequence: int) -> str:
"""生成發票號碼"""
return f"INV-{year:04d}-{month:02d}-{sequence:03d}"