feat: implement WeChat cross-batch refund reconciliation and fix misc issues
WeChat cross-batch refund reconciliation: - Add OriginalAmount field to CleanedBill for accurate cumulative refund math - DeduplicateRawFile detects WeChat status-update rows (已退款/已全额退款) and emits WechatRefundUpdates for Go-side reconciliation (Scenario 1) - WechatPy cleaner surfaces -退款 income rows with no same-batch expense match as unresolved_refunds for Go ReconcileRefund (Scenario 2) - Add ReconcileWechatRefund to repository interface and MongoDB implementation - upload.go step 15 iterates WechatRefundUpdates and reconciles against bills_cleaned Bug fixes: - ReviewStats: add nil repo check to prevent panic when DB is not connected - JWT: remove hardcoded fallback secret; return 500/401 if JWTSecret not configured - Remove unused parsePageParam dead code and its strconv import - BillDetailDrawer: show 不计收支 amount in muted gray instead of red - test_jd_cleaner.py: replace hardcoded D:\Projects\BillAI path with dynamic __file__ resolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -98,7 +98,11 @@ func Login(c *gin.Context) {
|
||||
|
||||
secret := config.Global.JWTSecret
|
||||
if secret == "" {
|
||||
secret = "billai-default-secret"
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "服务器 JWT 配置缺失",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
@@ -144,7 +148,12 @@ func ValidateToken(c *gin.Context) {
|
||||
|
||||
secret := config.Global.JWTSecret
|
||||
if secret == "" {
|
||||
secret = "billai-default-secret"
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "服务器 JWT 配置缺失",
|
||||
"code": "TOKEN_INVALID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -154,18 +153,6 @@ func ListBills(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// parsePageParam 解析分页参数
|
||||
func parsePageParam(s string, defaultVal int) int {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
}
|
||||
val, err := strconv.Atoi(s)
|
||||
if err != nil || val < 1 {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// MonthlyStatsResponse 月度统计响应
|
||||
type MonthlyStatsResponse struct {
|
||||
Result bool `json:"result"`
|
||||
@@ -202,6 +189,13 @@ func MonthlyStats(c *gin.Context) {
|
||||
// ReviewStats 获取待复核数据统计
|
||||
func ReviewStats(c *gin.Context) {
|
||||
repo := repository.GetRepository()
|
||||
if repo == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, model.ReviewResponse{
|
||||
Result: false,
|
||||
Message: "数据库未连接",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 从MongoDB查询所有需要复核的账单
|
||||
bills, err := repo.GetBillsNeedReview()
|
||||
|
||||
@@ -269,7 +269,7 @@ func Upload(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 14. 核销跨批次退款(本次清洗中未在同批次内匹配到对应支出的退款)
|
||||
// 14. 核销跨批次退款(支付宝:本次清洗中未在同批次内匹配到对应支出的退款)
|
||||
var reconciledCount int
|
||||
if repo != nil && cleanResult != nil {
|
||||
for _, ur := range cleanResult.UnresolvedRefunds {
|
||||
@@ -285,7 +285,26 @@ func Upload(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 15. 返回成功响应
|
||||
// 15. 核销跨批次微信退款(重复上传的行中携带的已退款状态)
|
||||
if repo != nil && len(dedupResult.WechatRefundUpdates) > 0 {
|
||||
for _, wu := range dedupResult.WechatRefundUpdates {
|
||||
matched, rErr := repo.ReconcileWechatRefund(wu.TransactionID, wu.FullRefund, wu.CumulativeRefundAmount)
|
||||
if rErr != nil {
|
||||
fmt.Printf("⚠️ 微信退款核销失败: %v\n", rErr)
|
||||
continue
|
||||
}
|
||||
if matched {
|
||||
reconciledCount++
|
||||
if wu.FullRefund {
|
||||
fmt.Printf("💰 已核销微信全额退款: 交易单号%s\n", wu.TransactionID)
|
||||
} else {
|
||||
fmt.Printf("💰 已核销微信部分退款: 交易单号%s, 累计退款%.2f元\n", wu.TransactionID, wu.CumulativeRefundAmount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 16. 返回成功响应
|
||||
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
|
||||
if dedupResult.DuplicateCount > 0 {
|
||||
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
||||
|
||||
@@ -38,7 +38,13 @@ func AuthRequired() gin.HandlerFunc {
|
||||
|
||||
secret := config.Global.JWTSecret
|
||||
if secret == "" {
|
||||
secret = "billai-default-secret"
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"error": "服务器 JWT 配置缺失",
|
||||
"code": "TOKEN_INVALID",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
|
||||
@@ -88,9 +88,10 @@ type CleanedBill struct {
|
||||
Category string `bson:"category" json:"category"` // 交易分类
|
||||
Merchant string `bson:"merchant" json:"merchant"` // 交易对方
|
||||
Description string `bson:"description" json:"description"` // 商品说明
|
||||
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
|
||||
Amount float64 `bson:"amount" json:"amount"` // 金额
|
||||
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
|
||||
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
|
||||
Amount float64 `bson:"amount" json:"amount"` // 金额
|
||||
OriginalAmount float64 `bson:"original_amount,omitempty" json:"original_amount,omitempty"` // 原始金额(入库时),用于微信跨批次退款核销
|
||||
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
|
||||
Status string `bson:"status" json:"status"` // 交易状态
|
||||
Remark string `bson:"remark" json:"remark"` // 备注
|
||||
ReviewLevel string `bson:"review_level" json:"review_level"` // 复核等级: HIGH/LOW/空
|
||||
|
||||
@@ -567,8 +567,83 @@ func (r *Repository) ReconcileRefund(billType, orderNo, merchantOrderNo string,
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// 建议: 为提升 ReconcileRefund 查询性能,可为 bills_cleaned 添加索引
|
||||
// {transaction_id:1, bill_type:1} 和 {merchant_order_no:1, bill_type:1}(与现有"无索引"问题一并处理)
|
||||
// ReconcileWechatRefund 将跨批次微信退款核销到已存储的清洗后账单
|
||||
// 微信"当前状态"字段为累计退款金额,使用 original_amount(入库时原始金额)计算剩余
|
||||
func (r *Repository) ReconcileWechatRefund(transactionID string, fullRefund bool, cumulativeRefundAmount float64) (bool, error) {
|
||||
if r.cleanedCollection == nil {
|
||||
return false, fmt.Errorf("cleaned collection not initialized")
|
||||
}
|
||||
if transactionID == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
filter := bson.M{
|
||||
"bill_type": "wechat",
|
||||
"transaction_id": transactionID,
|
||||
"is_deleted": bson.M{"$ne": true},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if fullRefund {
|
||||
update := bson.M{"$set": bson.M{
|
||||
"is_deleted": true,
|
||||
"updated_at": now,
|
||||
"remark": fmt.Sprintf("[退款核销]全额退款;%s", 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
|
||||
}
|
||||
|
||||
// 部分退款:用原始金额(original_amount)减去累计退款金额得到剩余
|
||||
originalAmount := bill.OriginalAmount
|
||||
if originalAmount <= 0 {
|
||||
originalAmount = bill.Amount // 兼容旧记录(无 original_amount 字段)
|
||||
}
|
||||
remaining := originalAmount - cumulativeRefundAmount
|
||||
|
||||
if remaining <= refundEpsilon {
|
||||
update := bson.M{"$set": bson.M{
|
||||
"is_deleted": true,
|
||||
"updated_at": now,
|
||||
"remark": fmt.Sprintf("[退款核销]全额退款%.2f元(原金额%.2f元);%s", cumulativeRefundAmount, originalAmount, 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", originalAmount, cumulativeRefundAmount, 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
|
||||
}
|
||||
|
||||
// 建议: 为提升查询性能,可为 bills_cleaned 添加索引
|
||||
// {transaction_id:1, bill_type:1} 和 {merchant_order_no:1, bill_type:1}
|
||||
|
||||
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
||||
func (r *Repository) GetClient() *mongo.Client {
|
||||
|
||||
@@ -62,4 +62,10 @@ type BillRepository interface {
|
||||
// 全额退款(剩余金额 <= 0.005)则软删除;部分退款则扣减 amount 并追加备注
|
||||
// 返回: 是否找到并核销了匹配记录、错误(未找到匹配记录不算错误,返回 matched=false)
|
||||
ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (matched bool, err error)
|
||||
|
||||
// ReconcileWechatRefund 将跨批次微信退款核销到已存储的清洗后账单
|
||||
// 微信退款通过重复上传时"当前状态"字段携带退款信息来触发
|
||||
// fullRefund=true 时软删除原记录;否则用 original_amount - cumulativeRefundAmount 计算剩余金额
|
||||
// 返回: 是否找到并核销了匹配记录、错误
|
||||
ReconcileWechatRefund(transactionID string, fullRefund bool, cumulativeRefundAmount float64) (matched bool, err error)
|
||||
}
|
||||
|
||||
@@ -23,13 +23,21 @@ func getRepo() repository.BillRepository {
|
||||
return repository.GetRepository()
|
||||
}
|
||||
|
||||
// WechatRefundUpdate 微信重复行中携带的退款信息(用于跨批次退款核销)
|
||||
type WechatRefundUpdate struct {
|
||||
TransactionID string // 原消费行的交易单号
|
||||
FullRefund bool // 是否全额退款(已全额退款)
|
||||
CumulativeRefundAmount float64 // 累计退款金额(已退款(¥X)中的 X,与原始金额相减得剩余)
|
||||
}
|
||||
|
||||
// DeduplicateResult 去重结果
|
||||
type DeduplicateResult struct {
|
||||
OriginalCount int // 原始记录数
|
||||
DuplicateCount int // 重复记录数
|
||||
NewCount int // 新记录数
|
||||
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
|
||||
BillType string // 检测到的账单类型
|
||||
OriginalCount int // 原始记录数
|
||||
DuplicateCount int // 重复记录数
|
||||
NewCount int // 新记录数
|
||||
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
|
||||
BillType string // 检测到的账单类型
|
||||
WechatRefundUpdates []WechatRefundUpdate // 微信重复行中检测到的退款状态(用于跨批次核销)
|
||||
}
|
||||
|
||||
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
|
||||
@@ -75,6 +83,17 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 对于微信账单,找到"当前状态"列的索引,用于检测退款状态
|
||||
wechatStatusIdx := -1
|
||||
if billType == "wechat" {
|
||||
for i, col := range header {
|
||||
if col == "当前状态" {
|
||||
wechatStatusIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查每行是否重复
|
||||
var newRows [][]string
|
||||
for _, row := range dataRows {
|
||||
@@ -101,6 +120,24 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
||||
newRows = append(newRows, row)
|
||||
} else {
|
||||
result.DuplicateCount++
|
||||
// 微信账单:检查重复行是否携带退款状态(跨批次退款核销)
|
||||
if wechatStatusIdx >= 0 && len(row) > wechatStatusIdx {
|
||||
status := strings.TrimSpace(row[wechatStatusIdx])
|
||||
if strings.Contains(status, "已全额退款") {
|
||||
result.WechatRefundUpdates = append(result.WechatRefundUpdates, WechatRefundUpdate{
|
||||
TransactionID: transactionID,
|
||||
FullRefund: true,
|
||||
})
|
||||
} else if strings.Contains(status, "已退款") {
|
||||
if amount := extractWechatRefundAmount(status); amount > 0 {
|
||||
result.WechatRefundUpdates = append(result.WechatRefundUpdates, WechatRefundUpdate{
|
||||
TransactionID: transactionID,
|
||||
FullRefund: false,
|
||||
CumulativeRefundAmount: amount,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +374,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
|
||||
bill.ReviewLevel = row[idx]
|
||||
}
|
||||
|
||||
bill.OriginalAmount = bill.Amount
|
||||
bills = append(bills, bill)
|
||||
}
|
||||
|
||||
@@ -431,6 +469,7 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string
|
||||
bill.ReviewLevel = v
|
||||
}
|
||||
|
||||
bill.OriginalAmount = bill.Amount
|
||||
bills = append(bills, bill)
|
||||
}
|
||||
|
||||
@@ -512,3 +551,28 @@ func parseAmount(s string) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractWechatRefundAmount 从微信"当前状态"字段中提取累计退款金额
|
||||
// 支持格式: "已退款(¥3.00)"、"已退款(¥3.00)"、"已退款¥3.00"
|
||||
func extractWechatRefundAmount(status string) float64 {
|
||||
start := strings.Index(status, "(")
|
||||
end := strings.LastIndex(status, ")")
|
||||
var inner string
|
||||
if start >= 0 && end > start {
|
||||
inner = status[start+1 : end]
|
||||
} else {
|
||||
idx := strings.Index(status, "已退款")
|
||||
if idx < 0 {
|
||||
return 0
|
||||
}
|
||||
inner = status[idx+len("已退款"):]
|
||||
}
|
||||
inner = strings.TrimPrefix(inner, "¥")
|
||||
inner = strings.TrimPrefix(inner, "¥")
|
||||
inner = strings.TrimSpace(inner)
|
||||
v, err := strconv.ParseFloat(inner, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user