|
|
@@ -0,0 +1,504 @@
|
|
|
+#!/usr/bin/env python3
|
|
|
+# -*- coding: utf-8 -*-
|
|
|
+"""
|
|
|
+IndexDebtMixin extracted from IndexManager.
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+from datetime import datetime
|
|
|
+from typing import Any, Dict, List, Optional
|
|
|
+
|
|
|
+
|
|
|
+class IndexDebtMixin:
|
|
|
+ def create_override_contract(self, contract: OverrideContractMeta) -> int:
|
|
|
+ """
|
|
|
+ 创建或更新 Override Contract
|
|
|
+
|
|
|
+ 使用 SQLite 的 INSERT ... ON CONFLICT ... DO UPDATE 实现原子 UPSERT:
|
|
|
+ - 并发安全,无需显式锁
|
|
|
+ - 保持 id 不变,避免 chase_debt.override_contract_id 悬挂
|
|
|
+ - 完全冻结终态:已 fulfilled/cancelled 的合约所有字段都不会被修改
|
|
|
+
|
|
|
+ 兼容性:支持 SQLite 3.24+(ON CONFLICT 语法),不依赖 RETURNING(3.35+)
|
|
|
+
|
|
|
+ 返回合约 ID
|
|
|
+ """
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+
|
|
|
+ # 使用 ON CONFLICT 实现原子 UPSERT(SQLite 3.24+)
|
|
|
+ # 终态完全冻结:fulfilled/cancelled 状态下所有字段都保持不变
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ INSERT INTO override_contracts
|
|
|
+ (chapter, constraint_type, constraint_id, rationale_type,
|
|
|
+ rationale_text, payback_plan, due_chapter, status)
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
+ ON CONFLICT(chapter, constraint_type, constraint_id) DO UPDATE SET
|
|
|
+ rationale_type = CASE
|
|
|
+ WHEN override_contracts.status IN ('fulfilled', 'cancelled')
|
|
|
+ THEN override_contracts.rationale_type
|
|
|
+ ELSE excluded.rationale_type
|
|
|
+ END,
|
|
|
+ rationale_text = CASE
|
|
|
+ WHEN override_contracts.status IN ('fulfilled', 'cancelled')
|
|
|
+ THEN override_contracts.rationale_text
|
|
|
+ ELSE excluded.rationale_text
|
|
|
+ END,
|
|
|
+ payback_plan = CASE
|
|
|
+ WHEN override_contracts.status IN ('fulfilled', 'cancelled')
|
|
|
+ THEN override_contracts.payback_plan
|
|
|
+ ELSE excluded.payback_plan
|
|
|
+ END,
|
|
|
+ due_chapter = CASE
|
|
|
+ WHEN override_contracts.status IN ('fulfilled', 'cancelled')
|
|
|
+ THEN override_contracts.due_chapter
|
|
|
+ ELSE excluded.due_chapter
|
|
|
+ END,
|
|
|
+ status = CASE
|
|
|
+ WHEN override_contracts.status IN ('fulfilled', 'cancelled')
|
|
|
+ THEN override_contracts.status
|
|
|
+ ELSE excluded.status
|
|
|
+ END
|
|
|
+ """,
|
|
|
+ (
|
|
|
+ contract.chapter,
|
|
|
+ contract.constraint_type,
|
|
|
+ contract.constraint_id,
|
|
|
+ contract.rationale_type,
|
|
|
+ contract.rationale_text,
|
|
|
+ contract.payback_plan,
|
|
|
+ contract.due_chapter,
|
|
|
+ contract.status,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ # 不使用 RETURNING(需要 SQLite 3.35+),改用查询获取 id
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ SELECT id FROM override_contracts
|
|
|
+ WHERE chapter = ? AND constraint_type = ? AND constraint_id = ?
|
|
|
+ """,
|
|
|
+ (contract.chapter, contract.constraint_type, contract.constraint_id),
|
|
|
+ )
|
|
|
+ row = cursor.fetchone()
|
|
|
+ if not row:
|
|
|
+ # UPSERT 后查不到记录是异常情况,不应发生
|
|
|
+ raise RuntimeError(
|
|
|
+ f"Override Contract UPSERT 后无法获取 id: "
|
|
|
+ f"chapter={contract.chapter}, type={contract.constraint_type}, "
|
|
|
+ f"id={contract.constraint_id}"
|
|
|
+ )
|
|
|
+ contract_id = row[0]
|
|
|
+
|
|
|
+ conn.commit()
|
|
|
+ return contract_id
|
|
|
+
|
|
|
+ def get_pending_overrides(self, before_chapter: int = None) -> List[Dict]:
|
|
|
+ """获取待偿还的Override Contracts"""
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+ if before_chapter:
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ SELECT * FROM override_contracts
|
|
|
+ WHERE status = 'pending' AND due_chapter <= ?
|
|
|
+ ORDER BY due_chapter ASC
|
|
|
+ """,
|
|
|
+ (before_chapter,),
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ cursor.execute("""
|
|
|
+ SELECT * FROM override_contracts
|
|
|
+ WHERE status = 'pending'
|
|
|
+ ORDER BY due_chapter ASC
|
|
|
+ """)
|
|
|
+ return [dict(row) for row in cursor.fetchall()]
|
|
|
+
|
|
|
+ def get_overdue_overrides(self, current_chapter: int) -> List[Dict]:
|
|
|
+ """获取已逾期的Override Contracts"""
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ SELECT * FROM override_contracts
|
|
|
+ WHERE status = 'pending' AND due_chapter < ?
|
|
|
+ ORDER BY due_chapter ASC
|
|
|
+ """,
|
|
|
+ (current_chapter,),
|
|
|
+ )
|
|
|
+ return [dict(row) for row in cursor.fetchall()]
|
|
|
+
|
|
|
+ def fulfill_override(self, contract_id: int) -> bool:
|
|
|
+ """标记Override Contract为已偿还"""
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ UPDATE override_contracts SET
|
|
|
+ status = 'fulfilled',
|
|
|
+ fulfilled_at = CURRENT_TIMESTAMP
|
|
|
+ WHERE id = ?
|
|
|
+ """,
|
|
|
+ (contract_id,),
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+ return cursor.rowcount > 0
|
|
|
+
|
|
|
+ def get_chapter_overrides(self, chapter: int) -> List[Dict]:
|
|
|
+ """获取某章创建的Override Contracts"""
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ SELECT * FROM override_contracts WHERE chapter = ?
|
|
|
+ """,
|
|
|
+ (chapter,),
|
|
|
+ )
|
|
|
+ return [dict(row) for row in cursor.fetchall()]
|
|
|
+
|
|
|
+ # ==================== v5.3 追读力债务操作 ====================
|
|
|
+
|
|
|
+ def create_debt(self, debt: ChaseDebtMeta) -> int:
|
|
|
+ """
|
|
|
+ 创建追读力债务
|
|
|
+
|
|
|
+ 返回债务 ID
|
|
|
+ """
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ INSERT INTO chase_debt
|
|
|
+ (debt_type, original_amount, current_amount, interest_rate,
|
|
|
+ source_chapter, due_chapter, override_contract_id, status)
|
|
|
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
|
+ """,
|
|
|
+ (
|
|
|
+ debt.debt_type,
|
|
|
+ debt.original_amount,
|
|
|
+ debt.current_amount,
|
|
|
+ debt.interest_rate,
|
|
|
+ debt.source_chapter,
|
|
|
+ debt.due_chapter,
|
|
|
+ debt.override_contract_id if debt.override_contract_id else None,
|
|
|
+ debt.status,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+ debt_id = cursor.lastrowid
|
|
|
+
|
|
|
+ # 记录创建事件
|
|
|
+ self._record_debt_event(
|
|
|
+ cursor,
|
|
|
+ debt_id,
|
|
|
+ "created",
|
|
|
+ debt.original_amount,
|
|
|
+ debt.source_chapter,
|
|
|
+ f"创建债务: {debt.debt_type}",
|
|
|
+ )
|
|
|
+ conn.commit()
|
|
|
+ return debt_id
|
|
|
+
|
|
|
+ def get_active_debts(self) -> List[Dict]:
|
|
|
+ """获取所有活跃债务"""
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+ cursor.execute("""
|
|
|
+ SELECT * FROM chase_debt
|
|
|
+ WHERE status = 'active'
|
|
|
+ ORDER BY due_chapter ASC
|
|
|
+ """)
|
|
|
+ return [dict(row) for row in cursor.fetchall()]
|
|
|
+
|
|
|
+ def get_overdue_debts(self, current_chapter: int) -> List[Dict]:
|
|
|
+ """获取已逾期的债务(包括 active 但已过期的,以及已标记为 overdue 的)"""
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ SELECT * FROM chase_debt
|
|
|
+ WHERE (status = 'overdue')
|
|
|
+ OR (status = 'active' AND due_chapter < ?)
|
|
|
+ ORDER BY due_chapter ASC
|
|
|
+ """,
|
|
|
+ (current_chapter,),
|
|
|
+ )
|
|
|
+ return [dict(row) for row in cursor.fetchall()]
|
|
|
+
|
|
|
+ def get_total_debt_balance(self) -> float:
|
|
|
+ """获取总债务余额(包括 active 和 overdue)"""
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+ cursor.execute("""
|
|
|
+ SELECT COALESCE(SUM(current_amount), 0) FROM chase_debt
|
|
|
+ WHERE status IN ('active', 'overdue')
|
|
|
+ """)
|
|
|
+ return cursor.fetchone()[0]
|
|
|
+
|
|
|
+ def accrue_interest(self, current_chapter: int) -> Dict[str, Any]:
|
|
|
+ """
|
|
|
+ 计算利息(每章调用一次)
|
|
|
+
|
|
|
+ - 对 active 和 overdue 债务都计息(逾期债务继续累积利息)
|
|
|
+ - 使用 debt_events 表防止同一章重复计息
|
|
|
+ - 检查逾期并更新状态
|
|
|
+
|
|
|
+ 返回: {debts_processed, total_interest, new_overdues, skipped_already_processed}
|
|
|
+ """
|
|
|
+ result = {
|
|
|
+ "debts_processed": 0,
|
|
|
+ "total_interest": 0.0,
|
|
|
+ "new_overdues": 0,
|
|
|
+ "skipped_already_processed": 0,
|
|
|
+ }
|
|
|
+
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+
|
|
|
+ # 获取所有未偿还债务(active + overdue 都继续计息)
|
|
|
+ cursor.execute("""
|
|
|
+ SELECT * FROM chase_debt WHERE status IN ('active', 'overdue')
|
|
|
+ """)
|
|
|
+ debts = cursor.fetchall()
|
|
|
+
|
|
|
+ for debt in debts:
|
|
|
+ debt_id = debt["id"]
|
|
|
+ current_amount = debt["current_amount"]
|
|
|
+ interest_rate = debt["interest_rate"]
|
|
|
+ due_chapter = debt["due_chapter"]
|
|
|
+ debt_status = debt["status"]
|
|
|
+
|
|
|
+ # 检查本章是否已计息(防止重复调用)
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ SELECT 1 FROM debt_events
|
|
|
+ WHERE debt_id = ? AND chapter = ? AND event_type = 'interest_accrued'
|
|
|
+ """,
|
|
|
+ (debt_id, current_chapter),
|
|
|
+ )
|
|
|
+ if cursor.fetchone():
|
|
|
+ result["skipped_already_processed"] += 1
|
|
|
+ continue
|
|
|
+
|
|
|
+ # 计算利息
|
|
|
+ interest = current_amount * interest_rate
|
|
|
+ new_amount = current_amount + interest
|
|
|
+
|
|
|
+ # 更新债务
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ UPDATE chase_debt SET
|
|
|
+ current_amount = ?,
|
|
|
+ updated_at = CURRENT_TIMESTAMP
|
|
|
+ WHERE id = ?
|
|
|
+ """,
|
|
|
+ (new_amount, debt_id),
|
|
|
+ )
|
|
|
+
|
|
|
+ # 记录利息事件
|
|
|
+ self._record_debt_event(
|
|
|
+ cursor,
|
|
|
+ debt_id,
|
|
|
+ "interest_accrued",
|
|
|
+ interest,
|
|
|
+ current_chapter,
|
|
|
+ f"利息: {interest:.2f} (利率: {interest_rate * 100:.0f}%)",
|
|
|
+ )
|
|
|
+
|
|
|
+ result["debts_processed"] += 1
|
|
|
+ result["total_interest"] += interest
|
|
|
+
|
|
|
+ # 检查是否逾期(仅对 active 状态的债务)
|
|
|
+ if debt_status == "active" and current_chapter > due_chapter:
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ UPDATE chase_debt SET status = 'overdue'
|
|
|
+ WHERE id = ? AND status = 'active'
|
|
|
+ """,
|
|
|
+ (debt_id,),
|
|
|
+ )
|
|
|
+ if cursor.rowcount > 0:
|
|
|
+ result["new_overdues"] += 1
|
|
|
+ self._record_debt_event(
|
|
|
+ cursor,
|
|
|
+ debt_id,
|
|
|
+ "overdue",
|
|
|
+ new_amount,
|
|
|
+ current_chapter,
|
|
|
+ f"债务逾期 (截止: 第{due_chapter}章)",
|
|
|
+ )
|
|
|
+
|
|
|
+ conn.commit()
|
|
|
+
|
|
|
+ return result
|
|
|
+
|
|
|
+ def pay_debt(self, debt_id: int, amount: float, chapter: int) -> Dict[str, Any]:
|
|
|
+ """
|
|
|
+ 偿还债务
|
|
|
+
|
|
|
+ - 校验 amount > 0
|
|
|
+ - 完全偿还时,使用原子 UPDATE 检查并标记关联 Override 为 fulfilled
|
|
|
+ (并发安全:用 NOT EXISTS 子查询确保所有债务都已清零)
|
|
|
+
|
|
|
+ 返回: {remaining, fully_paid, override_fulfilled}
|
|
|
+ """
|
|
|
+ # 校验偿还金额
|
|
|
+ if amount <= 0:
|
|
|
+ return {
|
|
|
+ "remaining": 0,
|
|
|
+ "fully_paid": False,
|
|
|
+ "error": "偿还金额必须大于0",
|
|
|
+ }
|
|
|
+
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+
|
|
|
+ cursor.execute(
|
|
|
+ "SELECT current_amount, override_contract_id FROM chase_debt WHERE id = ?",
|
|
|
+ (debt_id,),
|
|
|
+ )
|
|
|
+ row = cursor.fetchone()
|
|
|
+ if not row:
|
|
|
+ return {"remaining": 0, "fully_paid": False, "error": "债务不存在"}
|
|
|
+
|
|
|
+ current = row["current_amount"]
|
|
|
+ override_contract_id = row["override_contract_id"]
|
|
|
+ remaining = max(0, current - amount)
|
|
|
+ override_fulfilled = False
|
|
|
+
|
|
|
+ if remaining == 0:
|
|
|
+ # 完全偿还
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ UPDATE chase_debt SET
|
|
|
+ current_amount = 0,
|
|
|
+ status = 'paid',
|
|
|
+ updated_at = CURRENT_TIMESTAMP
|
|
|
+ WHERE id = ?
|
|
|
+ """,
|
|
|
+ (debt_id,),
|
|
|
+ )
|
|
|
+ self._record_debt_event(
|
|
|
+ cursor, debt_id, "full_payment", amount, chapter, "债务已完全偿还"
|
|
|
+ )
|
|
|
+
|
|
|
+ # 原子检查并标记 Override 为 fulfilled
|
|
|
+ # 使用 NOT EXISTS 子查询确保并发安全:只有当确实没有未清债务时才更新
|
|
|
+ if override_contract_id:
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ UPDATE override_contracts SET
|
|
|
+ status = 'fulfilled',
|
|
|
+ fulfilled_at = CURRENT_TIMESTAMP
|
|
|
+ WHERE id = ?
|
|
|
+ AND status = 'pending'
|
|
|
+ AND NOT EXISTS (
|
|
|
+ SELECT 1 FROM chase_debt
|
|
|
+ WHERE override_contract_id = ?
|
|
|
+ AND status IN ('active', 'overdue')
|
|
|
+ )
|
|
|
+ """,
|
|
|
+ (override_contract_id, override_contract_id),
|
|
|
+ )
|
|
|
+ if cursor.rowcount > 0:
|
|
|
+ override_fulfilled = True
|
|
|
+ else:
|
|
|
+ # 部分偿还
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ UPDATE chase_debt SET
|
|
|
+ current_amount = ?,
|
|
|
+ updated_at = CURRENT_TIMESTAMP
|
|
|
+ WHERE id = ?
|
|
|
+ """,
|
|
|
+ (remaining, debt_id),
|
|
|
+ )
|
|
|
+ self._record_debt_event(
|
|
|
+ cursor,
|
|
|
+ debt_id,
|
|
|
+ "partial_payment",
|
|
|
+ amount,
|
|
|
+ chapter,
|
|
|
+ f"部分偿还,剩余: {remaining:.2f}",
|
|
|
+ )
|
|
|
+
|
|
|
+ conn.commit()
|
|
|
+ return {
|
|
|
+ "remaining": remaining,
|
|
|
+ "fully_paid": remaining == 0,
|
|
|
+ "override_fulfilled": override_fulfilled,
|
|
|
+ }
|
|
|
+
|
|
|
+ def _record_debt_event(
|
|
|
+ self,
|
|
|
+ cursor,
|
|
|
+ debt_id: int,
|
|
|
+ event_type: str,
|
|
|
+ amount: float,
|
|
|
+ chapter: int,
|
|
|
+ note: str = "",
|
|
|
+ ):
|
|
|
+ """记录债务事件(内部方法)"""
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ INSERT INTO debt_events (debt_id, event_type, amount, chapter, note)
|
|
|
+ VALUES (?, ?, ?, ?, ?)
|
|
|
+ """,
|
|
|
+ (debt_id, event_type, amount, chapter, note),
|
|
|
+ )
|
|
|
+
|
|
|
+ def get_debt_history(self, debt_id: int) -> List[Dict]:
|
|
|
+ """获取债务的事件历史"""
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+ cursor.execute(
|
|
|
+ """
|
|
|
+ SELECT * FROM debt_events
|
|
|
+ WHERE debt_id = ?
|
|
|
+ ORDER BY created_at ASC
|
|
|
+ """,
|
|
|
+ (debt_id,),
|
|
|
+ )
|
|
|
+ return [dict(row) for row in cursor.fetchall()]
|
|
|
+
|
|
|
+ # ==================== v5.3 章节追读力元数据操作 ====================
|
|
|
+
|
|
|
+ def get_debt_summary(self) -> Dict[str, Any]:
|
|
|
+ """获取债务汇总信息"""
|
|
|
+ with self._get_conn() as conn:
|
|
|
+ cursor = conn.cursor()
|
|
|
+
|
|
|
+ # 活跃债务
|
|
|
+ cursor.execute("""
|
|
|
+ SELECT COUNT(*) as count, COALESCE(SUM(current_amount), 0) as total
|
|
|
+ FROM chase_debt WHERE status = 'active'
|
|
|
+ """)
|
|
|
+ active = cursor.fetchone()
|
|
|
+
|
|
|
+ # 逾期债务
|
|
|
+ cursor.execute("""
|
|
|
+ SELECT COUNT(*) as count, COALESCE(SUM(current_amount), 0) as total
|
|
|
+ FROM chase_debt WHERE status = 'overdue'
|
|
|
+ """)
|
|
|
+ overdue = cursor.fetchone()
|
|
|
+
|
|
|
+ # 待偿还Override
|
|
|
+ cursor.execute("""
|
|
|
+ SELECT COUNT(*) FROM override_contracts WHERE status = 'pending'
|
|
|
+ """)
|
|
|
+ pending_overrides = cursor.fetchone()[0]
|
|
|
+
|
|
|
+ return {
|
|
|
+ "active_debts": active["count"],
|
|
|
+ "active_total": active["total"],
|
|
|
+ "overdue_debts": overdue["count"],
|
|
|
+ "overdue_total": overdue["total"],
|
|
|
+ "pending_overrides": pending_overrides,
|
|
|
+ "total_balance": active["total"] + overdue["total"],
|
|
|
+ }
|
|
|
+
|
|
|
+ # ==================== 批量操作 ====================
|
|
|
+
|