Files
junhong_cmp_fiber/pkg/utils/excel.go
huang 809cb2b8a8 fix(01-05): 设备导入补充 IMEI 字段(CRITICAL-07)
- DeviceRow struct 新增 IMEI string 字段
- buildDeviceColumnIndex 支持 imei/设备imei/imei号 列名映射
- ParseDeviceExcel 解析数据行时填充 row.IMEI
- device_import.go processBatch 创建 model.Device 时赋值 IMEI: row.IMEI
2026-03-27 22:49:40 +08:00

357 lines
8.2 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 utils
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
)
// CardInfo 卡信息(ICCID + MSISDN + VirtualNo)
type CardInfo struct {
ICCID string
MSISDN string
VirtualNo 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
VirtualNo string
IMEI string // 设备IMEI对应 Excel IMEI 列,用于 Gateway API 调用
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["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
}
}
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.VirtualNo == "" {
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),
}
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++ {
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])
}
}
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,
VirtualNo: virtualNo,
})
}
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
}