2026-08-15 08:47:22 +08:00
|
|
|
|
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 {
|
2026-08-15 09:12:23 +08:00
|
|
|
|
if errors.Is(err, repository.ErrInnerCodeUniqueConflict) {
|
|
|
|
|
|
return nil, fmt.Errorf("%w:Excel 第 %d 行的入库码 %q 在该业务日期已属于另一条记录",
|
|
|
|
|
|
ErrInvalidInnerCodeImport, row.SourceRow, row.InnerCode)
|
|
|
|
|
|
}
|
2026-08-15 08:47:22 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|