feat(pickup): 支持线下提号 CSV 导出
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
package pickup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/auditlog"
|
||||
"hfb_sys/backend/internal/middleware"
|
||||
"hfb_sys/backend/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestBuildAdminPickupCSVUsesExcelFriendlyEncodingAndEscaping(t *testing.T) {
|
||||
createdAt := time.Date(2026, 11, 12, 10, 20, 30, 0, time.FixedZone("CST", 8*60*60))
|
||||
completedAt := createdAt.Add(2 * time.Hour)
|
||||
items := []PickupDTO{
|
||||
{
|
||||
PickupNo: "PK-001",
|
||||
AccountTitle: "账号,带逗号",
|
||||
ServerRegion: "国服",
|
||||
LoginPlatform: "QQ",
|
||||
ListingNo: "202611120001",
|
||||
OwnerPhone: "18800000000",
|
||||
Operator: "运营小张",
|
||||
AccountSource: AccountSourceExternalPlatformManaged,
|
||||
SourceChannel: "闲鱼",
|
||||
Platform: "拼多多",
|
||||
ThirdPartyOrderNo: "=1+1",
|
||||
ShopName: `大锤"专营店"`,
|
||||
Remark: "第一行\n第二行",
|
||||
OrderAmountCent: 29500,
|
||||
DepositAmountCent: 35000,
|
||||
EffectiveProfitAmountCent: 1234,
|
||||
EffectiveSettleAmountCent: 20000,
|
||||
SettlementMode: SettlementModePlatformManaged,
|
||||
OfflineSettlementStatus: OfflineSettlementStatusPending,
|
||||
Status: StatusCompleted,
|
||||
CreatedAt: createdAt,
|
||||
CompletedAt: &completedAt,
|
||||
CompleteRemark: "已完成",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := buildAdminPickupCSV(items)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAdminPickupCSV() error = %v", err)
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte{0xEF, 0xBB, 0xBF}) {
|
||||
t.Fatal("CSV 缺少 UTF-8 BOM")
|
||||
}
|
||||
|
||||
records, err := csv.NewReader(bytes.NewReader(data[3:])).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("解析 CSV 失败: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("CSV 行数 = %d, want 2", len(records))
|
||||
}
|
||||
if len(records[0]) != len(adminPickupExportHeader) {
|
||||
t.Fatalf("表头列数 = %d, want %d", len(records[0]), len(adminPickupExportHeader))
|
||||
}
|
||||
|
||||
row := records[1]
|
||||
if row[1] != "账号,带逗号" {
|
||||
t.Fatalf("账号标题 = %q", row[1])
|
||||
}
|
||||
if row[7] != "外部上传 · 平台代管" {
|
||||
t.Fatalf("账号来源 = %q", row[7])
|
||||
}
|
||||
if row[10] != "'=1+1" {
|
||||
t.Fatalf("第三方单号未转义公式注入: %q", row[10])
|
||||
}
|
||||
if row[11] != `大锤"专营店"` {
|
||||
t.Fatalf("店铺名称 = %q", row[11])
|
||||
}
|
||||
if row[12] != "第一行\n第二行" {
|
||||
t.Fatalf("提号备注 = %q", row[12])
|
||||
}
|
||||
if row[13] != "295.00" || row[14] != "350.00" || row[15] != "12.34" {
|
||||
t.Fatalf("金额列 = %q/%q/%q", row[13], row[14], row[15])
|
||||
}
|
||||
if row[16] != "200.00" {
|
||||
t.Fatalf("结算金额 = %q", row[16])
|
||||
}
|
||||
if row[17] != "已完成" || row[18] != "待线下结算" {
|
||||
t.Fatalf("状态列 = %q/%q", row[17], row[18])
|
||||
}
|
||||
if row[19] != "2026-11-12 10:20:30" || row[20] != "2026-11-12 12:20:30" {
|
||||
t.Fatalf("时间列 = %q/%q", row[19], row[20])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceExportAdminReusesFiltersAndRecordsAudit(t *testing.T) {
|
||||
repo, db := newPickupTestRepo(t)
|
||||
first := seedPickup(t, db, StatusPickingUp)
|
||||
if err := db.Model(&first).Updates(map[string]any{
|
||||
"operator": "运营,小张",
|
||||
"platform": "拼多多",
|
||||
"third_party_order_no": "=1+1",
|
||||
"order_amount_cent": 29500,
|
||||
"deposit_amount_cent": 35000,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("更新第一条提号失败: %v", err)
|
||||
}
|
||||
|
||||
second := first
|
||||
second.ID = 0
|
||||
second.PickupNo = "PK-EXPORT-002"
|
||||
second.Status = StatusCompleted
|
||||
second.Operator = "运营小李"
|
||||
second.ThirdPartyOrderNo = "TB-002"
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("创建第二条提号失败: %v", err)
|
||||
}
|
||||
|
||||
service := NewService(repo)
|
||||
data, rowCount, err := service.ExportAdmin(
|
||||
t.Context(),
|
||||
AdminPickupQuery{Status: StatusPickingUp},
|
||||
99,
|
||||
auditlog.Meta{RequestID: "req-pickup-export"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportAdmin() error = %v", err)
|
||||
}
|
||||
if rowCount != 1 {
|
||||
t.Fatalf("导出行数 = %d, want 1", rowCount)
|
||||
}
|
||||
|
||||
records, err := csv.NewReader(bytes.NewReader(data[3:])).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("解析导出 CSV 失败: %v", err)
|
||||
}
|
||||
if len(records) != 2 {
|
||||
t.Fatalf("筛选导出 CSV 行数 = %d, want 2", len(records))
|
||||
}
|
||||
if records[1][0] != first.PickupNo || records[1][10] != "'=1+1" {
|
||||
t.Fatalf("筛选导出行 = %#v", records[1])
|
||||
}
|
||||
|
||||
var audit model.AuditLog
|
||||
if err := db.Where("action = ?", "pickup_export").First(&audit).Error; err != nil {
|
||||
t.Fatalf("查询导出审计失败: %v", err)
|
||||
}
|
||||
var detail map[string]any
|
||||
if err := json.Unmarshal(audit.Detail, &detail); err != nil {
|
||||
t.Fatalf("解析导出审计失败: %v", err)
|
||||
}
|
||||
if detail["status"] != StatusPickingUp {
|
||||
t.Fatalf("审计 status = %v, want %s", detail["status"], StatusPickingUp)
|
||||
}
|
||||
assertJSONNumber(t, "导出行数", detail["row_count"], 1)
|
||||
if !strings.Contains(string(audit.Detail), "req-pickup-export") {
|
||||
t.Fatalf("审计日志缺少 request_id: %s", audit.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerExportWritesCSVDownloadResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo, db := newPickupTestRepo(t)
|
||||
seedPickup(t, db, StatusPickingUp)
|
||||
|
||||
handler := NewHandler(NewService(repo))
|
||||
router := gin.New()
|
||||
router.GET("/export", func(c *gin.Context) {
|
||||
c.Set(middleware.ContextAdminID, uint64(99))
|
||||
handler.Export(c)
|
||||
})
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/export", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("状态码 = %d, want %d, body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if contentType := recorder.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/csv") {
|
||||
t.Fatalf("Content-Type = %q", contentType)
|
||||
}
|
||||
if disposition := recorder.Header().Get("Content-Disposition"); !strings.Contains(disposition, ".csv") {
|
||||
t.Fatalf("Content-Disposition = %q", disposition)
|
||||
}
|
||||
if rowCount := recorder.Header().Get("X-Export-Row-Count"); rowCount != "1" {
|
||||
t.Fatalf("X-Export-Row-Count = %q, want 1", rowCount)
|
||||
}
|
||||
if !bytes.HasPrefix(recorder.Body.Bytes(), []byte{0xEF, 0xBB, 0xBF}) {
|
||||
t.Fatal("响应体缺少 UTF-8 BOM")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user