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: