fix: 使用 Excel 生成日期导入档口入库码 (#237)
This commit is contained in:
@@ -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 读取远端最新详情并保存只读规划,不执行任何回写。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user