feat: Excel 导入按列位置读取,IoT 卡导入支持卡业务类型
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s

- 重写 parseCardRows:移除列名识别,固定跳过第1行表头,按 col0=ICCID、
  col1=MSISDN、col2=VirtualNo 取值,VirtualNo 升级为必填字段
- 重写 ParseDeviceExcel:移除 buildDeviceColumnIndex,IMEI 调整至第5列
  新增 MaxSimSlots 范围校验 [1,4],整行为空时不计入 total
- 删除 findCardColumns 和 buildDeviceColumnIndex 两个冗余函数
- ImportIotCardRequest 新增 card_category 字段(normal/industry,默认 normal)
- IotCardImportTask model 新增 card_category 字段
- 迁移 000111:tb_iot_card_import_task 新增 card_category 列
- CreateImportTask 将 req.CardCategory 写入任务,空串兜底 normal
- processBatch 改用 task.CardCategory 替代写死的 CardCategoryNormal
- ImportTaskResponse 新增 card_category 字段返回

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 17:16:31 +08:00
parent ff7e749bf2
commit cd807e58e3
13 changed files with 419 additions and 152 deletions

View File

@@ -120,55 +120,50 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
return nil, ErrExcelNoData
}
header := rows[0]
colIndex := buildDeviceColumnIndex(header)
parseResult := &DeviceParseResult{
Rows: make([]DeviceRow, 0),
ParseErrors: make([]CSVParseError, 0),
}
// 按固定列位置读取第1行为表头跳过
// col0=虚拟号 col1=设备名称 col2=设备型号 col3=设备类型 col4=IMEI
// col5=制造商 col6=最大SIM槽数 col7~10=卡1~4 ICCID
for i := 1; i < len(rows); i++ {
record := rows[i]
lineNum := i + 1
row := DeviceRow{Line: lineNum}
if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) {
row.VirtualNo = strings.TrimSpace(record[idx])
}
if idx := colIndex["imei"]; idx >= 0 && idx < len(record) {
row.IMEI = 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
getCol := func(idx int) string {
if idx < len(record) {
return strings.TrimSpace(record[idx])
}
}
if idx := colIndex["manufacturer"]; idx >= 0 && idx < len(record) {
row.Manufacturer = strings.TrimSpace(record[idx])
return ""
}
row.VirtualNo = getCol(0)
row.DeviceName = getCol(1)
row.DeviceModel = getCol(2)
row.DeviceType = getCol(3)
row.IMEI = getCol(4)
row.Manufacturer = getCol(5)
maxSimSlotsStr := getCol(6)
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)
}
for j := 7; j <= 10; j++ {
iccid := getCol(j)
if iccid != "" {
row.ICCIDs = append(row.ICCIDs, iccid)
}
}
// 整行目标列皆为空,视为空行跳过,不计入 total
if row.VirtualNo == "" && row.DeviceName == "" && row.DeviceModel == "" &&
row.DeviceType == "" && row.IMEI == "" && row.Manufacturer == "" &&
maxSimSlotsStr == "" && len(row.ICCIDs) == 0 {
continue
}
parseResult.TotalCount++
if row.VirtualNo == "" {
@@ -179,8 +174,22 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) {
continue
}
// 解析最大SIM槽数
if maxSimSlotsStr != "" {
if n, err := strconv.Atoi(maxSimSlotsStr); err == nil {
row.MaxSimSlots = n
}
}
// 范围校验:不在 [1,4] 则失败;为空/0 时回填默认值 4
if row.MaxSimSlots == 0 {
row.MaxSimSlots = 4
} else if row.MaxSimSlots < 1 || row.MaxSimSlots > 4 {
parseResult.ParseErrors = append(parseResult.ParseErrors, CSVParseError{
Line: lineNum,
Reason: "最大SIM槽数必须在1-4之间",
})
continue
}
parseResult.Rows = append(parseResult.Rows, row)
@@ -209,70 +218,44 @@ func selectSheet(f *excelize.File) string {
}
// parseCardRows 解析卡数据行
// 自动检测表头并提取ICCID和MSISDN列
// 第1行固定为表头跳过按列位置取值col0=ICCID、col1=MSISDN、col2=VirtualNo
func parseCardRows(rows [][]string) (*CSVParseResult, error) {
result := &CSVParseResult{
Cards: make([]CardInfo, 0),
ParseErrors: make([]CSVParseError, 0),
}
headerSkipped := false
iccidCol, msisdnCol, virtualNoCol := -1, -1, -1
if len(rows) > 0 {
iccidCol, msisdnCol, virtualNoCol = findCardColumns(rows[0])
if iccidCol >= 0 && msisdnCol >= 0 {
headerSkipped = true
}
}
startLine := 0
if headerSkipped {
startLine = 1
}
for i := startLine; i < len(rows); i++ {
// 从第2行索引1开始跳过第1行表头
for i := 1; i < len(rows); i++ {
row := rows[i]
lineNum := i + 1
if len(row) == 0 {
continue
}
iccid := ""
msisdn := ""
virtualNo := ""
if iccidCol >= 0 {
if iccidCol < len(row) {
iccid = strings.TrimSpace(row[iccidCol])
}
if msisdnCol < len(row) {
msisdn = strings.TrimSpace(row[msisdnCol])
}
if virtualNoCol >= 0 && virtualNoCol < len(row) {
virtualNo = strings.TrimSpace(row[virtualNoCol])
}
} else {
if len(row) >= 1 {
iccid = strings.TrimSpace(row[0])
}
if len(row) >= 2 {
msisdn = strings.TrimSpace(row[1])
}
if len(row) >= 1 {
iccid = strings.TrimSpace(row[0])
}
if len(row) >= 2 {
msisdn = strings.TrimSpace(row[1])
}
if len(row) >= 3 {
virtualNo = strings.TrimSpace(row[2])
}
// 三列皆为空视为空行,跳过且不计入 total
if iccid == "" && msisdn == "" && virtualNo == "" {
continue
}
result.TotalCount++
if iccid == "" && msisdn == "" {
continue
}
if iccid == "" {
result.ParseErrors = append(result.ParseErrors, CSVParseError{
Line: lineNum,
MSISDN: msisdn,
Reason: "ICCID不能为空",
Reason: "ICCID 不能为空",
})
continue
}
@@ -281,7 +264,16 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
result.ParseErrors = append(result.ParseErrors, CSVParseError{
Line: lineNum,
ICCID: iccid,
Reason: "MSISDN不能为空",
Reason: "MSISDN 不能为空",
})
continue
}
if virtualNo == "" {
result.ParseErrors = append(result.ParseErrors, CSVParseError{
Line: lineNum,
ICCID: iccid,
Reason: "虚拟号(virtual_no)不能为空",
})
continue
}
@@ -296,68 +288,3 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
return result, nil
}
// findCardColumns 查找ICCID、MSISDN和VirtualNo列索引
// 支持中英文列名识别
func findCardColumns(header []string) (iccidCol, msisdnCol, virtualNoCol int) {
iccidCol, msisdnCol, virtualNoCol = -1, -1, -1
for i, col := range header {
colLower := strings.ToLower(strings.TrimSpace(col))
if colLower == "iccid" || colLower == "卡号" || colLower == "号码" {
if iccidCol == -1 {
iccidCol = i
}
}
if colLower == "msisdn" || colLower == "接入号" || colLower == "手机号" || colLower == "电话" {
if msisdnCol == -1 {
msisdnCol = i
}
}
if colLower == "virtual_no" || colLower == "virtualno" || colLower == "虚拟号" || colLower == "设备号" {
if virtualNoCol == -1 {
virtualNoCol = i
}
}
}
return iccidCol, msisdnCol, virtualNoCol
}
// buildDeviceColumnIndex 构建设备导入列索引
// 识别表头中的列名,返回列名到列索引的映射
// IMEI 列支持多种列名格式imei/IMEI/设备IMEI/IMEI号
func buildDeviceColumnIndex(header []string) map[string]int {
index := map[string]int{
"virtual_no": -1,
"imei": -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 {
normalized := strings.ToLower(strings.TrimSpace(col))
// 支持 IMEI 多种列名格式
switch normalized {
case "imei", "设备imei", "imei号":
if index["imei"] == -1 {
index["imei"] = i
}
default:
if _, exists := index[normalized]; exists {
index[normalized] = i
}
}
}
return index
}