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