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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user