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:
2026-06-16 21:38:25 +08:00
parent e2e1beb6f7
commit a2de8c5078
13 changed files with 246 additions and 31 deletions

View File

@@ -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
}