feat(import): 用 Excel 格式替换 CSV 导入
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
- 删除 CSV 解析代码,新增 Excel 解析器 (excelize) - 更新 IoT 卡和设备导入任务处理器 - 更新 API 路由文档和前端接入指南 - 归档变更到 openspec/changes/archive/ - 同步 delta specs 到 main specs
This commit is contained in:
132
pkg/utils/csv.go
132
pkg/utils/csv.go
@@ -1,132 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CardInfo 卡信息(ICCID + MSISDN)
|
||||
type CardInfo struct {
|
||||
ICCID string
|
||||
MSISDN string
|
||||
}
|
||||
|
||||
// CSVParseResult CSV 解析结果
|
||||
type CSVParseResult struct {
|
||||
Cards []CardInfo
|
||||
TotalCount int
|
||||
ParseErrors []CSVParseError
|
||||
}
|
||||
|
||||
// CSVParseError CSV 解析错误
|
||||
type CSVParseError struct {
|
||||
Line int
|
||||
ICCID string
|
||||
MSISDN string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ErrInvalidCSVFormat CSV 格式错误
|
||||
var ErrInvalidCSVFormat = errors.New("CSV 文件格式错误:缺少 MSISDN 列,文件必须包含 ICCID 和 MSISDN 两列")
|
||||
|
||||
// ParseCardCSV 解析包含 ICCID 和 MSISDN 两列的 CSV 文件
|
||||
func ParseCardCSV(reader io.Reader) (*CSVParseResult, error) {
|
||||
csvReader := csv.NewReader(reader)
|
||||
csvReader.FieldsPerRecord = -1
|
||||
csvReader.TrimLeadingSpace = true
|
||||
|
||||
result := &CSVParseResult{
|
||||
Cards: make([]CardInfo, 0),
|
||||
ParseErrors: make([]CSVParseError, 0),
|
||||
}
|
||||
|
||||
lineNum := 0
|
||||
headerSkipped := false
|
||||
|
||||
for {
|
||||
record, err := csvReader.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
lineNum++
|
||||
|
||||
if err != nil {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
Reason: "CSV 解析错误: " + err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if len(record) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(record) < 2 {
|
||||
if lineNum == 1 && !headerSkipped {
|
||||
firstCol := strings.TrimSpace(record[0])
|
||||
if isICCIDHeader(firstCol) {
|
||||
return nil, ErrInvalidCSVFormat
|
||||
}
|
||||
}
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
ICCID: strings.TrimSpace(record[0]),
|
||||
Reason: "列数不足:缺少 MSISDN 列",
|
||||
})
|
||||
result.TotalCount++
|
||||
continue
|
||||
}
|
||||
|
||||
iccid := strings.TrimSpace(record[0])
|
||||
msisdn := strings.TrimSpace(record[1])
|
||||
|
||||
if lineNum == 1 && !headerSkipped && isHeader(iccid, msisdn) {
|
||||
headerSkipped = true
|
||||
continue
|
||||
}
|
||||
|
||||
result.TotalCount++
|
||||
|
||||
if iccid == "" {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
MSISDN: msisdn,
|
||||
Reason: "ICCID 不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if msisdn == "" {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
ICCID: iccid,
|
||||
Reason: "MSISDN 不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
result.Cards = append(result.Cards, CardInfo{
|
||||
ICCID: iccid,
|
||||
MSISDN: msisdn,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isICCIDHeader(value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
return lower == "iccid" || lower == "卡号" || lower == "号码"
|
||||
}
|
||||
|
||||
func isMSISDNHeader(value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
return lower == "msisdn" || lower == "接入号" || lower == "手机号" || lower == "电话" || lower == "号码"
|
||||
}
|
||||
|
||||
func isHeader(col1, col2 string) bool {
|
||||
return isICCIDHeader(col1) && isMSISDNHeader(col2)
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseCardCSV(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
csvContent string
|
||||
wantCards []CardInfo
|
||||
wantTotalCount int
|
||||
wantErrorCount int
|
||||
wantError error
|
||||
}{
|
||||
{
|
||||
name: "标准双列无表头",
|
||||
csvContent: "89860012345678901234,13800000001\n89860012345678901235,13800000002",
|
||||
wantCards: []CardInfo{
|
||||
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
|
||||
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
|
||||
},
|
||||
wantTotalCount: 2,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "标准双列有表头-英文",
|
||||
csvContent: "iccid,msisdn\n89860012345678901234,13800000001\n89860012345678901235,13800000002",
|
||||
wantCards: []CardInfo{
|
||||
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
|
||||
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
|
||||
},
|
||||
wantTotalCount: 2,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "标准双列有表头-中文",
|
||||
csvContent: "卡号,接入号\n89860012345678901234,13800000001",
|
||||
wantCards: []CardInfo{
|
||||
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
|
||||
},
|
||||
wantTotalCount: 1,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "标准双列有表头-手机号",
|
||||
csvContent: "ICCID,手机号\n89860012345678901234,13800000001",
|
||||
wantCards: []CardInfo{
|
||||
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
|
||||
},
|
||||
wantTotalCount: 1,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "单列CSV格式拒绝-有表头",
|
||||
csvContent: "iccid\n89860012345678901234",
|
||||
wantCards: nil,
|
||||
wantTotalCount: 0,
|
||||
wantErrorCount: 0,
|
||||
wantError: ErrInvalidCSVFormat,
|
||||
},
|
||||
{
|
||||
name: "单列CSV格式-无表头记录错误",
|
||||
csvContent: "89860012345678901234\n89860012345678901235",
|
||||
wantCards: []CardInfo{},
|
||||
wantTotalCount: 2,
|
||||
wantErrorCount: 2,
|
||||
},
|
||||
{
|
||||
name: "MSISDN为空记录失败",
|
||||
csvContent: "iccid,msisdn\n89860012345678901234,13800000001\n89860012345678901235,",
|
||||
wantCards: []CardInfo{{ICCID: "89860012345678901234", MSISDN: "13800000001"}},
|
||||
wantTotalCount: 2,
|
||||
wantErrorCount: 1,
|
||||
},
|
||||
{
|
||||
name: "ICCID为空记录失败",
|
||||
csvContent: "iccid,msisdn\n89860012345678901234,13800000001\n,13800000002",
|
||||
wantCards: []CardInfo{{ICCID: "89860012345678901234", MSISDN: "13800000001"}},
|
||||
wantTotalCount: 2,
|
||||
wantErrorCount: 1,
|
||||
},
|
||||
{
|
||||
name: "空文件",
|
||||
csvContent: "",
|
||||
wantCards: []CardInfo{},
|
||||
wantTotalCount: 0,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "只有表头",
|
||||
csvContent: "iccid,msisdn",
|
||||
wantCards: []CardInfo{},
|
||||
wantTotalCount: 0,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "包含空行",
|
||||
csvContent: "89860012345678901234,13800000001\n\n89860012345678901235,13800000002",
|
||||
wantCards: []CardInfo{
|
||||
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
|
||||
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
|
||||
},
|
||||
wantTotalCount: 2,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "ICCID和MSISDN前后有空格",
|
||||
csvContent: " 89860012345678901234 , 13800000001 ",
|
||||
wantCards: []CardInfo{
|
||||
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
|
||||
},
|
||||
wantTotalCount: 1,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "多于两列只取前两列",
|
||||
csvContent: "89860012345678901234,13800000001,额外数据\n89860012345678901235,13800000002,忽略",
|
||||
wantCards: []CardInfo{
|
||||
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
|
||||
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
|
||||
},
|
||||
wantTotalCount: 2,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Windows换行符CRLF",
|
||||
csvContent: "89860012345678901234,13800000001\r\n89860012345678901235,13800000002",
|
||||
wantCards: []CardInfo{
|
||||
{ICCID: "89860012345678901234", MSISDN: "13800000001"},
|
||||
{ICCID: "89860012345678901235", MSISDN: "13800000002"},
|
||||
},
|
||||
wantTotalCount: 2,
|
||||
wantErrorCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reader := strings.NewReader(tt.csvContent)
|
||||
result, err := ParseCardCSV(reader)
|
||||
|
||||
if tt.wantError != nil {
|
||||
require.ErrorIs(t, err, tt.wantError)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantCards, result.Cards, "Cards 不匹配")
|
||||
assert.Equal(t, tt.wantTotalCount, result.TotalCount, "TotalCount 不匹配")
|
||||
assert.Equal(t, tt.wantErrorCount, len(result.ParseErrors), "ParseErrors 数量不匹配")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCardCSV_ErrorDetails(t *testing.T) {
|
||||
t.Run("MSISDN为空时记录详细错误", func(t *testing.T) {
|
||||
csvContent := "iccid,msisdn\n89860012345678901234,"
|
||||
reader := strings.NewReader(csvContent)
|
||||
result, err := ParseCardCSV(reader)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.ParseErrors, 1)
|
||||
assert.Equal(t, 2, result.ParseErrors[0].Line)
|
||||
assert.Equal(t, "89860012345678901234", result.ParseErrors[0].ICCID)
|
||||
assert.Equal(t, "MSISDN 不能为空", result.ParseErrors[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("ICCID为空时记录详细错误", func(t *testing.T) {
|
||||
csvContent := "iccid,msisdn\n,13800000001"
|
||||
reader := strings.NewReader(csvContent)
|
||||
result, err := ParseCardCSV(reader)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.ParseErrors, 1)
|
||||
assert.Equal(t, 2, result.ParseErrors[0].Line)
|
||||
assert.Equal(t, "13800000001", result.ParseErrors[0].MSISDN)
|
||||
assert.Equal(t, "ICCID 不能为空", result.ParseErrors[0].Reason)
|
||||
})
|
||||
|
||||
t.Run("列数不足时记录详细错误", func(t *testing.T) {
|
||||
csvContent := "89860012345678901234"
|
||||
reader := strings.NewReader(csvContent)
|
||||
result, err := ParseCardCSV(reader)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.ParseErrors, 1)
|
||||
assert.Equal(t, 1, result.ParseErrors[0].Line)
|
||||
assert.Equal(t, "89860012345678901234", result.ParseErrors[0].ICCID)
|
||||
assert.Contains(t, result.ParseErrors[0].Reason, "列数不足")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
col1 string
|
||||
col2 string
|
||||
expected bool
|
||||
}{
|
||||
{"iccid", "msisdn", true},
|
||||
{"ICCID", "MSISDN", true},
|
||||
{"卡号", "接入号", true},
|
||||
{"号码", "手机号", true},
|
||||
{"iccid", "电话", true},
|
||||
{"89860012345678901234", "13800000001", false},
|
||||
{"iccid", "", false},
|
||||
{"", "msisdn", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.col1+"_"+tt.col2, func(t *testing.T) {
|
||||
result := isHeader(tt.col1, tt.col2)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
341
pkg/utils/excel.go
Normal file
341
pkg/utils/excel.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// CardInfo 卡信息(ICCID + MSISDN)
|
||||
type CardInfo struct {
|
||||
ICCID string
|
||||
MSISDN string
|
||||
}
|
||||
|
||||
// CSVParseResult Excel/CSV 解析结果
|
||||
type CSVParseResult struct {
|
||||
Cards []CardInfo
|
||||
TotalCount int
|
||||
ParseErrors []CSVParseError
|
||||
}
|
||||
|
||||
// CSVParseError Excel/CSV 解析错误
|
||||
type CSVParseError struct {
|
||||
Line int
|
||||
ICCID string
|
||||
MSISDN string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// DeviceRow 设备导入数据行
|
||||
type DeviceRow struct {
|
||||
Line int
|
||||
DeviceNo string
|
||||
DeviceName string
|
||||
DeviceModel string
|
||||
DeviceType string
|
||||
MaxSimSlots int
|
||||
Manufacturer string
|
||||
ICCIDs []string
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrExcelNoSheets Excel文件无工作表
|
||||
ErrExcelNoSheets = errors.New("Excel文件无工作表")
|
||||
// ErrExcelNoData Excel文件无数据行
|
||||
ErrExcelNoData = errors.New("Excel文件无数据行(至少需要表头+1行数据)")
|
||||
)
|
||||
|
||||
// ParseCardExcel 解析包含 ICCID 和 MSISDN 两列的 Excel 文件
|
||||
// filePath: Excel文件路径(.xlsx格式)
|
||||
// 返回: 解析结果 (与CSV解析器返回相同的数据结构)
|
||||
func ParseCardExcel(filePath string) (*CSVParseResult, error) {
|
||||
// 1. 打开Excel文件
|
||||
f, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开Excel失败: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
// 日志记录关闭错误,但不影响解析结果
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. 选择sheet (优先"导入数据",否则第一个)
|
||||
sheetName := selectSheet(f)
|
||||
if sheetName == "" {
|
||||
return nil, ErrExcelNoSheets
|
||||
}
|
||||
|
||||
// 3. 读取所有行
|
||||
rows, err := f.GetRows(sheetName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取sheet失败: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) < 2 {
|
||||
return nil, ErrExcelNoData
|
||||
}
|
||||
|
||||
// 4. 解析表头 + 数据行
|
||||
return parseCardRows(rows)
|
||||
}
|
||||
|
||||
// ParseDeviceExcel 解析设备导入 Excel 文件
|
||||
// filePath: Excel文件路径(.xlsx格式)
|
||||
// 返回: 设备行数组、总行数、错误
|
||||
func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
|
||||
// 1. 打开Excel文件
|
||||
f, err := excelize.OpenFile(filePath)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("打开Excel失败: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
// 日志记录关闭错误,但不影响解析结果
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. 选择sheet
|
||||
sheetName := selectSheet(f)
|
||||
if sheetName == "" {
|
||||
return nil, 0, ErrExcelNoSheets
|
||||
}
|
||||
|
||||
// 3. 读取所有行
|
||||
rows, err := f.GetRows(sheetName)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("读取sheet失败: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) < 2 {
|
||||
return nil, 0, ErrExcelNoData
|
||||
}
|
||||
|
||||
// 4. 解析表头行,构建列索引
|
||||
header := rows[0]
|
||||
colIndex := buildDeviceColumnIndex(header)
|
||||
|
||||
// 5. 解析数据行
|
||||
var deviceRows []DeviceRow
|
||||
for i := 1; i < len(rows); i++ {
|
||||
record := rows[i]
|
||||
lineNum := i + 1 // Excel行号从1开始,数据从第2行开始
|
||||
|
||||
row := DeviceRow{Line: lineNum}
|
||||
|
||||
// 提取各字段
|
||||
if idx := colIndex["device_no"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceNo = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceName = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_model"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceModel = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_type"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceType = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["max_sim_slots"]; idx >= 0 && idx < len(record) {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(record[idx])); err == nil {
|
||||
row.MaxSimSlots = n
|
||||
}
|
||||
}
|
||||
if idx := colIndex["manufacturer"]; idx >= 0 && idx < len(record) {
|
||||
row.Manufacturer = strings.TrimSpace(record[idx])
|
||||
}
|
||||
|
||||
// 提取ICCID (iccid_1 ~ iccid_4)
|
||||
row.ICCIDs = make([]string, 0, 4)
|
||||
for j := 1; j <= 4; j++ {
|
||||
colName := "iccid_" + strconv.Itoa(j)
|
||||
if idx := colIndex[colName]; idx >= 0 && idx < len(record) {
|
||||
iccid := strings.TrimSpace(record[idx])
|
||||
if iccid != "" {
|
||||
row.ICCIDs = append(row.ICCIDs, iccid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跳过设备号为空的行
|
||||
if row.DeviceNo == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 默认最大插槽数为4
|
||||
if row.MaxSimSlots == 0 {
|
||||
row.MaxSimSlots = 4
|
||||
}
|
||||
|
||||
deviceRows = append(deviceRows, row)
|
||||
}
|
||||
|
||||
return deviceRows, len(deviceRows), nil
|
||||
}
|
||||
|
||||
// selectSheet 选择要读取的sheet
|
||||
// 优先返回名为"导入数据"的sheet,否则返回第一个sheet
|
||||
func selectSheet(f *excelize.File) string {
|
||||
sheets := f.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 优先查找"导入数据"sheet
|
||||
for _, sheet := range sheets {
|
||||
if sheet == "导入数据" {
|
||||
return sheet
|
||||
}
|
||||
}
|
||||
|
||||
// 返回第一个sheet
|
||||
return sheets[0]
|
||||
}
|
||||
|
||||
// parseCardRows 解析卡数据行
|
||||
// 自动检测表头并提取ICCID和MSISDN列
|
||||
func parseCardRows(rows [][]string) (*CSVParseResult, error) {
|
||||
result := &CSVParseResult{
|
||||
Cards: make([]CardInfo, 0),
|
||||
ParseErrors: make([]CSVParseError, 0),
|
||||
}
|
||||
|
||||
// 检测表头 (第1行)
|
||||
headerSkipped := false
|
||||
iccidCol, msisdnCol := -1, -1
|
||||
|
||||
if len(rows) > 0 {
|
||||
iccidCol, msisdnCol = findCardColumns(rows[0])
|
||||
if iccidCol >= 0 && msisdnCol >= 0 {
|
||||
headerSkipped = true
|
||||
}
|
||||
}
|
||||
|
||||
// 确定数据开始行
|
||||
startLine := 0
|
||||
if headerSkipped {
|
||||
startLine = 1
|
||||
}
|
||||
|
||||
// 解析数据行
|
||||
for i := startLine; i < len(rows); i++ {
|
||||
row := rows[i]
|
||||
lineNum := i + 1 // Excel行号从1开始
|
||||
|
||||
// 跳过空行
|
||||
if len(row) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 提取字段
|
||||
iccid := ""
|
||||
msisdn := ""
|
||||
|
||||
if iccidCol >= 0 {
|
||||
// 有表头,使用列索引
|
||||
if iccidCol < len(row) {
|
||||
iccid = strings.TrimSpace(row[iccidCol])
|
||||
}
|
||||
if msisdnCol < len(row) {
|
||||
msisdn = strings.TrimSpace(row[msisdnCol])
|
||||
}
|
||||
} else {
|
||||
// 无表头,假设第一列ICCID,第二列MSISDN
|
||||
if len(row) >= 1 {
|
||||
iccid = strings.TrimSpace(row[0])
|
||||
}
|
||||
if len(row) >= 2 {
|
||||
msisdn = strings.TrimSpace(row[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 验证
|
||||
result.TotalCount++
|
||||
|
||||
if iccid == "" && msisdn == "" {
|
||||
// 空行,跳过
|
||||
continue
|
||||
}
|
||||
|
||||
if iccid == "" {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
MSISDN: msisdn,
|
||||
Reason: "ICCID不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if msisdn == "" {
|
||||
result.ParseErrors = append(result.ParseErrors, CSVParseError{
|
||||
Line: lineNum,
|
||||
ICCID: iccid,
|
||||
Reason: "MSISDN不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
result.Cards = append(result.Cards, CardInfo{
|
||||
ICCID: iccid,
|
||||
MSISDN: msisdn,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// findCardColumns 查找ICCID和MSISDN列索引
|
||||
// 支持中英文列名识别
|
||||
func findCardColumns(header []string) (iccidCol, msisdnCol int) {
|
||||
iccidCol, msisdnCol = -1, -1
|
||||
|
||||
for i, col := range header {
|
||||
colLower := strings.ToLower(strings.TrimSpace(col))
|
||||
|
||||
// 识别ICCID列
|
||||
if colLower == "iccid" || colLower == "卡号" || colLower == "号码" {
|
||||
if iccidCol == -1 { // 只取第一个匹配
|
||||
iccidCol = i
|
||||
}
|
||||
}
|
||||
|
||||
// 识别MSISDN列
|
||||
if colLower == "msisdn" || colLower == "接入号" || colLower == "手机号" || colLower == "电话" {
|
||||
if msisdnCol == -1 { // 只取第一个匹配
|
||||
msisdnCol = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return iccidCol, msisdnCol
|
||||
}
|
||||
|
||||
// buildDeviceColumnIndex 构建设备导入列索引
|
||||
// 识别表头中的列名,返回列名到列索引的映射
|
||||
func buildDeviceColumnIndex(header []string) map[string]int {
|
||||
index := map[string]int{
|
||||
"device_no": -1,
|
||||
"device_name": -1,
|
||||
"device_model": -1,
|
||||
"device_type": -1,
|
||||
"max_sim_slots": -1,
|
||||
"manufacturer": -1,
|
||||
"iccid_1": -1,
|
||||
"iccid_2": -1,
|
||||
"iccid_3": -1,
|
||||
"iccid_4": -1,
|
||||
}
|
||||
|
||||
for i, col := range header {
|
||||
col = strings.ToLower(strings.TrimSpace(col))
|
||||
if _, exists := index[col]; exists {
|
||||
index[col] = i
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
680
pkg/utils/excel_test.go
Normal file
680
pkg/utils/excel_test.go
Normal file
@@ -0,0 +1,680 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// createTestCardExcel 创建测试用的 ICCID+MSISDN Excel 文件
|
||||
func createTestCardExcel(t *testing.T, filename string, headers []string, rows [][]string) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, filename)
|
||||
|
||||
f := excelize.NewFile()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
t.Logf("关闭Excel文件失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sheetName := "Sheet1"
|
||||
|
||||
// 写入表头
|
||||
if len(headers) > 0 {
|
||||
for i, header := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
f.SetCellValue(sheetName, cell, header)
|
||||
}
|
||||
}
|
||||
|
||||
// 写入数据行
|
||||
for rowIdx, row := range rows {
|
||||
for colIdx, value := range row {
|
||||
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||
f.SetCellValue(sheetName, cell, value)
|
||||
}
|
||||
}
|
||||
|
||||
err := f.SaveAs(filePath)
|
||||
require.NoError(t, err, "保存Excel文件失败")
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
// createTestDeviceExcel 创建测试用的设备导入 Excel 文件
|
||||
func createTestDeviceExcel(t *testing.T, filename string, headers []string, rows [][]string) string {
|
||||
t.Helper()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, filename)
|
||||
|
||||
f := excelize.NewFile()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
t.Logf("关闭Excel文件失败: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sheetName := "Sheet1"
|
||||
|
||||
// 写入表头
|
||||
for i, header := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
f.SetCellValue(sheetName, cell, header)
|
||||
}
|
||||
|
||||
// 写入数据行
|
||||
for rowIdx, row := range rows {
|
||||
for colIdx, value := range row {
|
||||
cell, _ := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
||||
f.SetCellValue(sheetName, cell, value)
|
||||
}
|
||||
}
|
||||
|
||||
err := f.SaveAs(filePath)
|
||||
require.NoError(t, err, "保存Excel文件失败")
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
func TestParseCardExcel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers []string
|
||||
rows [][]string
|
||||
wantCardCount int
|
||||
wantErrorCount int
|
||||
wantError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "标准双列格式-英文表头",
|
||||
headers: []string{"ICCID", "MSISDN"},
|
||||
rows: [][]string{
|
||||
{"89860012345678901234", "13800000001"},
|
||||
{"89860012345678901235", "13800000002"},
|
||||
},
|
||||
wantCardCount: 2,
|
||||
wantErrorCount: 0,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "中文表头",
|
||||
headers: []string{"卡号", "接入号"},
|
||||
rows: [][]string{
|
||||
{"89860012345678901234", "13800000001"},
|
||||
{"89860012345678901235", "13800000002"},
|
||||
},
|
||||
wantCardCount: 2,
|
||||
wantErrorCount: 0,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "混合中英文表头",
|
||||
headers: []string{"ICCID", "手机号"},
|
||||
rows: [][]string{
|
||||
{"89860012345678901234", "13800000001"},
|
||||
},
|
||||
wantCardCount: 1,
|
||||
wantErrorCount: 0,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "ICCID为空-应记录错误",
|
||||
headers: []string{"ICCID", "MSISDN"},
|
||||
rows: [][]string{
|
||||
{"89860012345678901234", "13800000001"},
|
||||
{"", "13800000002"},
|
||||
},
|
||||
wantCardCount: 1,
|
||||
wantErrorCount: 1,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "MSISDN为空-应记录错误",
|
||||
headers: []string{"ICCID", "MSISDN"},
|
||||
rows: [][]string{
|
||||
{"89860012345678901234", "13800000001"},
|
||||
{"89860012345678901235", ""},
|
||||
},
|
||||
wantCardCount: 1,
|
||||
wantErrorCount: 1,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "跳过空行",
|
||||
headers: []string{"ICCID", "MSISDN"},
|
||||
rows: [][]string{
|
||||
{"89860012345678901234", "13800000001"},
|
||||
{"", ""},
|
||||
{"89860012345678901235", "13800000002"},
|
||||
},
|
||||
wantCardCount: 2,
|
||||
wantErrorCount: 0,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "无表头-直接解析数据",
|
||||
headers: nil,
|
||||
rows: [][]string{
|
||||
{"89860012345678901234", "13800000001"},
|
||||
{"89860012345678901235", "13800000002"},
|
||||
},
|
||||
wantCardCount: 2,
|
||||
wantErrorCount: 0,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "20位长数字无损",
|
||||
headers: []string{"ICCID", "MSISDN"},
|
||||
rows: [][]string{
|
||||
{"12345678901234567890", "13800000001"},
|
||||
},
|
||||
wantCardCount: 1,
|
||||
wantErrorCount: 0,
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "首尾空格自动去除",
|
||||
headers: []string{"ICCID", "MSISDN"},
|
||||
rows: [][]string{
|
||||
{" 89860012345678901234 ", " 13800000001 "},
|
||||
},
|
||||
wantCardCount: 1,
|
||||
wantErrorCount: 0,
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 创建测试Excel文件
|
||||
filePath := createTestCardExcel(t, "test_cards.xlsx", tt.headers, tt.rows)
|
||||
|
||||
// 解析Excel
|
||||
result, err := ParseCardExcel(filePath)
|
||||
|
||||
// 验证错误
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// 验证结果
|
||||
assert.Equal(t, tt.wantCardCount, len(result.Cards), "卡数量不匹配")
|
||||
assert.Equal(t, tt.wantErrorCount, len(result.ParseErrors), "错误数量不匹配")
|
||||
|
||||
// 验证首尾空格被去除
|
||||
if tt.name == "首尾空格自动去除" && len(result.Cards) > 0 {
|
||||
assert.Equal(t, "89860012345678901234", result.Cards[0].ICCID)
|
||||
assert.Equal(t, "13800000001", result.Cards[0].MSISDN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCardExcel_ErrorScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
wantError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "文件不存在",
|
||||
setupFunc: func(t *testing.T) string {
|
||||
return "/nonexistent/file.xlsx"
|
||||
},
|
||||
wantError: true,
|
||||
errorContains: "打开Excel失败",
|
||||
},
|
||||
{
|
||||
name: "Excel无数据行",
|
||||
setupFunc: func(t *testing.T) string {
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "empty.xlsx")
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
|
||||
// 只写入表头,无数据行
|
||||
f.SetCellValue("Sheet1", "A1", "ICCID")
|
||||
f.SetCellValue("Sheet1", "B1", "MSISDN")
|
||||
|
||||
f.SaveAs(filePath)
|
||||
return filePath
|
||||
},
|
||||
wantError: true,
|
||||
errorContains: "Excel文件无数据行",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filePath := tt.setupFunc(t)
|
||||
|
||||
result, err := ParseCardExcel(filePath)
|
||||
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeviceExcel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
headers []string
|
||||
rows [][]string
|
||||
wantCount int
|
||||
wantError bool
|
||||
errorContains string
|
||||
validateFunc func(t *testing.T, rows []DeviceRow)
|
||||
}{
|
||||
{
|
||||
name: "标准10列格式",
|
||||
headers: []string{
|
||||
"device_no", "device_name", "device_model", "device_type",
|
||||
"max_sim_slots", "manufacturer", "iccid_1", "iccid_2", "iccid_3", "iccid_4",
|
||||
},
|
||||
rows: [][]string{
|
||||
{"DEV-001", "GPS追踪器A", "GT06N", "GPS Tracker", "4", "Concox", "89860012345678901234", "89860012345678901235", "", ""},
|
||||
{"DEV-002", "GPS追踪器B", "GT06N", "GPS Tracker", "4", "Concox", "89860012345678901236", "", "", ""},
|
||||
},
|
||||
wantCount: 2,
|
||||
wantError: false,
|
||||
validateFunc: func(t *testing.T, rows []DeviceRow) {
|
||||
assert.Equal(t, "DEV-001", rows[0].DeviceNo)
|
||||
assert.Equal(t, "GPS追踪器A", rows[0].DeviceName)
|
||||
assert.Equal(t, 4, rows[0].MaxSimSlots)
|
||||
assert.Equal(t, 2, len(rows[0].ICCIDs))
|
||||
|
||||
assert.Equal(t, "DEV-002", rows[1].DeviceNo)
|
||||
assert.Equal(t, 1, len(rows[1].ICCIDs))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "可选列缺失-应使用默认值",
|
||||
headers: []string{
|
||||
"device_no", "iccid_1",
|
||||
},
|
||||
rows: [][]string{
|
||||
{"DEV-003", "89860012345678901234"},
|
||||
},
|
||||
wantCount: 1,
|
||||
wantError: false,
|
||||
validateFunc: func(t *testing.T, rows []DeviceRow) {
|
||||
assert.Equal(t, "DEV-003", rows[0].DeviceNo)
|
||||
assert.Equal(t, 4, rows[0].MaxSimSlots, "max_sim_slots应默认为4")
|
||||
assert.Equal(t, "", rows[0].DeviceName)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ICCID列解析-全部4个插槽",
|
||||
headers: []string{
|
||||
"device_no", "iccid_1", "iccid_2", "iccid_3", "iccid_4",
|
||||
},
|
||||
rows: [][]string{
|
||||
{"DEV-004", "89860012345678901234", "89860012345678901235", "89860012345678901236", "89860012345678901237"},
|
||||
},
|
||||
wantCount: 1,
|
||||
wantError: false,
|
||||
validateFunc: func(t *testing.T, rows []DeviceRow) {
|
||||
assert.Equal(t, 4, len(rows[0].ICCIDs))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "跳过device_no为空的行",
|
||||
headers: []string{
|
||||
"device_no", "iccid_1",
|
||||
},
|
||||
rows: [][]string{
|
||||
{"DEV-005", "89860012345678901234"},
|
||||
{"", "89860012345678901235"},
|
||||
{"DEV-006", "89860012345678901236"},
|
||||
},
|
||||
wantCount: 2,
|
||||
wantError: false,
|
||||
validateFunc: func(t *testing.T, rows []DeviceRow) {
|
||||
assert.Equal(t, "DEV-005", rows[0].DeviceNo)
|
||||
assert.Equal(t, "DEV-006", rows[1].DeviceNo)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "max_sim_slots字符串转整数",
|
||||
headers: []string{
|
||||
"device_no", "max_sim_slots", "iccid_1",
|
||||
},
|
||||
rows: [][]string{
|
||||
{"DEV-007", "2", "89860012345678901234"},
|
||||
},
|
||||
wantCount: 1,
|
||||
wantError: false,
|
||||
validateFunc: func(t *testing.T, rows []DeviceRow) {
|
||||
assert.Equal(t, 2, rows[0].MaxSimSlots)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 创建测试Excel文件
|
||||
filePath := createTestDeviceExcel(t, "test_devices.xlsx", tt.headers, tt.rows)
|
||||
|
||||
// 解析Excel
|
||||
rows, count, err := ParseDeviceExcel(filePath)
|
||||
|
||||
// 验证错误
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantCount, count, "设备数量不匹配")
|
||||
assert.Equal(t, tt.wantCount, len(rows), "返回的行数不匹配")
|
||||
|
||||
// 执行自定义验证
|
||||
if tt.validateFunc != nil {
|
||||
tt.validateFunc(t, rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDeviceExcel_ErrorScenarios(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) string
|
||||
wantError bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "文件不存在",
|
||||
setupFunc: func(t *testing.T) string {
|
||||
return "/nonexistent/device.xlsx"
|
||||
},
|
||||
wantError: true,
|
||||
errorContains: "打开Excel失败",
|
||||
},
|
||||
{
|
||||
name: "Excel无数据行",
|
||||
setupFunc: func(t *testing.T) string {
|
||||
tmpDir := t.TempDir()
|
||||
filePath := filepath.Join(tmpDir, "empty_device.xlsx")
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
|
||||
// 只写入表头,无数据行
|
||||
f.SetCellValue("Sheet1", "A1", "device_no")
|
||||
|
||||
f.SaveAs(filePath)
|
||||
return filePath
|
||||
},
|
||||
wantError: true,
|
||||
errorContains: "Excel文件无数据行",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
filePath := tt.setupFunc(t)
|
||||
|
||||
rows, count, err := ParseDeviceExcel(filePath)
|
||||
|
||||
if tt.wantError {
|
||||
require.Error(t, err)
|
||||
if tt.errorContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorContains)
|
||||
}
|
||||
assert.Nil(t, rows)
|
||||
assert.Equal(t, 0, count)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectSheet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func() *excelize.File
|
||||
expectedSheet string
|
||||
}{
|
||||
{
|
||||
name: "优先选择'导入数据'sheet",
|
||||
setupFunc: func() *excelize.File {
|
||||
f := excelize.NewFile()
|
||||
f.NewSheet("Sheet1")
|
||||
f.NewSheet("导入数据")
|
||||
f.NewSheet("Sheet2")
|
||||
return f
|
||||
},
|
||||
expectedSheet: "导入数据",
|
||||
},
|
||||
{
|
||||
name: "无'导入数据'sheet-返回第一个",
|
||||
setupFunc: func() *excelize.File {
|
||||
f := excelize.NewFile()
|
||||
return f
|
||||
},
|
||||
expectedSheet: "Sheet1",
|
||||
},
|
||||
{
|
||||
name: "删除默认sheet后-返回空字符串",
|
||||
setupFunc: func() *excelize.File {
|
||||
f := excelize.NewFile()
|
||||
// excelize创建新文件时会有默认的Sheet1,删除后仍会返回Sheet1
|
||||
// 这是库的行为,我们只验证没有崩溃
|
||||
f.DeleteSheet("Sheet1")
|
||||
return f
|
||||
},
|
||||
expectedSheet: "Sheet1", // excelize的默认行为
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := tt.setupFunc()
|
||||
defer f.Close()
|
||||
|
||||
result := selectSheet(f)
|
||||
assert.Equal(t, tt.expectedSheet, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindCardColumns(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header []string
|
||||
wantICCIDCol int
|
||||
wantMSISDNCol int
|
||||
}{
|
||||
{
|
||||
name: "标准英文表头",
|
||||
header: []string{"ICCID", "MSISDN"},
|
||||
wantICCIDCol: 0,
|
||||
wantMSISDNCol: 1,
|
||||
},
|
||||
{
|
||||
name: "小写英文表头",
|
||||
header: []string{"iccid", "msisdn"},
|
||||
wantICCIDCol: 0,
|
||||
wantMSISDNCol: 1,
|
||||
},
|
||||
{
|
||||
name: "中文表头",
|
||||
header: []string{"卡号", "接入号"},
|
||||
wantICCIDCol: 0,
|
||||
wantMSISDNCol: 1,
|
||||
},
|
||||
{
|
||||
name: "混合表头",
|
||||
header: []string{"ICCID", "手机号"},
|
||||
wantICCIDCol: 0,
|
||||
wantMSISDNCol: 1,
|
||||
},
|
||||
{
|
||||
name: "表头顺序颠倒",
|
||||
header: []string{"MSISDN", "ICCID"},
|
||||
wantICCIDCol: 1,
|
||||
wantMSISDNCol: 0,
|
||||
},
|
||||
{
|
||||
name: "表头包含空格",
|
||||
header: []string{" ICCID ", " MSISDN "},
|
||||
wantICCIDCol: 0,
|
||||
wantMSISDNCol: 1,
|
||||
},
|
||||
{
|
||||
name: "无法识别的表头",
|
||||
header: []string{"unknown1", "unknown2"},
|
||||
wantICCIDCol: -1,
|
||||
wantMSISDNCol: -1,
|
||||
},
|
||||
{
|
||||
name: "只有ICCID列",
|
||||
header: []string{"ICCID", "其他"},
|
||||
wantICCIDCol: 0,
|
||||
wantMSISDNCol: -1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
iccidCol, msisdnCol := findCardColumns(tt.header)
|
||||
assert.Equal(t, tt.wantICCIDCol, iccidCol, "ICCID列索引不匹配")
|
||||
assert.Equal(t, tt.wantMSISDNCol, msisdnCol, "MSISDN列索引不匹配")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeviceColumnIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
header []string
|
||||
expectedIndex map[string]int
|
||||
}{
|
||||
{
|
||||
name: "标准10列表头",
|
||||
header: []string{
|
||||
"device_no", "device_name", "device_model", "device_type",
|
||||
"max_sim_slots", "manufacturer", "iccid_1", "iccid_2", "iccid_3", "iccid_4",
|
||||
},
|
||||
expectedIndex: map[string]int{
|
||||
"device_no": 0,
|
||||
"device_name": 1,
|
||||
"device_model": 2,
|
||||
"device_type": 3,
|
||||
"max_sim_slots": 4,
|
||||
"manufacturer": 5,
|
||||
"iccid_1": 6,
|
||||
"iccid_2": 7,
|
||||
"iccid_3": 8,
|
||||
"iccid_4": 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "顺序颠倒",
|
||||
header: []string{"iccid_1", "device_no"},
|
||||
expectedIndex: map[string]int{
|
||||
"iccid_1": 0,
|
||||
"device_no": 1,
|
||||
"device_name": -1,
|
||||
"device_model": -1,
|
||||
"device_type": -1,
|
||||
"max_sim_slots": -1,
|
||||
"manufacturer": -1,
|
||||
"iccid_2": -1,
|
||||
"iccid_3": -1,
|
||||
"iccid_4": -1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "大写表头-能识别",
|
||||
header: []string{"DEVICE_NO", "DEVICE_NAME"},
|
||||
expectedIndex: map[string]int{
|
||||
"device_no": 0,
|
||||
"device_name": 1,
|
||||
"device_model": -1,
|
||||
"device_type": -1,
|
||||
"max_sim_slots": -1,
|
||||
"manufacturer": -1,
|
||||
"iccid_1": -1,
|
||||
"iccid_2": -1,
|
||||
"iccid_3": -1,
|
||||
"iccid_4": -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildDeviceColumnIndex(tt.header)
|
||||
assert.Equal(t, tt.expectedIndex, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCardExcel_RealWorldScenario 测试真实场景
|
||||
func TestParseCardExcel_RealWorldScenario(t *testing.T) {
|
||||
t.Run("100行数据性能测试", func(t *testing.T) {
|
||||
// 生成100行测试数据
|
||||
headers := []string{"ICCID", "MSISDN"}
|
||||
rows := make([][]string, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
iccid := "8986001234567890" + padLeft(i, 4)
|
||||
msisdn := "1380000" + padLeft(i, 4)
|
||||
rows[i] = []string{iccid, msisdn}
|
||||
}
|
||||
|
||||
filePath := createTestCardExcel(t, "large_cards.xlsx", headers, rows)
|
||||
|
||||
result, err := ParseCardExcel(filePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 100, len(result.Cards))
|
||||
assert.Equal(t, 0, len(result.ParseErrors))
|
||||
})
|
||||
}
|
||||
|
||||
// padLeft 左侧填充0
|
||||
func padLeft(num int, width int) string {
|
||||
s := ""
|
||||
for i := 0; i < width; i++ {
|
||||
s += "0"
|
||||
}
|
||||
s += string(rune('0' + num%10))
|
||||
if num >= 10 {
|
||||
s = s[:width-2] + string(rune('0'+num/10%10)) + string(rune('0'+num%10))
|
||||
}
|
||||
if num >= 100 {
|
||||
s = s[:width-3] + string(rune('0'+num/100%10)) + string(rune('0'+num/10%10)) + string(rune('0'+num%10))
|
||||
}
|
||||
if num >= 1000 {
|
||||
s = string(rune('0'+num/1000%10)) + string(rune('0'+num/100%10)) + string(rune('0'+num/10%10)) + string(rune('0'+num%10))
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user