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="分期还款失败")