release: Elysia ToDo v1.0.0

鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆

Made-with: Cursor
This commit is contained in:
祀梦
2026-03-14 22:21:26 +08:00
commit 2979197b1c
104 changed files with 21737 additions and 0 deletions

498
api/app/routers/accounts.py Normal file
View File

@@ -0,0 +1,498 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import date
from calendar import monthrange
from app.database import get_db
from app.models.account import FinancialAccount, AccountHistory, DebtInstallment
from app.schemas.account import (
AccountCreate, AccountUpdate, AccountResponse, BalanceUpdateRequest,
AccountHistoryResponse, AccountListItemResponse,
DebtInstallmentCreate, DebtInstallmentUpdate, DebtInstallmentResponse,
)
from app.schemas.common import DeleteResponse
from app.utils.crud import get_or_404
from app.utils.datetime import utcnow
from app.utils.logger import logger
router = APIRouter(prefix="/api", tags=["资产"])
def compute_installment_info(installment: DebtInstallment, today: date) -> dict:
"""计算分期计划的下次还款日期、距今天数、剩余期数"""
if installment.is_completed:
return {
"next_payment_date": None,
"days_until_payment": None,
"remaining_periods": 0,
}
remaining = installment.total_periods - installment.current_period + 1
if remaining <= 0:
return {
"next_payment_date": None,
"days_until_payment": None,
"remaining_periods": 0,
}
# 根据 start_date 和 payment_day 计算下一还款日期
payment_day = installment.payment_day
start_year = installment.start_date.year
start_month = installment.start_date.month
# 计算当前应还的期数对应的月份
period_index = installment.current_period - 1
next_month_year = start_year * 12 + (start_month - 1) + period_index
next_year = next_month_year // 12
next_month = next_month_year % 12 + 1
# 处理 payment_day 超出当月天数的情况
max_day = monthrange(next_year, next_month)[1]
actual_day = min(payment_day, max_day)
next_payment_date = date(next_year, next_month, actual_day)
# 如果计算出的日期在 start_date 之前(边界情况),使用 start_date
if next_payment_date < installment.start_date:
next_payment_date = installment.start_date
days_until = (next_payment_date - today).days
return {
"next_payment_date": next_payment_date,
"days_until_payment": days_until,
"remaining_periods": remaining,
}
# ============ 财务账户 API ============
@router.get("/accounts", response_model=List[AccountListItemResponse])
def get_accounts(db: Session = Depends(get_db)):
"""获取所有账户列表"""
try:
accounts = db.query(FinancialAccount).order_by(
FinancialAccount.sort_order.asc(),
FinancialAccount.id.asc()
).all()
result = []
for acc in accounts:
data = {
"id": acc.id,
"name": acc.name,
"account_type": acc.account_type,
"balance": acc.balance,
"icon": acc.icon,
"color": acc.color,
"sort_order": acc.sort_order,
"is_active": acc.is_active,
"description": acc.description,
"created_at": acc.created_at,
"updated_at": acc.updated_at,
}
# 附加分期计划摘要(欠款账户)
if acc.account_type == "debt":
installments = db.query(DebtInstallment).filter(
DebtInstallment.account_id == acc.id,
DebtInstallment.is_completed == False
).all()
today = date.today()
active_installments = []
for inst in installments:
info = compute_installment_info(inst, today)
active_installments.append(info)
data["installments"] = active_installments
else:
data["installments"] = []
result.append(data)
logger.info(f"获取账户列表成功,总数: {len(result)}")
return result
except Exception as e:
logger.error(f"获取账户列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取账户列表失败")
@router.post("/accounts", response_model=AccountResponse, status_code=201)
def create_account(data: AccountCreate, db: Session = Depends(get_db)):
"""创建账户"""
try:
account = FinancialAccount(
name=data.name,
account_type=data.account_type,
balance=data.balance,
icon=data.icon,
color=data.color,
sort_order=data.sort_order,
is_active=data.is_active,
description=data.description,
)
db.add(account)
db.commit()
db.refresh(account)
logger.info(f"创建账户成功: id={account.id}, name={account.name}")
return account
except Exception as e:
db.rollback()
logger.error(f"创建账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建账户失败")
@router.get("/accounts/{account_id}", response_model=AccountResponse)
def get_account(account_id: int, db: Session = Depends(get_db)):
"""获取单个账户"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
return account
except HTTPException:
raise
except Exception as e:
logger.error(f"获取账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取账户失败")
@router.put("/accounts/{account_id}", response_model=AccountResponse)
def update_account(account_id: int, data: AccountUpdate, db: Session = Depends(get_db)):
"""更新账户基本信息"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
update_data = data.model_dump(exclude_unset=True)
# 不允许通过此接口修改余额(使用专门的余额更新接口)
if "balance" in update_data:
del update_data["balance"]
for field, value in update_data.items():
setattr(account, field, value)
account.updated_at = utcnow()
db.commit()
db.refresh(account)
logger.info(f"更新账户成功: id={account_id}")
return account
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新账户失败")
@router.delete("/accounts/{account_id}")
def delete_account(account_id: int, db: Session = Depends(get_db)):
"""删除账户(级联删除历史记录和分期计划)"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
db.delete(account)
db.commit()
logger.info(f"删除账户成功: id={account_id}")
return DeleteResponse(message="账户删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除账户失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除账户失败")
@router.post("/accounts/{account_id}/balance", response_model=AccountResponse)
def update_balance(account_id: int, data: BalanceUpdateRequest, db: Session = Depends(get_db)):
"""更新账户余额(自动记录变更历史)"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
old_balance = account.balance
new_balance = data.new_balance
change_amount = round(new_balance - old_balance, 2)
# 创建历史记录
history = AccountHistory(
account_id=account_id,
change_amount=change_amount,
balance_before=old_balance,
balance_after=new_balance,
note=data.note,
)
db.add(history)
# 更新余额
account.balance = new_balance
account.updated_at = utcnow()
db.commit()
db.refresh(account)
logger.info(f"更新余额成功: account_id={account_id}, {old_balance} -> {new_balance}")
return account
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新余额失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新余额失败")
@router.get("/accounts/{account_id}/history")
def get_account_history(
account_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db)):
"""获取账户变更历史"""
try:
account = get_or_404(db, FinancialAccount, account_id, "账户")
total = db.query(AccountHistory).filter(
AccountHistory.account_id == account_id
).count()
records = db.query(AccountHistory).filter(
AccountHistory.account_id == account_id
).order_by(
AccountHistory.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all()
result = {
"total": total,
"page": page,
"page_size": page_size,
"records": [
{
"id": r.id,
"account_id": r.account_id,
"change_amount": r.change_amount,
"balance_before": r.balance_before,
"balance_after": r.balance_after,
"note": r.note,
"created_at": r.created_at,
}
for r in records
]
}
logger.info(f"获取账户历史成功: account_id={account_id}, total={total}")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"获取账户历史失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取账户历史失败")
# ============ 分期还款计划 API ============
@router.get("/debt-installments", response_model=List[DebtInstallmentResponse])
def get_installments(db: Session = Depends(get_db)):
"""获取所有分期计划(含下次还款计算)"""
try:
installments = db.query(DebtInstallment).order_by(
DebtInstallment.is_completed.asc(),
DebtInstallment.next_payment_date.asc() if hasattr(DebtInstallment, 'next_payment_date') else DebtInstallment.id.asc()
).all()
today = date.today()
result = []
for inst in installments:
info = compute_installment_info(inst, today)
data = {
"id": inst.id,
"account_id": inst.account_id,
"total_amount": inst.total_amount,
"total_periods": inst.total_periods,
"current_period": inst.current_period,
"payment_day": inst.payment_day,
"payment_amount": inst.payment_amount,
"start_date": inst.start_date,
"is_completed": inst.is_completed,
"created_at": inst.created_at,
"updated_at": inst.updated_at,
**info,
}
if inst.account:
data["account_name"] = inst.account.name
data["account_icon"] = inst.account.icon
data["account_color"] = inst.account.color
result.append(data)
# 排序:未完成且临近的排前面
result.sort(key=lambda x: (
0 if not x["is_completed"] and x["days_until_payment"] is not None else 1,
0 if not x["is_completed"] else 1,
x["days_until_payment"] if x["days_until_payment"] is not None else 9999,
))
logger.info(f"获取分期计划列表成功,总数: {len(result)}")
return result
except Exception as e:
logger.error(f"获取分期计划列表失败: {str(e)}")
raise HTTPException(status_code=500, detail="获取分期计划列表失败")
@router.post("/debt-installments", response_model=DebtInstallmentResponse, status_code=201)
def create_installment(data: DebtInstallmentCreate, db: Session = Depends(get_db)):
"""创建分期计划"""
try:
# 验证关联账户存在且为欠款类型
account = get_or_404(db, FinancialAccount, data.account_id, "账户")
if account.account_type != "debt":
raise HTTPException(status_code=400, detail="分期计划只能关联欠款类型的账户")
installment = DebtInstallment(
account_id=data.account_id,
total_amount=data.total_amount,
total_periods=data.total_periods,
current_period=data.current_period,
payment_day=data.payment_day,
payment_amount=data.payment_amount,
start_date=data.start_date,
is_completed=data.is_completed,
)
db.add(installment)
db.commit()
db.refresh(installment)
# 返回含计算字段的响应
today = date.today()
info = compute_installment_info(installment, today)
logger.info(f"创建分期计划成功: id={installment.id}")
return DebtInstallmentResponse(
id=installment.id,
account_id=installment.account_id,
total_amount=installment.total_amount,
total_periods=installment.total_periods,
current_period=installment.current_period,
payment_day=installment.payment_day,
payment_amount=installment.payment_amount,
start_date=installment.start_date,
is_completed=installment.is_completed,
created_at=installment.created_at,
updated_at=installment.updated_at,
**info,
account_name=account.name,
account_icon=account.icon,
account_color=account.color,
)
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"创建分期计划失败: {str(e)}")
raise HTTPException(status_code=500, detail="创建分期计划失败")
@router.put("/debt-installments/{installment_id}", response_model=DebtInstallmentResponse)
def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Session = Depends(get_db)):
"""更新分期计划"""
try:
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(installment, field, value)
installment.updated_at = utcnow()
db.commit()
db.refresh(installment)
today = date.today()
info = compute_installment_info(installment, today)
result = DebtInstallmentResponse(
id=installment.id,
account_id=installment.account_id,
total_amount=installment.total_amount,
total_periods=installment.total_periods,
current_period=installment.current_period,
payment_day=installment.payment_day,
payment_amount=installment.payment_amount,
start_date=installment.start_date,
is_completed=installment.is_completed,
created_at=installment.created_at,
updated_at=installment.updated_at,
**info,
)
if installment.account:
result.account_name = installment.account.name
result.account_icon = installment.account.icon
result.account_color = installment.account.color
logger.info(f"更新分期计划成功: id={installment_id}")
return result
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"更新分期计划失败: {str(e)}")
raise HTTPException(status_code=500, detail="更新分期计划失败")
@router.delete("/debt-installments/{installment_id}")
def delete_installment(installment_id: int, db: Session = Depends(get_db)):
"""删除分期计划"""
try:
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
db.delete(installment)
db.commit()
logger.info(f"删除分期计划成功: id={installment_id}")
return DeleteResponse(message="分期计划删除成功")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"删除分期计划失败: {str(e)}")
raise HTTPException(status_code=500, detail="删除分期计划失败")
@router.patch("/debt-installments/{installment_id}/pay")
def pay_installment(installment_id: int, db: Session = Depends(get_db)):
"""标记已还一期"""
try:
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
if installment.is_completed:
raise HTTPException(status_code=400, detail="该分期计划已全部还清")
installment.current_period += 1
# 检查是否已全部还清
if installment.current_period > installment.total_periods:
installment.is_completed = True
installment.current_period = installment.total_periods
installment.updated_at = utcnow()
db.commit()
db.refresh(installment)
today = date.today()
info = compute_installment_info(installment, today)
result = DebtInstallmentResponse(
id=installment.id,
account_id=installment.account_id,
total_amount=installment.total_amount,
total_periods=installment.total_periods,
current_period=installment.current_period,
payment_day=installment.payment_day,
payment_amount=installment.payment_amount,
start_date=installment.start_date,
is_completed=installment.is_completed,
created_at=installment.created_at,
updated_at=installment.updated_at,
**info,
)
if installment.account:
result.account_name = installment.account.name
result.account_icon = installment.account.icon
result.account_color = installment.account.color
logger.info(f"分期还款成功: id={installment_id}, current_period={installment.current_period}")
return result
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"分期还款失败: {str(e)}")
raise HTTPException(status_code=500, detail="分期还款失败")