Compare commits
1 Commits
dev
...
f465faea38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f465faea38 |
@@ -92,13 +92,6 @@ Browser → SvelteKit proxy → Go (Gin) → handler → service → adapter →
|
|||||||
### File Processing Pipeline
|
### File Processing Pipeline
|
||||||
Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type (alipay/wechat/jd) → Deduplicate against MongoDB → Clean via Python → Save cleaned data.
|
Upload: ZIP/XLSX → Extract → Convert UTF-8 CSV → Detect bill type (alipay/wechat/jd) → Deduplicate against MongoDB → Clean via Python → Save cleaned data.
|
||||||
|
|
||||||
### Cross-Batch Refund Reconciliation
|
|
||||||
When the original purchase and its refund are in different upload batches, within-batch logic can't match them. Two reconciliation paths handle this:
|
|
||||||
|
|
||||||
**Alipay** (`handler/upload.go` step 14): After cleaning, `cleaner.unresolved_refunds` (refund rows that found no matching expense in the same file) is returned by FastAPI and iterated in Go. `ReconcileRefund()` looks up the original expense by `transaction_id` or `merchant_order_no` and either soft-deletes (full refund) or deducts `amount` (partial). Alipay refund rows have their own distinct `transaction_id` (suffixed `_<refund_id>`), so they pass raw dedup and are naturally idempotent on re-upload.
|
|
||||||
|
|
||||||
**WeChat** (`handler/upload.go` step 15): WeChat re-exports the *same row* (same `transaction_id`) with an updated `当前状态` field (`已全额退款` or `已退款(¥X)`). `DeduplicateRawFile` detects these duplicate rows, extracts the refund info into `DeduplicateResult.WechatRefundUpdates`, and `ReconcileWechatRefund()` applies the update. WeChat's `¥X` is the **cumulative** total refunded, so `CleanedBill.original_amount` (set at first save) is used to compute `remaining = original_amount - X`.
|
|
||||||
|
|
||||||
### Adapter (Go ↔ Python)
|
### Adapter (Go ↔ Python)
|
||||||
`adapter.Cleaner` interface. Two modes:
|
`adapter.Cleaner` interface. Two modes:
|
||||||
- `http` (default): calls FastAPI at `ANALYZER_URL`
|
- `http` (default): calls FastAPI at `ANALYZER_URL`
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -7,23 +7,6 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### 新增
|
|
||||||
- **微信跨批次退款核销** - 重复上传微信账单时,自动识别"当前状态"字段中的退款信息并核销原始支出记录
|
|
||||||
- 支持「已全额退款」:原支出记录软删除
|
|
||||||
- 支持「已退款(¥X)」:累计退款金额从原始金额(`original_amount`)中扣减,剩余金额更新到记录
|
|
||||||
- 新增 `CleanedBill.original_amount` 字段,存储入库时的原始金额,保证多次部分退款累计计算正确
|
|
||||||
- `ReconcileWechatRefund` 接口和 MongoDB 实现
|
|
||||||
- **支付宝跨批次退款核销** - 上传含退款行的支付宝账单时,若原消费记录来自更早的批次,自动在数据库中核销
|
|
||||||
- 退款行的「交易订单号」带后缀(`原订单号_退款编号`),天然幂等:重复上传时被原始数据去重拦截,不会重复核销
|
|
||||||
- 全额退款软删除,部分退款扣减金额并追加备注
|
|
||||||
- `ReconcileRefund` 接口和 MongoDB 实现
|
|
||||||
|
|
||||||
### 技术改进
|
|
||||||
- `server/service/bill.go`:`DeduplicateRawFile` 在微信账单去重阶段检测已退款状态行,收集 `WechatRefundUpdates` 供后续核销
|
|
||||||
- `server/service/bill.go`:`saveCleanedBillsFromCSV` / `saveCleanedBillsFromJSON` 入库时同步写入 `original_amount`
|
|
||||||
- `analyzer/cleaners/alipay.py`:`_process_expenses` 收集同批次内未匹配的退款(`unresolved_refunds`),通过 FastAPI 响应透传至 Go
|
|
||||||
- `analyzer/cleaners/base.py`:`BaseCleaner` 基类新增 `unresolved_refunds` 属性
|
|
||||||
|
|
||||||
## [1.4.0] - 2026-03-23
|
## [1.4.0] - 2026-03-23
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -55,22 +55,6 @@ class WechatCleaner(BaseCleaner):
|
|||||||
# 第三步:处理退款(包括转账退款)
|
# 第三步:处理退款(包括转账退款)
|
||||||
final_expense_rows, income_rows = self._process_refunds(expense_rows, income_rows)
|
final_expense_rows, income_rows = self._process_refunds(expense_rows, income_rows)
|
||||||
|
|
||||||
# 收集跨批次未匹配的 -退款 行(当前批次无对应支出记录,需 Go 侧跨批次核销)
|
|
||||||
expense_merchants = {exp[2].strip() for exp in expense_rows}
|
|
||||||
for refund_row in refund_rows:
|
|
||||||
if refund_row[2].strip() not in expense_merchants:
|
|
||||||
amount = float(parse_amount(refund_row[5]))
|
|
||||||
if amount > 0:
|
|
||||||
self.unresolved_refunds.append({
|
|
||||||
"order_no": "",
|
|
||||||
"merchant_order_no": refund_row[9].strip() if len(refund_row) > 9 else "",
|
|
||||||
"refund_order_no": refund_row[8].strip() if len(refund_row) > 8 else "",
|
|
||||||
"amount": amount,
|
|
||||||
"time": refund_row[0],
|
|
||||||
"merchant": refund_row[2],
|
|
||||||
"description": refund_row[3] if len(refund_row) > 3 else "",
|
|
||||||
})
|
|
||||||
|
|
||||||
print(f"\n处理结果:")
|
print(f"\n处理结果:")
|
||||||
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
print(f" 全额退款删除: {self.stats['fully_refunded']} 条")
|
||||||
print(f" 部分退款调整: {self.stats['partially_refunded']} 条")
|
print(f" 部分退款调整: {self.stats['partially_refunded']} 条")
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import sys
|
|||||||
sys.stdout.reconfigure(encoding='utf-8')
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
|
||||||
def test_jd_cleaner():
|
def test_jd_cleaner():
|
||||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
zip_path = r'D:\Projects\BillAI\mock_data\京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip'
|
||||||
zip_path = os.path.join(base_dir, 'mock_data', '京东交易流水(申请时间2026年01月26日13时29分47秒)(密码683263)_209.zip')
|
|
||||||
|
|
||||||
with zipfile.ZipFile(zip_path, 'r') as zf:
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
|||||||
@@ -98,11 +98,7 @@ func Login(c *gin.Context) {
|
|||||||
|
|
||||||
secret := config.Global.JWTSecret
|
secret := config.Global.JWTSecret
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
secret = "billai-default-secret"
|
||||||
"success": false,
|
|
||||||
"error": "服务器 JWT 配置缺失",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
@@ -148,12 +144,7 @@ func ValidateToken(c *gin.Context) {
|
|||||||
|
|
||||||
secret := config.Global.JWTSecret
|
secret := config.Global.JWTSecret
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
secret = "billai-default-secret"
|
||||||
"success": false,
|
|
||||||
"error": "服务器 JWT 配置缺失",
|
|
||||||
"code": "TOKEN_INVALID",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -153,6 +154,18 @@ 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 月度统计响应
|
// MonthlyStatsResponse 月度统计响应
|
||||||
type MonthlyStatsResponse struct {
|
type MonthlyStatsResponse struct {
|
||||||
Result bool `json:"result"`
|
Result bool `json:"result"`
|
||||||
@@ -189,13 +202,6 @@ func MonthlyStats(c *gin.Context) {
|
|||||||
// ReviewStats 获取待复核数据统计
|
// ReviewStats 获取待复核数据统计
|
||||||
func ReviewStats(c *gin.Context) {
|
func ReviewStats(c *gin.Context) {
|
||||||
repo := repository.GetRepository()
|
repo := repository.GetRepository()
|
||||||
if repo == nil {
|
|
||||||
c.JSON(http.StatusServiceUnavailable, model.ReviewResponse{
|
|
||||||
Result: false,
|
|
||||||
Message: "数据库未连接",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从MongoDB查询所有需要复核的账单
|
// 从MongoDB查询所有需要复核的账单
|
||||||
bills, err := repo.GetBillsNeedReview()
|
bills, err := repo.GetBillsNeedReview()
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ func Upload(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 14. 核销跨批次退款(支付宝:本次清洗中未在同批次内匹配到对应支出的退款)
|
// 14. 核销跨批次退款(本次清洗中未在同批次内匹配到对应支出的退款)
|
||||||
var reconciledCount int
|
var reconciledCount int
|
||||||
if repo != nil && cleanResult != nil {
|
if repo != nil && cleanResult != nil {
|
||||||
for _, ur := range cleanResult.UnresolvedRefunds {
|
for _, ur := range cleanResult.UnresolvedRefunds {
|
||||||
@@ -285,26 +285,7 @@ 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)
|
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
|
||||||
if dedupResult.DuplicateCount > 0 {
|
if dedupResult.DuplicateCount > 0 {
|
||||||
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
||||||
|
|||||||
@@ -38,13 +38,7 @@ func AuthRequired() gin.HandlerFunc {
|
|||||||
|
|
||||||
secret := config.Global.JWTSecret
|
secret := config.Global.JWTSecret
|
||||||
if secret == "" {
|
if secret == "" {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
secret = "billai-default-secret"
|
||||||
"success": false,
|
|
||||||
"error": "服务器 JWT 配置缺失",
|
|
||||||
"code": "TOKEN_INVALID",
|
|
||||||
})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ type CleanedBill struct {
|
|||||||
Description string `bson:"description" json:"description"` // 商品说明
|
Description string `bson:"description" json:"description"` // 商品说明
|
||||||
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
|
IncomeExpense string `bson:"income_expense" json:"income_expense"` // 收/支
|
||||||
Amount float64 `bson:"amount" json:"amount"` // 金额
|
Amount float64 `bson:"amount" json:"amount"` // 金额
|
||||||
OriginalAmount float64 `bson:"original_amount,omitempty" json:"original_amount,omitempty"` // 原始金额(入库时),用于微信跨批次退款核销
|
|
||||||
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
|
PayMethod string `bson:"pay_method" json:"pay_method"` // 支付方式
|
||||||
Status string `bson:"status" json:"status"` // 交易状态
|
Status string `bson:"status" json:"status"` // 交易状态
|
||||||
Remark string `bson:"remark" json:"remark"` // 备注
|
Remark string `bson:"remark" json:"remark"` // 备注
|
||||||
|
|||||||
@@ -567,83 +567,8 @@ func (r *Repository) ReconcileRefund(billType, orderNo, merchantOrderNo string,
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReconcileWechatRefund 将跨批次微信退款核销到已存储的清洗后账单
|
// 建议: 为提升 ReconcileRefund 查询性能,可为 bills_cleaned 添加索引
|
||||||
// 微信"当前状态"字段为累计退款金额,使用 original_amount(入库时原始金额)计算剩余
|
// {transaction_id:1, bill_type:1} 和 {merchant_order_no:1, bill_type:1}(与现有"无索引"问题一并处理)
|
||||||
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 客户端(用于兼容旧代码)
|
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
|
||||||
func (r *Repository) GetClient() *mongo.Client {
|
func (r *Repository) GetClient() *mongo.Client {
|
||||||
|
|||||||
@@ -62,10 +62,4 @@ type BillRepository interface {
|
|||||||
// 全额退款(剩余金额 <= 0.005)则软删除;部分退款则扣减 amount 并追加备注
|
// 全额退款(剩余金额 <= 0.005)则软删除;部分退款则扣减 amount 并追加备注
|
||||||
// 返回: 是否找到并核销了匹配记录、错误(未找到匹配记录不算错误,返回 matched=false)
|
// 返回: 是否找到并核销了匹配记录、错误(未找到匹配记录不算错误,返回 matched=false)
|
||||||
ReconcileRefund(billType, orderNo, merchantOrderNo string, refundAmount float64, refundTime, merchant, description, refundOrderNo string) (matched bool, err error)
|
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,6 @@ func getRepo() repository.BillRepository {
|
|||||||
return repository.GetRepository()
|
return repository.GetRepository()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WechatRefundUpdate 微信重复行中携带的退款信息(用于跨批次退款核销)
|
|
||||||
type WechatRefundUpdate struct {
|
|
||||||
TransactionID string // 原消费行的交易单号
|
|
||||||
FullRefund bool // 是否全额退款(已全额退款)
|
|
||||||
CumulativeRefundAmount float64 // 累计退款金额(已退款(¥X)中的 X,与原始金额相减得剩余)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeduplicateResult 去重结果
|
// DeduplicateResult 去重结果
|
||||||
type DeduplicateResult struct {
|
type DeduplicateResult struct {
|
||||||
OriginalCount int // 原始记录数
|
OriginalCount int // 原始记录数
|
||||||
@@ -37,7 +30,6 @@ type DeduplicateResult struct {
|
|||||||
NewCount int // 新记录数
|
NewCount int // 新记录数
|
||||||
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
|
DedupFilePath string // 去重后的文件路径(如果有去重则生成新文件)
|
||||||
BillType string // 检测到的账单类型
|
BillType string // 检测到的账单类型
|
||||||
WechatRefundUpdates []WechatRefundUpdate // 微信重复行中检测到的退款状态(用于跨批次核销)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
|
// DeduplicateRawFile 对原始文件进行去重检查,返回去重后的文件路径
|
||||||
@@ -83,17 +75,6 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对于微信账单,找到"当前状态"列的索引,用于检测退款状态
|
|
||||||
wechatStatusIdx := -1
|
|
||||||
if billType == "wechat" {
|
|
||||||
for i, col := range header {
|
|
||||||
if col == "当前状态" {
|
|
||||||
wechatStatusIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查每行是否重复
|
// 检查每行是否重复
|
||||||
var newRows [][]string
|
var newRows [][]string
|
||||||
for _, row := range dataRows {
|
for _, row := range dataRows {
|
||||||
@@ -120,24 +101,6 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
|||||||
newRows = append(newRows, row)
|
newRows = append(newRows, row)
|
||||||
} else {
|
} else {
|
||||||
result.DuplicateCount++
|
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +337,6 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
|
|||||||
bill.ReviewLevel = row[idx]
|
bill.ReviewLevel = row[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
bill.OriginalAmount = bill.Amount
|
|
||||||
bills = append(bills, bill)
|
bills = append(bills, bill)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +431,6 @@ func saveCleanedBillsFromJSON(filePath, billType, sourceFile, uploadBatch string
|
|||||||
bill.ReviewLevel = v
|
bill.ReviewLevel = v
|
||||||
}
|
}
|
||||||
|
|
||||||
bill.OriginalAmount = bill.Amount
|
|
||||||
bills = append(bills, bill)
|
bills = append(bills, bill)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,28 +512,3 @@ func parseAmount(s string) float64 {
|
|||||||
}
|
}
|
||||||
return 0
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-6">
|
||||||
<div class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : record.incomeExpense === '不计收支' ? 'text-muted-foreground' : 'text-red-600 dark:text-red-400'}">
|
<div class="text-3xl font-bold font-mono {record.incomeExpense === '收入' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
|
||||||
¥{record.amount.toFixed(2)}
|
¥{record.amount.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div>
|
<div class="text-sm text-muted-foreground mt-1">{record.incomeExpense || '支出'}金额</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user