fix: 使用 Excel 生成日期导入档口入库码 (#237)

This commit is contained in:
chengma
2026-08-15 10:38:34 +08:00
parent 3bd2473e8f
commit 74856deff7
4 changed files with 149 additions and 37 deletions
+4 -9
View File
@@ -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 读取远端最新详情并保存只读规划,不执行任何回写。
+68 -11
View File
@@ -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
+77 -16
View File
@@ -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 {
-1
View File
@@ -10,7 +10,6 @@
<form class="inline" method="post" action="/inner-codes/import" enctype="multipart/form-data"
data-upload-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="date" value="{{.BusinessDate}}" data-inner-code-date-field>
<button type="button" data-inner-code-file-open>选择 Excel</button>
<input id="inner-code-file" type="file" name="file" accept=".xlsx" required data-file-input hidden>
<span class="file-field" data-file-name>未选择文件</span>