release: Elysia ToDo v1.0.0
鍏ㄦ爤涓汉淇℃伅绠$悊搴旂敤锛岄泦鎴愬緟鍔炰换鍔°€佷範鎯墦鍗°€佺邯蹇垫棩鎻愰啋銆佽祫浜ф€昏鍔熻兘銆 Made-with: Cursor
This commit is contained in:
498
api/app/routers/accounts.py
Normal file
498
api/app/routers/accounts.py
Normal 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="分期还款失败")
|
||||
Reference in New Issue
Block a user