499 lines
18 KiB
Python
499 lines
18 KiB
Python
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="分期还款失败")
|