Files
cmautobuy/admin/service/inner_code_import.go
T

329 lines
11 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 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
}