Files
billai/server/adapter/http/cleaner.go
cheliangzhao e2e1beb6f7 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>
2026-06-16 19:29:47 +08:00

313 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package http 实现通过 HTTP API 调用 Python 服务的清洗器
package http
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"billai-server/adapter"
)
// CleanRequest HTTP 清洗请求
type CleanRequest struct {
InputPath string `json:"input_path"`
OutputPath string `json:"output_path"`
Year string `json:"year,omitempty"`
Month string `json:"month,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Format string `json:"format,omitempty"`
BillType string `json:"bill_type,omitempty"`
}
// CleanResponse HTTP 清洗响应
type CleanResponse struct {
Success bool `json:"success"`
BillType string `json:"bill_type"`
Message string `json:"message"`
OutputPath string `json:"output_path,omitempty"`
UnresolvedRefunds []UnresolvedRefund `json:"unresolved_refunds,omitempty"`
}
// UnresolvedRefund 本次清洗未在同批次内匹配到对应支出的退款(与 Python 端 dict 字段对应)
type UnresolvedRefund struct {
OrderNo string `json:"order_no"`
MerchantOrderNo string `json:"merchant_order_no"`
RefundOrderNo string `json:"refund_order_no"`
Amount float64 `json:"amount"`
Time string `json:"time"`
Merchant string `json:"merchant"`
Description string `json:"description"`
}
// ErrorResponse 错误响应
type ErrorResponse struct {
Detail string `json:"detail"`
}
// Cleaner 通过 HTTP API 调用 Python 服务的清洗器实现
type Cleaner struct {
baseURL string // Python 服务基础 URL
httpClient *http.Client // HTTP 客户端
}
// NewCleaner 创建 HTTP 清洗器
func NewCleaner(baseURL string) *Cleaner {
return &Cleaner{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 60 * time.Second, // 清洗可能需要较长时间
},
}
}
// NewCleanerWithClient 使用自定义 HTTP 客户端创建清洗器
func NewCleanerWithClient(baseURL string, client *http.Client) *Cleaner {
return &Cleaner{
baseURL: baseURL,
httpClient: client,
}
}
// Clean 执行账单清洗(使用文件上传方式)
func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions) (*adapter.CleanResult, error) {
// 打开输入文件
file, err := os.Open(inputPath)
if err != nil {
return nil, fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
// 创建 multipart form
var body bytes.Buffer
writer := multipart.NewWriter(&body)
// 添加文件
part, err := writer.CreateFormFile("file", filepath.Base(inputPath))
if err != nil {
return nil, fmt.Errorf("创建表单文件失败: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return nil, fmt.Errorf("复制文件内容失败: %w", err)
}
// 添加其他参数
if opts != nil {
if opts.Year != "" {
writer.WriteField("year", opts.Year)
}
if opts.Month != "" {
writer.WriteField("month", opts.Month)
}
if opts.Start != "" {
writer.WriteField("start", opts.Start)
}
if opts.End != "" {
writer.WriteField("end", opts.End)
}
if opts.Format != "" {
writer.WriteField("format", opts.Format)
}
}
writer.WriteField("bill_type", "auto")
writer.Close()
// 发送上传请求
fmt.Printf("🌐 调用清洗服务: %s/clean/upload\n", c.baseURL)
req, err := http.NewRequest("POST", c.baseURL+"/clean/upload", &body)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 处理错误响应
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil {
return nil, fmt.Errorf("清洗失败: %s", errResp.Detail)
}
return nil, fmt.Errorf("清洗失败: HTTP %d - %s", resp.StatusCode, string(respBody))
}
// 解析成功响应
var cleanResp CleanResponse
if err := json.Unmarshal(respBody, &cleanResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 下载清洗后的文件
if cleanResp.OutputPath != "" {
if err := c.downloadFile(cleanResp.OutputPath, outputPath); err != nil {
return nil, fmt.Errorf("下载清洗结果失败: %w", err)
}
}
unresolvedRefunds := make([]adapter.UnresolvedRefund, 0, len(cleanResp.UnresolvedRefunds))
for _, ur := range cleanResp.UnresolvedRefunds {
unresolvedRefunds = append(unresolvedRefunds, adapter.UnresolvedRefund{
OrderNo: ur.OrderNo,
MerchantOrderNo: ur.MerchantOrderNo,
RefundOrderNo: ur.RefundOrderNo,
Amount: ur.Amount,
Time: ur.Time,
Merchant: ur.Merchant,
Description: ur.Description,
})
}
return &adapter.CleanResult{
BillType: cleanResp.BillType,
Output: cleanResp.Message,
UnresolvedRefunds: unresolvedRefunds,
}, nil
}
// downloadFile 下载清洗后的文件
func (c *Cleaner) downloadFile(remotePath, localPath string) error {
// 构建下载 URL
downloadURL := fmt.Sprintf("%s/clean/download/%s", c.baseURL, remotePath)
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("下载请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
}
// 创建本地文件
out, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer out.Close()
// 写入文件内容
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
}
// ConvertResponse 转换响应
type ConvertResponse struct {
Success bool `json:"success"`
BillType string `json:"bill_type"`
Message string `json:"message"`
OutputPath string `json:"output_path,omitempty"`
}
// Convert 转换账单文件格式xlsx -> csv处理 GBK 编码等)
func (c *Cleaner) Convert(inputPath string) (outputPath string, billType string, err error) {
// 打开输入文件
file, err := os.Open(inputPath)
if err != nil {
return "", "", fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
// 创建 multipart form
var body bytes.Buffer
writer := multipart.NewWriter(&body)
// 添加文件
part, err := writer.CreateFormFile("file", filepath.Base(inputPath))
if err != nil {
return "", "", fmt.Errorf("创建表单文件失败: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return "", "", fmt.Errorf("复制文件内容失败: %w", err)
}
writer.Close()
// 发送转换请求
fmt.Printf("🌐 调用转换服务: %s/convert\n", c.baseURL)
req, err := http.NewRequest("POST", c.baseURL+"/convert", &body)
if err != nil {
return "", "", fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("读取响应失败: %w", err)
}
// 处理错误响应
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil {
return "", "", fmt.Errorf("转换失败: %s", errResp.Detail)
}
return "", "", fmt.Errorf("转换失败: HTTP %d - %s", resp.StatusCode, string(respBody))
}
// 解析成功响应
var convertResp ConvertResponse
if err := json.Unmarshal(respBody, &convertResp); err != nil {
return "", "", fmt.Errorf("解析响应失败: %w", err)
}
// 下载转换后的文件到本地(与输入文件同目录,但扩展名改为 .csv
localOutputPath := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))] + ".csv"
fmt.Printf(" 下载转换后文件: %s -> %s\n", convertResp.OutputPath, localOutputPath)
if err := c.downloadFile(convertResp.OutputPath, localOutputPath); err != nil {
return "", "", fmt.Errorf("下载转换结果失败: %w", err)
}
// 验证文件是否存在
if _, err := os.Stat(localOutputPath); err != nil {
return "", "", fmt.Errorf("下载后文件不存在: %s", localOutputPath)
}
fmt.Printf(" 文件下载成功,已保存到: %s\n", localOutputPath)
return localOutputPath, convertResp.BillType, nil
}
// HealthCheck 检查 Python 服务健康状态
func (c *Cleaner) HealthCheck() error {
resp, err := c.httpClient.Get(c.baseURL + "/health")
if err != nil {
return fmt.Errorf("健康检查失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("服务不健康: HTTP %d", resp.StatusCode)
}
return nil
}
// 确保 Cleaner 实现了 adapter.Cleaner 接口
var _ adapter.Cleaner = (*Cleaner)(nil)