package service import ( "bytes" "database/sql" "errors" "fmt" "path/filepath" "regexp" "strconv" "strings" "time" "unicode/utf8" "github.com/xuri/excelize/v2" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) const ( MaxInnerCodeUploadBytes = 10 * 1024 * 1024 MaxInnerCodeImportRows = 5000 innerCodeSheetName = "标签入库码映射" ) var ( ErrInvalidInnerCodeImport = errors.New("档口入库码导入文件无效") innerCodeXLSXMagic = []byte{0x50, 0x4B, 0x03, 0x04} innerCodeBracketCN = regexp.MustCompile(`【[^】]*】`) innerCodeParenthesesCN = regexp.MustCompile(`([^)]*)`) innerCodeParenthesesASCII = regexp.MustCompile(`\([^)]*\)`) innerCodeSuggestionTail = regexp.MustCompile(`建議.*$`) innerCodeSpaces = regexp.MustCompile(`\s+`) innerCodeStallToken = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*$`) innerCodeWeightTail = regexp.MustCompile(`(?i)[\d.]+[-~~到至][\d.]+(?:公斤|kg).*$`) innerCodeSizeTail = regexp.MustCompile(`(?i)((?:[1-9]\d*)?XL|XXL|XS|S|M|L)$`) ) // InnerCodeImportResult 是一次单表幂等导入的统计。 type InnerCodeImportResult struct { TotalRows int CreatedCount int UpdatedCount int DuplicateRows int } // IsInvalidInnerCodeImport 判断错误是否需要用户修正上传文件。 func IsInvalidInnerCodeImport(err error) bool { return errors.Is(err, ErrInvalidInnerCodeImport) } // ValidateInnerCodeUpload 在落盘前限制 Excel 类型和大小。 func ValidateInnerCodeUpload(filename string, size int64, head []byte) error { if size <= 0 { return fmt.Errorf("文件是空的") } if size > MaxInnerCodeUploadBytes { return fmt.Errorf("文件超过 10MB 上限(当前约 %.1fMB)", float64(size)/1024/1024) } if !strings.EqualFold(filepath.Ext(filename), ".xlsx") { return fmt.Errorf("只允许 .xlsx 文件,收到的是 %q", filepath.Ext(filename)) } if !bytes.HasPrefix(head, innerCodeXLSXMagic) { return fmt.Errorf("文件内容不像 xlsx,可能只是修改了扩展名") } return nil } // NormalizeInnerCodeSpecKey 把 Excel/顺运宝规格收敛为可比较的“颜色,尺码”。 // 它只做确定性清洗,解析不出来时保留清洗后的原文,不猜测含义。 func NormalizeInnerCodeSpecKey(value string) string { text := strings.TrimSpace(value) if text == "" { return "" } text = innerCodeBracketCN.ReplaceAllString(text, "") text = innerCodeParenthesesCN.ReplaceAllString(text, "") text = innerCodeParenthesesASCII.ReplaceAllString(text, "") text = innerCodeSuggestionTail.ReplaceAllString(text, "") text = strings.TrimSpace(innerCodeSpaces.ReplaceAllString(text, " ")) color, size, found := strings.Cut(text, ",") if !found { return innerCodeSpaces.ReplaceAllString(text, "") } colorTokens := strings.Fields(color) if len(colorTokens) >= 2 && innerCodeStallToken.MatchString(colorTokens[0]) { color = strings.Join(colorTokens[1:], "") } else { color = innerCodeSpaces.ReplaceAllString(color, "") } size = innerCodeSpaces.ReplaceAllString(size, "") size = strings.ReplaceAll(size, "碼", "") size = innerCodeWeightTail.ReplaceAllString(size, "") if match := innerCodeSizeTail.FindStringSubmatch(size); len(match) == 2 { size = strings.ToUpper(match[1]) } return color + "," + size } // ImportInnerCodeExcel 解析工作表并在一个事务中幂等写入业务表。 func ImportInnerCodeExcel(db *sql.DB, path, businessDate, actorUserID string) (*InnerCodeImportResult, error) { if _, err := time.Parse("2006-01-02", businessDate); err != nil { return nil, fmt.Errorf("%w:业务日期格式应为 YYYY-MM-DD", ErrInvalidInnerCodeImport) } if strings.TrimSpace(actorUserID) == "" { return nil, fmt.Errorf("导入账号不能为空") } book, err := excelize.OpenFile(path, excelize.Options{RawCellValue: true}) if err != nil { return nil, fmt.Errorf("%w:打开 Excel 失败(%v)", ErrInvalidInnerCodeImport, err) } defer book.Close() result, rows, err := parseInnerCodeWorkbook(book, businessDate, actorUserID) if err != nil { return nil, fmt.Errorf("%w:%v", ErrInvalidInnerCodeImport, err) } tx, err := db.Begin() if err != nil { return nil, fmt.Errorf("开始档口入库码导入事务失败: %w", err) } defer tx.Rollback() now := model.NowISO() for _, row := range rows { outcome, err := repository.UpsertInnerCodeImportRow(tx, row, now) if err != nil { if errors.Is(err, repository.ErrInnerCodeUniqueConflict) { return nil, fmt.Errorf("%w:Excel 第 %d 行的入库码 %q 在该业务日期已属于另一条记录", ErrInvalidInnerCodeImport, row.SourceRow, row.InnerCode) } return nil, fmt.Errorf("导入 Excel 第 %d 行失败: %w", row.SourceRow, err) } if outcome == repository.InnerCodeImportCreated { result.CreatedCount++ } else { result.UpdatedCount++ } } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("提交档口入库码导入事务失败: %w", err) } return result, nil } type innerCodeParsedRow struct { model.InnerCodeImportRow businessKey string } func parseInnerCodeWorkbook(book *excelize.File, businessDate, actorUserID string) (*InnerCodeImportResult, []model.InnerCodeImportRow, error) { found := false for _, name := range book.GetSheetList() { if name != innerCodeSheetName { continue } found = true rows, err := book.Rows(name) if err != nil { return nil, nil, fmt.Errorf("读取工作表 %q 失败: %w", name, err) } result, parsed, parseErr := parseInnerCodeRows(rows, businessDate, actorUserID) closeErr := rows.Close() if parseErr != nil { return nil, nil, parseErr } if closeErr != nil { return nil, nil, fmt.Errorf("关闭工作表 %q 失败: %w", name, closeErr) } return result, parsed, nil } if !found { return nil, nil, fmt.Errorf("Excel 中没有工作表 %q", innerCodeSheetName) } return nil, nil, fmt.Errorf("工作表 %q 无法读取", innerCodeSheetName) } func parseInnerCodeRows(rows *excelize.Rows, businessDate, actorUserID string) (*InnerCodeImportResult, []model.InnerCodeImportRow, error) { var headers map[string]int var specColumn int rowNumber := 0 parsed := make([]innerCodeParsedRow, 0, 128) for rows.Next() { rowNumber++ columns, err := rows.Columns() if err != nil { return nil, nil, fmt.Errorf("读取第 %d 行失败: %w", rowNumber, err) } if headers == nil { if rowNumber > 10 { break } candidate, candidateSpec := innerCodeHeaderMap(columns) if _, ok := candidate["内部档口入库码"]; ok { if _, ok = candidate["Shopee订单编号"]; ok && candidateSpec >= 0 { headers, specColumn = candidate, candidateSpec } } continue } orderNumber := innerCodeCell(columns, headers["Shopee订单编号"]) innerCode := innerCodeCell(columns, headers["内部档口入库码"]) if orderNumber == "" && innerCode == "" { continue } if len(parsed) >= MaxInnerCodeImportRows { return nil, nil, fmt.Errorf("非空数据超过 %d 条上限,请拆成多个文件导入", MaxInnerCodeImportRows) } if orderNumber == "" || innerCode == "" { return nil, nil, fmt.Errorf("第 %d 行缺少 Shopee订单编号或内部档口入库码", rowNumber) } specRaw := strings.ReplaceAll(innerCodeCell(columns, specColumn), "\r", " ") specRaw = strings.TrimSpace(strings.ReplaceAll(specRaw, "\n", " ")) stall := innerCodeStall(columns, headers) shop := innerCodeNamedCell(columns, headers, "店铺名称") printSequence := 0 if raw := innerCodeNamedCell(columns, headers, "标签打印序号"); raw != "" { if value, parseErr := strconv.Atoi(raw); parseErr == nil && value > 0 { printSequence = value } } if err := validateInnerCodeFields(rowNumber, orderNumber, shop, stall, specRaw, innerCode); err != nil { return nil, nil, err } specKey := NormalizeInnerCodeSpecKey(specRaw) key := strings.Join([]string{businessDate, orderNumber, stall, specKey}, "\x00") parsed = append(parsed, innerCodeParsedRow{InnerCodeImportRow: model.InnerCodeImportRow{ BusinessDate: businessDate, SourceRow: rowNumber, PrintSequence: printSequence, OrderNumber: orderNumber, ShopName: shop, Stall: stall, SpecRaw: specRaw, SpecKey: specKey, InnerCode: innerCode, SourceDuplicateCount: 1, CreatedByUserID: actorUserID, }, businessKey: key}) } if err := rows.Error(); err != nil { return nil, nil, fmt.Errorf("遍历工作表失败: %w", err) } if headers == nil { return nil, nil, fmt.Errorf("前 10 行未找到包含内部档口入库码、Shopee订单编号和规格列的表头") } if len(parsed) == 0 { return nil, nil, fmt.Errorf("工作表只有表头,没有可导入数据") } groups := make(map[string][]innerCodeParsedRow, len(parsed)) codeKeys := make(map[string]string, len(parsed)) for _, row := range parsed { groups[row.businessKey] = append(groups[row.businessKey], row) if oldKey, exists := codeKeys[row.InnerCode]; exists && oldKey != row.businessKey { return nil, nil, fmt.Errorf("同一业务日期的入库码 %q 对应多组订单/档口/规格", row.InnerCode) } codeKeys[row.InnerCode] = row.businessKey } result := &InnerCodeImportResult{TotalRows: len(parsed)} unique := make([]model.InnerCodeImportRow, 0, len(groups)) seen := make(map[string]bool, len(groups)) for _, original := range parsed { if seen[original.businessKey] { continue } seen[original.businessKey] = true group := groups[original.businessKey] for _, duplicate := range group[1:] { if duplicate.InnerCode != original.InnerCode { return nil, nil, fmt.Errorf("第 %d 行与第 %d 行业务键相同但入库码不同", original.SourceRow, duplicate.SourceRow) } } row := original.InnerCodeImportRow row.SourceDuplicateCount = len(group) unique = append(unique, row) result.DuplicateRows += len(group) - 1 } return result, unique, nil } func innerCodeHeaderMap(columns []string) (map[string]int, int) { result := make(map[string]int, len(columns)) specColumn := -1 for index, raw := range columns { name := strings.TrimSpace(raw) result[name] = index if name == "清洗后规格" || (specColumn < 0 && strings.HasPrefix(name, "原始产品规格")) { specColumn = index } } return result, specColumn } func innerCodeStall(columns []string, headers map[string]int) string { if combined := innerCodeNamedCell(columns, headers, "档口及货号"); combined != "" { return combined } name := innerCodeNamedCell(columns, headers, "档口名称") article := innerCodeNamedCell(columns, headers, "档口货号") if name != "" && article != "" { return name + "#" + article } return name + article } func innerCodeNamedCell(columns []string, headers map[string]int, name string) string { index, ok := headers[name] if !ok { return "" } return innerCodeCell(columns, index) } func innerCodeCell(columns []string, index int) string { if index < 0 || index >= len(columns) { return "" } return strings.TrimSpace(columns[index]) } func validateInnerCodeFields(row int, order, shop, stall, spec, code string) error { for _, field := range []struct { name, value string max int }{ {"Shopee订单编号", order, 64}, {"店铺名称", shop, 191}, {"档口及货号", stall, 191}, {"规格", spec, 500}, {"内部档口入库码", code, 128}, } { if utf8.RuneCountInString(field.value) > field.max { return fmt.Errorf("第 %d 行%s超过 %d 个字符", row, field.name, field.max) } } return nil }