feat: implement cross-batch Alipay refund reconciliation

When a refund row in an uploaded Alipay bill has no matching expense
row in the same batch (because the original purchase was uploaded in a
prior batch), the refund is now reconciled against the stored record in
bills_cleaned rather than being silently discarded.

Changes:
- analyzer/cleaners/base.py: add unresolved_refunds list to BaseCleaner
- analyzer/cleaners/alipay.py: _aggregate_refunds stores full refund
  metadata (dict); _process_expenses tracks matched keys and populates
  self.unresolved_refunds for unmatched refunds
- analyzer/server.py: thread unresolved_refunds through do_clean,
  CleanResponse, and both /clean endpoints
- server/adapter/adapter.go: add UnresolvedRefund type and field to CleanResult
- server/adapter/http/cleaner.go: deserialize unresolved_refunds from
  Python response and populate CleanResult
- server/repository/repository.go: add ReconcileRefund to BillRepository interface
- server/repository/mongo/repository.go: implement ReconcileRefund —
  full refund soft-deletes the bill, partial refund reduces amount and
  appends remark with original amount and refund order number
- server/handler/upload.go: capture clean result and call ReconcileRefund
  for each unresolved refund after saving cleaned bills
- server/model/response.go: add ReconciledRefundCount to UploadData

Also: add CLAUDE.md (@AGENTS.md), update AGENTS.md, fix DailyTrendChart
missing-date gap by filling zero-expense dates in daily map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 19:29:47 +08:00
parent bb717faac3
commit e2e1beb6f7
12 changed files with 342 additions and 133 deletions

View File

@@ -100,45 +100,54 @@ class AlipayCleaner(BaseCleaner):
def _aggregate_refunds(self, refund_rows: list) -> dict:
"""聚合退款金额"""
order_refunds = {}
for row in refund_rows:
if len(row) >= 11:
refund_order_no = row[9].strip()
refund_merchant_no = row[10].strip()
refund_amount = parse_amount(row[6])
original_order = refund_order_no.split("_")[0] if "_" in refund_order_no else refund_order_no
key = original_order if original_order else refund_merchant_no
if key:
if key not in order_refunds:
order_refunds[key] = Decimal("0")
order_refunds[key] += refund_amount
order_refunds[key] = {
"amount": Decimal("0"),
"merchant_order_no": refund_merchant_no,
"refund_order_no": refund_order_no,
"time": row[0],
"merchant": row[2],
"description": row[4] if len(row) > 4 else "",
}
order_refunds[key]["amount"] += refund_amount
print(f" 退款记录: {row[0]} | {row[2]} | {refund_amount}")
return order_refunds
def _process_expenses(self, expense_rows: list, order_refunds: dict) -> list:
"""处理支出记录"""
final_rows = []
matched_keys = set()
for row in expense_rows:
if len(row) >= 12:
order_no = row[9].strip()
merchant_no = row[10].strip()
expense_amount = parse_amount(row[6])
# 查找对应的退款
refund_amount = Decimal("0")
matched_key = None
for key, amount in order_refunds.items():
for key, refund in order_refunds.items():
if key and (order_no == key or merchant_no == key or order_no.startswith(key)):
refund_amount = amount
refund_amount = refund["amount"]
matched_key = key
break
if matched_key:
matched_keys.add(matched_key)
if refund_amount >= expense_amount:
# 全额退款,删除
self.stats["fully_refunded"] += 1
@@ -148,10 +157,10 @@ class AlipayCleaner(BaseCleaner):
remaining = expense_amount - refund_amount
new_row = row.copy()
new_row[6] = format_amount(remaining)
original_remark = new_row[11] if len(new_row) > 11 else ""
new_row[11] = f"原金额{expense_amount}元,退款{refund_amount}{';' + original_remark if original_remark else ''}"
final_rows.append(new_row)
self.stats["partially_refunded"] += 1
print(f" 部分退款: {row[0]} | {row[2]} | 原{expense_amount}元 -> {format_amount(remaining)}")
@@ -163,7 +172,22 @@ class AlipayCleaner(BaseCleaner):
self.stats["zero_amount"] = self.stats.get("zero_amount", 0) + 1
else:
final_rows.append(row)
# 本批次内未匹配到对应支出的退款,交由调用方做跨批次核销
self.unresolved_refunds = [
{
"order_no": key,
"merchant_order_no": refund["merchant_order_no"],
"refund_order_no": refund["refund_order_no"],
"amount": float(format_amount(refund["amount"])),
"time": refund["time"],
"merchant": refund["merchant"],
"description": refund["description"],
}
for key, refund in order_refunds.items()
if key not in matched_keys
]
return final_rows
def _is_platform_merchant(self, merchant: str) -> bool:

