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:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
"""设置日期筛选范围"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user