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:
@@ -223,7 +223,7 @@ func Upload(c *gin.Context) {
|
||||
End: req.End,
|
||||
Format: req.Format,
|
||||
}
|
||||
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
||||
cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
||||
if cleanErr != nil {
|
||||
service.CleanupExtractedFiles(extractedFiles)
|
||||
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
||||
@@ -255,22 +255,37 @@ func Upload(c *gin.Context) {
|
||||
}
|
||||
service.CleanupExtractedFiles(extractedFiles)
|
||||
|
||||
repo := repository.GetRepository()
|
||||
|
||||
// 13. 如果是京东账单,软删除其他来源中包含"京东-订单编号"的记录
|
||||
var jdRelatedDeleted int64
|
||||
if billType == "jd" {
|
||||
repo := repository.GetRepository()
|
||||
if repo != nil {
|
||||
deleted, err := repo.SoftDeleteJDRelatedBills()
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err)
|
||||
} else if deleted > 0 {
|
||||
jdRelatedDeleted = deleted
|
||||
fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted)
|
||||
if billType == "jd" && repo != nil {
|
||||
deleted, err := repo.SoftDeleteJDRelatedBills()
|
||||
if err != nil {
|
||||
fmt.Printf("⚠️ 软删除京东关联记录失败: %v\n", err)
|
||||
} else if deleted > 0 {
|
||||
jdRelatedDeleted = deleted
|
||||
fmt.Printf("🗑️ 已软删除 %d 条其他来源中的京东关联记录\n", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// 14. 核销跨批次退款(本次清洗中未在同批次内匹配到对应支出的退款)
|
||||
var reconciledCount int
|
||||
if repo != nil && cleanResult != nil {
|
||||
for _, ur := range cleanResult.UnresolvedRefunds {
|
||||
matched, rErr := repo.ReconcileRefund(billType, ur.OrderNo, ur.MerchantOrderNo, ur.Amount, ur.Time, ur.Merchant, ur.Description, ur.RefundOrderNo)
|
||||
if rErr != nil {
|
||||
fmt.Printf("⚠️ 退款核销失败: %v\n", rErr)
|
||||
continue
|
||||
}
|
||||
if matched {
|
||||
reconciledCount++
|
||||
fmt.Printf("💰 已核销退款: 订单%s, 金额%.2f元\n", ur.OrderNo, ur.Amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 14. 返回成功响应
|
||||
// 15. 返回成功响应
|
||||
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
|
||||
if dedupResult.DuplicateCount > 0 {
|
||||
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
|
||||
@@ -278,18 +293,22 @@ func Upload(c *gin.Context) {
|
||||
if jdRelatedDeleted > 0 {
|
||||
message = fmt.Sprintf("%s,标记删除 %d 条重复的京东订单", message, jdRelatedDeleted)
|
||||
}
|
||||
if reconciledCount > 0 {
|
||||
message = fmt.Sprintf("%s,核销退款 %d 条", message, reconciledCount)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.UploadResponse{
|
||||
Result: true,
|
||||
Message: message,
|
||||
Data: &model.UploadData{
|
||||
BillType: billType,
|
||||
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
||||
FileName: outputFileName,
|
||||
RawCount: rawCount,
|
||||
CleanedCount: cleanedCount,
|
||||
DuplicateCount: dedupResult.DuplicateCount,
|
||||
JDRelatedDeleted: jdRelatedDeleted,
|
||||
BillType: billType,
|
||||
FileURL: fmt.Sprintf("/download/%s", outputFileName),
|
||||
FileName: outputFileName,
|
||||
RawCount: rawCount,
|
||||
CleanedCount: cleanedCount,
|
||||
DuplicateCount: dedupResult.DuplicateCount,
|
||||
JDRelatedDeleted: jdRelatedDeleted,
|
||||
ReconciledRefundCount: reconciledCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user