View File

@@ -220,6 +220,9 @@ class BaseCleaner(ABC):
"category_adjusted": 0,
"final_count": 0,
}
# 本次清理中未能在同批次内匹配到对应支出的退款(跨批次核销用)
self.unresolved_refunds: list[dict] = []
def set_date_range(self, start_date: date | None, end_date: date | None):
"""设置日期筛选范围"""

View File

@@ -52,6 +52,7 @@ class CleanResponse(BaseModel):
bill_type: str
message: str
output_path: Optional[str] = None
unresolved_refunds: list[dict] = []
class CategoryRequest(BaseModel):
@@ -138,27 +139,27 @@ def do_clean(
start: str = None,
end: str = None,
output_format: str = "csv"
) -> tuple[bool, str, str]:
) -> tuple[bool, str, str, list[dict]]:
"""
执行清洗逻辑
Returns:
(success, bill_type, message)
(success, bill_type, message, unresolved_refunds)
"""
# 检查文件是否存在
if not Path(input_path).exists():
return False, "", f"文件不存在: {input_path}"
return False, "", f"文件不存在: {input_path}", []
# 检测账单类型
if bill_type == "auto":
detected_type = detect_bill_type(input_path)
if detected_type is None:
return False, "", "无法识别账单类型"
return False, "", "无法识别账单类型", []
bill_type = detected_type
# 计算日期范围
start_date, end_date = compute_date_range_from_values(year, month, start, end)
# 创建对应的清理器
try:
if bill_type == "alipay":
@@ -167,15 +168,15 @@ def do_clean(
cleaner = JDCleaner(input_path, output_path, output_format)
else:
cleaner = WechatCleaner(input_path, output_path, output_format)
cleaner.set_date_range(start_date, end_date)
cleaner.clean()
type_names = {"alipay": "支付宝", "wechat": "微信", "jd": "京东白条"}
return True, bill_type, f"{type_names.get(bill_type, bill_type)}账单清洗完成"
return True, bill_type, f"{type_names.get(bill_type, bill_type)}账单清洗完成", cleaner.unresolved_refunds
except Exception as e:
return False, bill_type, f"清洗失败: {str(e)}"
return False, bill_type, f"清洗失败: {str(e)}", []
# =============================================================================
@@ -215,7 +216,7 @@ async def clean_bill(request: CleanRequest):
接收账单文件路径,执行清洗后输出到指定路径
"""
success, bill_type, message = do_clean(
success, bill_type, message, unresolved_refunds = do_clean(
input_path=request.input_path,
output_path=request.output_path,
bill_type=request.bill_type or "auto",
@@ -225,15 +226,16 @@ async def clean_bill(request: CleanRequest):
end=request.end,
output_format=request.format or "csv"
)
if not success:
raise HTTPException(status_code=400, detail=message)
return CleanResponse(
success=True,
bill_type=bill_type,
message=message,
output_path=request.output_path
output_path=request.output_path,
unresolved_refunds=unresolved_refunds
)
@@ -264,7 +266,7 @@ async def clean_bill_upload(
output_path = tmp_output.name
try:
success, detected_type, message = do_clean(
success, detected_type, message, unresolved_refunds = do_clean(
input_path=input_path,
output_path=output_path,
bill_type=bill_type or "auto",
@@ -274,15 +276,16 @@ async def clean_bill_upload(
end=end,
output_format=format or "csv"
)
if not success:
raise HTTPException(status_code=400, detail=message)
return CleanResponse(
success=True,
bill_type=detected_type,
message=message,
output_path=output_path
output_path=output_path,
unresolved_refunds=unresolved_refunds
)
finally: