diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go index 3ef8e80..2b2fe27 100644 --- a/admin/handler/web/inner_code.go +++ b/admin/handler/web/inner_code.go @@ -57,11 +57,6 @@ func (h *Handler) InnerCodeList(c *gin.Context) { // InnerCodeImport 接收一次性 xlsx,导入完成后总会删除临时文件。 func (h *Handler) InnerCodeImport(c *gin.Context) { - businessDate := strings.TrimSpace(c.PostForm("date")) - if _, err := time.Parse("2006-01-02", businessDate); err != nil { - fail(c, http.StatusBadRequest, "请选择合法的业务日期;没有导入任何数据。") - return - } fileHeader, err := c.FormFile("file") if err != nil { fail(c, http.StatusBadRequest, "没有收到 Excel(也可能超过 10MB);没有导入任何数据。") @@ -104,7 +99,7 @@ func (h *Handler) InnerCodeImport(c *gin.Context) { fail(c, http.StatusInternalServerError, "保存临时上传文件失败;没有导入任何数据。") return } - result, err := service.ImportInnerCodeExcel(h.db, tmpPath, businessDate, currentUser(c).UserID) + result, err := service.ImportInnerCodeExcel(h.db, tmpPath, fileHeader.Filename, currentUser(c).UserID) if err != nil { if service.IsInvalidInnerCodeImport(err) { fail(c, http.StatusBadRequest, err.Error()+";请修正文件后重新导入。") @@ -113,9 +108,9 @@ func (h *Handler) InnerCodeImport(c *gin.Context) { fail(c, http.StatusInternalServerError, "档口入库码写库失败,本次导入已整体回滚,请稍后重试。") return } - message := fmt.Sprintf("导入完成:读取 %d 行,新增 %d 条,更新 %d 条,文件内重复 %d 行。", - result.TotalRows, result.CreatedCount, result.UpdatedCount, result.DuplicateRows) - h.innerCodeRedirect(c, businessDate, "", "", 1, message) + message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,文件内重复 %d 行。", + result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.DuplicateRows) + h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, message) } // InnerCodeMatch 读取远端最新详情并保存只读规划,不执行任何回写。 diff --git a/admin/service/inner_code_import.go b/admin/service/inner_code_import.go index 6c54860..cc29ca6 100644 --- a/admin/service/inner_code_import.go +++ b/admin/service/inner_code_import.go @@ -35,10 +35,13 @@ var ( 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)$`) + innerCodeFilenameDate = regexp.MustCompile(`(?:^|_)(\d{8})(?:_|\.|$)`) + innerCodeShortDate = regexp.MustCompile(`^(\d{1,2})[-/](\d{1,2})$`) ) // InnerCodeImportResult 是一次单表幂等导入的统计。 type InnerCodeImportResult struct { + BusinessDate string TotalRows int CreatedCount int UpdatedCount int @@ -99,10 +102,8 @@ func NormalizeInnerCodeSpecKey(value string) string { } // 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) - } +// 业务日期只取 Excel“生成日期”;originalFilename 仅用于给短日期补充并校验年份。 +func ImportInnerCodeExcel(db *sql.DB, path, originalFilename, actorUserID string) (*InnerCodeImportResult, error) { if strings.TrimSpace(actorUserID) == "" { return nil, fmt.Errorf("导入账号不能为空") } @@ -112,7 +113,7 @@ func ImportInnerCodeExcel(db *sql.DB, path, businessDate, actorUserID string) (* } defer book.Close() - result, rows, err := parseInnerCodeWorkbook(book, businessDate, actorUserID) + result, rows, err := parseInnerCodeWorkbook(book, originalFilename, actorUserID) if err != nil { return nil, fmt.Errorf("%w:%v", ErrInvalidInnerCodeImport, err) } @@ -148,7 +149,7 @@ type innerCodeParsedRow struct { businessKey string } -func parseInnerCodeWorkbook(book *excelize.File, businessDate, actorUserID string) (*InnerCodeImportResult, []model.InnerCodeImportRow, error) { +func parseInnerCodeWorkbook(book *excelize.File, originalFilename, actorUserID string) (*InnerCodeImportResult, []model.InnerCodeImportRow, error) { found := false for _, name := range book.GetSheetList() { if name != innerCodeSheetName { @@ -159,7 +160,7 @@ func parseInnerCodeWorkbook(book *excelize.File, businessDate, actorUserID strin if err != nil { return nil, nil, fmt.Errorf("读取工作表 %q 失败: %w", name, err) } - result, parsed, parseErr := parseInnerCodeRows(rows, businessDate, actorUserID) + result, parsed, parseErr := parseInnerCodeRows(rows, originalFilename, actorUserID) closeErr := rows.Close() if parseErr != nil { return nil, nil, parseErr @@ -175,9 +176,10 @@ func parseInnerCodeWorkbook(book *excelize.File, businessDate, actorUserID strin return nil, nil, fmt.Errorf("工作表 %q 无法读取", innerCodeSheetName) } -func parseInnerCodeRows(rows *excelize.Rows, businessDate, actorUserID string) (*InnerCodeImportResult, []model.InnerCodeImportRow, error) { +func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID string) (*InnerCodeImportResult, []model.InnerCodeImportRow, error) { var headers map[string]int var specColumn int + var businessDate string rowNumber := 0 parsed := make([]innerCodeParsedRow, 0, 128) for rows.Next() { @@ -192,7 +194,8 @@ func parseInnerCodeRows(rows *excelize.Rows, businessDate, actorUserID string) ( } candidate, candidateSpec := innerCodeHeaderMap(columns) if _, ok := candidate["内部档口入库码"]; ok { - if _, ok = candidate["Shopee订单编号"]; ok && candidateSpec >= 0 { + _, hasDate := candidate["生成日期"] + if _, ok = candidate["Shopee订单编号"]; ok && candidateSpec >= 0 && hasDate { headers, specColumn = candidate, candidateSpec } } @@ -209,6 +212,15 @@ func parseInnerCodeRows(rows *excelize.Rows, businessDate, actorUserID string) ( if orderNumber == "" || innerCode == "" { return nil, nil, fmt.Errorf("第 %d 行缺少 Shopee订单编号或内部档口入库码", rowNumber) } + rowBusinessDate, err := parseInnerCodeBusinessDate(innerCodeNamedCell(columns, headers, "生成日期"), originalFilename) + if err != nil { + return nil, nil, fmt.Errorf("第 %d 行生成日期无效:%v", rowNumber, err) + } + if businessDate == "" { + businessDate = rowBusinessDate + } else if businessDate != rowBusinessDate { + return nil, nil, fmt.Errorf("同一工作簿包含多个生成日期(%s、%s),请拆分后导入", businessDate, rowBusinessDate) + } specRaw := strings.ReplaceAll(innerCodeCell(columns, specColumn), "\r", " ") specRaw = strings.TrimSpace(strings.ReplaceAll(specRaw, "\n", " ")) stall := innerCodeStall(columns, headers) @@ -235,7 +247,7 @@ func parseInnerCodeRows(rows *excelize.Rows, businessDate, actorUserID string) ( return nil, nil, fmt.Errorf("遍历工作表失败: %w", err) } if headers == nil { - return nil, nil, fmt.Errorf("前 10 行未找到包含内部档口入库码、Shopee订单编号和规格列的表头") + return nil, nil, fmt.Errorf("前 10 行未找到包含生成日期、内部档口入库码、Shopee订单编号和规格列的表头") } if len(parsed) == 0 { return nil, nil, fmt.Errorf("工作表只有表头,没有可导入数据") @@ -250,7 +262,7 @@ func parseInnerCodeRows(rows *excelize.Rows, businessDate, actorUserID string) ( } codeKeys[row.InnerCode] = row.businessKey } - result := &InnerCodeImportResult{TotalRows: len(parsed)} + result := &InnerCodeImportResult{BusinessDate: businessDate, TotalRows: len(parsed)} unique := make([]model.InnerCodeImportRow, 0, len(groups)) seen := make(map[string]bool, len(groups)) for _, original := range parsed { @@ -272,6 +284,51 @@ func parseInnerCodeRows(rows *excelize.Rows, businessDate, actorUserID string) ( return result, unique, nil } +// parseInnerCodeBusinessDate 把完整日期或依赖文件名年份的短日期标准化为 YYYY-MM-DD。 +func parseInnerCodeBusinessDate(raw, originalFilename string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", fmt.Errorf("不能为空") + } + for _, layout := range []string{"2006-01-02", "2006/01/02", "2006.01.02"} { + if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil { + return parsed.Format("2006-01-02"), nil + } + } + if serial, err := strconv.ParseFloat(value, 64); err == nil && serial >= 1 { + if parsed, dateErr := excelize.ExcelDateToTime(serial, false); dateErr == nil { + return parsed.Format("2006-01-02"), nil + } + } + match := innerCodeShortDate.FindStringSubmatch(value) + if len(match) != 3 { + return "", fmt.Errorf("%q 不是 YYYY-MM-DD、YYYY/MM/DD、MM-DD 或 MM/DD", value) + } + filenameDate, ok := innerCodeDateFromFilename(originalFilename) + if !ok { + return "", fmt.Errorf("短日期 %q 缺少年份,原始文件名中也没有 _YYYYMMDD_ 日期", value) + } + month, _ := strconv.Atoi(match[1]) + day, _ := strconv.Atoi(match[2]) + parsed := time.Date(filenameDate.Year(), time.Month(month), day, 0, 0, 0, 0, time.Local) + if parsed.Month() != time.Month(month) || parsed.Day() != day { + return "", fmt.Errorf("短日期 %q 不存在", value) + } + if parsed.Month() != filenameDate.Month() || parsed.Day() != filenameDate.Day() { + return "", fmt.Errorf("短日期 %q 与文件名日期 %s 不一致", value, filenameDate.Format("2006-01-02")) + } + return parsed.Format("2006-01-02"), nil +} + +func innerCodeDateFromFilename(filename string) (time.Time, bool) { + match := innerCodeFilenameDate.FindStringSubmatch(filepath.Base(strings.TrimSpace(filename))) + if len(match) != 2 { + return time.Time{}, false + } + parsed, err := time.ParseInLocation("20060102", match[1], time.Local) + return parsed, err == nil +} + func innerCodeHeaderMap(columns []string) (map[string]int, int) { result := make(map[string]int, len(columns)) specColumn := -1 diff --git a/admin/service/inner_code_import_test.go b/admin/service/inner_code_import_test.go index 7c9ad5b..b3bc469 100644 --- a/admin/service/inner_code_import_test.go +++ b/admin/service/inner_code_import_test.go @@ -31,14 +31,14 @@ func TestParseInnerCodeWorkbook_表头前置行与重复业务组(t *testing.T) } rows := [][]string{ {"线下档口标签"}, - {"标签打印序号", "内部档口入库码", "Shopee订单编号", "清洗后规格", "档口名称", "档口货号", "店铺名称"}, - {"1", "DK001", "ORDER-1", "A-027 黑色,M碼 建議45-55公斤", "A档", "027", "shop-a"}, - {"2", "DK001", "ORDER-1", "A-027 黑色,M碼 建議45-55公斤", "A档", "027", "shop-a"}, - {"3", "DK002", "ORDER-2", "杏色,XL", "B档", "102", "shop-b"}, + {"标签打印序号", "内部档口入库码", "Shopee订单编号", "清洗后规格", "档口名称", "档口货号", "店铺名称", "生成日期"}, + {"1", "DK001", "ORDER-1", "A-027 黑色,M碼 建議45-55公斤", "A档", "027", "shop-a", "2026-08-15"}, + {"2", "DK001", "ORDER-1", "A-027 黑色,M碼 建議45-55公斤", "A档", "027", "shop-a", "2026-08-15"}, + {"3", "DK002", "ORDER-2", "杏色,XL", "B档", "102", "shop-b", "2026-08-15"}, } setInnerCodeWorkbookRows(t, book, rows) - result, imported, err := parseInnerCodeWorkbook(book, "2026-08-15", "user-1") + result, imported, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1") if err != nil { t.Fatal(err) } @@ -46,6 +46,9 @@ func TestParseInnerCodeWorkbook_表头前置行与重复业务组(t *testing.T) t.Fatalf("统计不正确: result=%+v rows=%d", result, len(imported)) } first := imported[0] + if result.BusinessDate != "2026-08-15" || first.BusinessDate != "2026-08-15" { + t.Fatalf("生成日期解析不正确: result=%+v row=%+v", result, first) + } if first.SpecKey != "黑色,M" || first.Stall != "A档#027" || first.SourceDuplicateCount != 2 { t.Fatalf("首行业务字段不正确: %+v", first) } @@ -61,11 +64,11 @@ func TestParseInnerCodeWorkbook_同日入库码指向不同业务键时报错(t t.Fatal(err) } setInnerCodeWorkbookRows(t, book, [][]string{ - {"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号"}, - {"DK001", "ORDER-1", "黑色,M", "A#1"}, - {"DK001", "ORDER-2", "白色,L", "B#2"}, + {"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号", "生成日期"}, + {"DK001", "ORDER-1", "黑色,M", "A#1", "2026-08-15"}, + {"DK001", "ORDER-2", "白色,L", "B#2", "2026-08-15"}, }) - _, _, err := parseInnerCodeWorkbook(book, "2026-08-15", "user-1") + _, _, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1") if err == nil || !strings.Contains(err.Error(), "对应多组") { t.Fatalf("期望同日入库码冲突错误,实际 %v", err) } @@ -78,11 +81,11 @@ func TestParseInnerCodeWorkbook_业务键相同但入库码不同时报错(t *te t.Fatal(err) } setInnerCodeWorkbookRows(t, book, [][]string{ - {"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号"}, - {"DK001", "ORDER-1", "黑色,M", "A#1"}, - {"DK002", "ORDER-1", "黑色,M", "A#1"}, + {"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号", "生成日期"}, + {"DK001", "ORDER-1", "黑色,M", "A#1", "2026-08-15"}, + {"DK002", "ORDER-1", "黑色,M", "A#1", "2026-08-15"}, }) - _, _, err := parseInnerCodeWorkbook(book, "2026-08-15", "user-1") + _, _, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1") if err == nil || !strings.Contains(err.Error(), "业务键相同") { t.Fatalf("期望业务键冲突错误,实际 %v", err) } @@ -116,8 +119,8 @@ func TestParseInnerCodeWorkbook_脱敏文件可重开(t *testing.T) { t.Fatal(err) } setInnerCodeWorkbookRows(t, book, [][]string{ - {"内部档口入库码", "Shopee订单编号", "原始产品规格(源Excel D列,原样直拷)", "档口及货号"}, - {"DK-TEST-1", "ORDER-TEST-1", "蓝色,L", "T#001"}, + {"内部档口入库码", "Shopee订单编号", "原始产品规格(源Excel D列,原样直拷)", "档口及货号", "生成日期"}, + {"DK-TEST-1", "ORDER-TEST-1", "蓝色,L", "T#001", "08-15"}, }) if err := book.SaveAs(path); err != nil { t.Fatal(err) @@ -128,12 +131,70 @@ func TestParseInnerCodeWorkbook_脱敏文件可重开(t *testing.T) { t.Fatal(err) } defer reopened.Close() - _, imported, err := parseInnerCodeWorkbook(reopened, "2026-08-15", "user-1") + _, imported, err := parseInnerCodeWorkbook(reopened, "labels_20260815_V3.3.xlsx", "user-1") if err != nil || len(imported) != 1 || imported[0].SpecKey != "蓝色,L" { t.Fatalf("重开解析失败 rows=%+v err=%v", imported, err) } } +func TestParseInnerCodeWorkbook_短日期与文件名不一致时报错(t *testing.T) { + book := newInnerCodeDateTestBook(t, "08-14") + defer book.Close() + _, _, err := parseInnerCodeWorkbook(book, "labels_20260815_V3.3.xlsx", "user-1") + if err == nil || !strings.Contains(err.Error(), "与文件名日期") { + t.Fatalf("期望文件名日期冲突,实际 %v", err) + } +} + +func TestParseInnerCodeWorkbook_混合生成日期整批失败(t *testing.T) { + book := excelize.NewFile() + defaultSheet := book.GetSheetList()[0] + if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil { + t.Fatal(err) + } + setInnerCodeWorkbookRows(t, book, [][]string{ + {"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号", "生成日期"}, + {"DK001", "ORDER-1", "黑色,M", "A#1", "2026-08-14"}, + {"DK002", "ORDER-2", "白色,L", "B#2", "2026-08-15"}, + }) + defer book.Close() + _, _, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1") + if err == nil || !strings.Contains(err.Error(), "多个生成日期") { + t.Fatalf("期望混合日期错误,实际 %v", err) + } +} + +func TestParseInnerCodeWorkbook_缺少生成日期表头时报错(t *testing.T) { + book := excelize.NewFile() + defaultSheet := book.GetSheetList()[0] + if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil { + t.Fatal(err) + } + setInnerCodeWorkbookRows(t, book, [][]string{ + {"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号"}, + {"DK001", "ORDER-1", "黑色,M", "A#1"}, + }) + defer book.Close() + _, _, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1") + if err == nil || !strings.Contains(err.Error(), "生成日期") { + t.Fatalf("期望缺少生成日期表头错误,实际 %v", err) + } +} + +func newInnerCodeDateTestBook(t *testing.T, businessDate string) *excelize.File { + t.Helper() + book := excelize.NewFile() + defaultSheet := book.GetSheetList()[0] + if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil { + t.Fatal(err) + } + setInnerCodeWorkbookRows(t, book, [][]string{ + {"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口及货号", "生成日期"}, + {"DK001", "ORDER-1", "黑色,M", "A#1", businessDate}, + }) + return book +} + func setInnerCodeWorkbookRows(t *testing.T, book *excelize.File, rows [][]string) { t.Helper() for rowIndex, row := range rows { diff --git a/admin/templates/inner_code/list.html b/admin/templates/inner_code/list.html index 6a33de6..26077ca 100644 --- a/admin/templates/inner_code/list.html +++ b/admin/templates/inner_code/list.html @@ -10,7 +10,6 @@