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

@@ -4,6 +4,7 @@ package mongo
import (
"context"
"fmt"
"math"
"time"
"go.mongodb.org/mongo-driver/bson"
@@ -16,6 +17,9 @@ import (
"billai-server/repository"
)
// refundEpsilon 退款核销后剩余金额的容差阈值,小于该值视为已全额退款
const refundEpsilon = 0.005
// Repository MongoDB 账单存储实现
type Repository struct {
client *mongo.Client
@@ -498,6 +502,74 @@ func (r *Repository) SoftDeleteJDRelatedBills() (int64, error) {
return result.ModifiedCount, nil
}
// ReconcileRefund 将跨批次退款核销到已存储的清洗后账单
// 按 bill_type + (transaction_id == orderNo 或 merchant_order_no == merchantOrderNo) 查找未删除记录
// 全额退款(剩余金额 <= refundEpsilon则软删除部分退款则扣减 amount 并追加备注
func (r *Repository) ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (bool, error) {
if r.cleanedCollection == nil {
return false, fmt.Errorf("cleaned collection not initialized")
}
if orderNo == "" && merchantOrderNo == "" {
return false, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var or []bson.M
if orderNo != "" {
or = append(or, bson.M{"transaction_id": orderNo})
}
if merchantOrderNo != "" {
or = append(or, bson.M{"merchant_order_no": merchantOrderNo})
}
filter := bson.M{
"bill_type": billType,
"is_deleted": bson.M{"$ne": true},
"$or": or,
}
var bill model.CleanedBill
if err := r.cleanedCollection.FindOne(ctx, filter).Decode(&bill); err != nil {
if err == mongo.ErrNoDocuments {
return false, nil
}
return false, fmt.Errorf("查询待核销账单失败: %w", err)
}
remaining := bill.Amount - refundAmount
now := time.Now()
if remaining <= refundEpsilon {
update := bson.M{"$set": bson.M{
"is_deleted": true,
"updated_at": now,
"remark": fmt.Sprintf("[退款核销]全额退款%.2f元(退款单号%s);%s", refundAmount, refundOrderNo, bill.Remark),
}}
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
if err != nil {
return false, fmt.Errorf("核销退款失败: %w", err)
}
return true, nil
}
remaining = math.Round(remaining*100) / 100
update := bson.M{"$set": bson.M{
"amount": remaining,
"updated_at": now,
"remark": fmt.Sprintf("原金额%.2f元,退款%.2f元(退款单号%s);%s", bill.Amount, refundAmount, refundOrderNo, bill.Remark),
}}
_, err := r.cleanedCollection.UpdateOne(ctx, bson.M{"_id": bill.ID}, update)
if err != nil {
return false, fmt.Errorf("核销退款失败: %w", err)
}
return true, nil
}
// 建议: 为提升 ReconcileRefund 查询性能,可为 bills_cleaned 添加索引
// {transaction_id:1, bill_type:1} 和 {merchant_order_no:1, bill_type:1}(与现有"无索引"问题一并处理)
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
func (r *Repository) GetClient() *mongo.Client {
return r.client

View File

@@ -56,4 +56,10 @@ type BillRepository interface {
// 用于避免京东账单与其他来源(微信、支付宝)账单重复计算
// 返回: 删除数量、错误
SoftDeleteJDRelatedBills() (int64, error)
// ReconcileRefund 将跨批次退款核销到已存储的清洗后账单
// 按 bill_type + (transaction_id == orderNo 或 merchant_order_no == merchantOrderNo) 查找未删除记录
// 全额退款(剩余金额 <= 0.005)则软删除;部分退款则扣减 amount 并追加备注
// 返回: 是否找到并核销了匹配记录、错误(未找到匹配记录不算错误,返回 matched=false
ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (matched bool, err error)
}