From 5a9c0f0e6cc6ba3e9b779e8921775fb85f7a2513 Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 15 Aug 2026 08:47:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=A1=A3=E5=8F=A3?= =?UTF-8?q?=E5=85=A5=E5=BA=93=E7=A0=81=E5=8D=95=E8=A1=A8=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=20(#231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/model/inner_code.go | 63 +++++ admin/repository/inner_code.go | 84 ++++++ admin/repository/mysql_db.go | 97 ++++++- admin/service/inner_code_import.go | 324 ++++++++++++++++++++++++ admin/service/inner_code_import_test.go | 150 +++++++++++ docs/admin/03-data-model.md | 17 ++ 6 files changed, 733 insertions(+), 2 deletions(-) create mode 100644 admin/model/inner_code.go create mode 100644 admin/repository/inner_code.go create mode 100644 admin/service/inner_code_import.go create mode 100644 admin/service/inner_code_import_test.go diff --git a/admin/model/inner_code.go b/admin/model/inner_code.go new file mode 100644 index 0000000..bc7e740 --- /dev/null +++ b/admin/model/inner_code.go @@ -0,0 +1,63 @@ +package model + +// InnerCodeStatus 是档口入库码从导入到远端核验的单表状态。 +type InnerCodeStatus string + +const ( + InnerCodePending InnerCodeStatus = "pending" + InnerCodeReady InnerCodeStatus = "ready" + InnerCodeApplying InnerCodeStatus = "applying" + InnerCodeUpdated InnerCodeStatus = "updated" + InnerCodeAlreadyFilled InnerCodeStatus = "already_filled" + InnerCodeSkipped InnerCodeStatus = "skipped" + InnerCodeFailed InnerCodeStatus = "failed" + InnerCodeNeedsCheck InnerCodeStatus = "needs_check" +) + +// InnerCodeRecord 保存一张线下标签在导入、匹配和回写过程中的全部必要信息。 +// Excel 只是载体,原文件不会进入数据库。 +type InnerCodeRecord struct { + ID int64 + BusinessDate string + SourceRow int + PrintSequence int + OrderNumber string + ShopName string + Stall string + SpecRaw string + SpecKey string + InnerCode string + SourceDuplicateCount int + StockID int64 + DetailID int64 + SybSpec string + SybSKU string + SybVariationSKU string + PurchasePlatform string + PurchaseCode string + RemoteInnerCode string + Status InnerCodeStatus + ResultMessage string + CreatedByUserID string + AppliedByUserID string + PlannedAt string + ApplyStartedAt string + AppliedAt string + CreatedAt string + UpdatedAt string +} + +// InnerCodeImportRow 是通过校验后交给 repository 幂等写入的一行。 +type InnerCodeImportRow struct { + BusinessDate string + SourceRow int + PrintSequence int + OrderNumber string + ShopName string + Stall string + SpecRaw string + SpecKey string + InnerCode string + SourceDuplicateCount int + CreatedByUserID string +} diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go new file mode 100644 index 0000000..2a571fd --- /dev/null +++ b/admin/repository/inner_code.go @@ -0,0 +1,84 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + + "cmautobuy/admin/model" +) + +// InnerCodeImportOutcome 说明幂等导入是新增还是更新。 +type InnerCodeImportOutcome string + +const ( + InnerCodeImportCreated InnerCodeImportOutcome = "created" + InnerCodeImportUpdated InnerCodeImportOutcome = "updated" +) + +// UpsertInnerCodeImportRow 按已确认业务键写入一行。 +// 必须在事务中调用;先锁定业务键,避免另一个唯一键冲突时更新错行。 +func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now string) (InnerCodeImportOutcome, error) { + var id int64 + err := tx.QueryRow(` + SELECT id FROM syb_inner_code_records + WHERE business_date=? AND order_number=? AND stall=? AND spec_key=? + FOR UPDATE`, row.BusinessDate, row.OrderNumber, row.Stall, row.SpecKey).Scan(&id) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("锁定档口入库码业务键失败: %w", err) + } + if errors.Is(err, sql.ErrNoRows) { + _, err = tx.Exec(` + INSERT INTO syb_inner_code_records ( + business_date,source_row,print_sequence,order_number,shop_name,stall, + spec_raw,spec_key,inner_code,source_duplicate_count,status, + created_by_user_id,created_at,updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',?,?,?)`, + row.BusinessDate, row.SourceRow, nullablePositiveInt(row.PrintSequence), row.OrderNumber, + nullableString(row.ShopName), row.Stall, row.SpecRaw, row.SpecKey, row.InnerCode, + row.SourceDuplicateCount, row.CreatedByUserID, now, now) + if err != nil { + return "", fmt.Errorf("新增档口入库码记录失败: %w", err) + } + return InnerCodeImportCreated, nil + } + + _, err = tx.Exec(` + UPDATE syb_inner_code_records + SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,inner_code=?, + source_duplicate_count=?, + status=CASE WHEN status IN ('updated','already_filled','applying','needs_check') + THEN status ELSE 'pending' END, + stock_id=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN stock_id ELSE NULL END, + detail_id=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN detail_id ELSE NULL END, + syb_spec=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN syb_spec ELSE NULL END, + syb_sku=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN syb_sku ELSE NULL END, + syb_variation_sku=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN syb_variation_sku ELSE NULL END, + purchase_platform=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN purchase_platform ELSE NULL END, + purchase_code=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN purchase_code ELSE NULL END, + remote_inner_code=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN remote_inner_code ELSE NULL END, + result_message=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN result_message ELSE NULL END, + planned_at=CASE WHEN status IN ('updated','already_filled','applying','needs_check') THEN planned_at ELSE NULL END, + updated_at=? + WHERE id=?`, + row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName), row.SpecRaw, + row.InnerCode, row.SourceDuplicateCount, now, id) + if err != nil { + return "", fmt.Errorf("更新档口入库码导入记录失败: %w", err) + } + return InnerCodeImportUpdated, nil +} + +func nullablePositiveInt(value int) any { + if value <= 0 { + return nil + } + return value +} + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 5248d70..1f1473f 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -20,7 +20,7 @@ import ( "cmautobuy/admin/spec" ) -const mysqlSchemaVersion = 22 +const mysqlSchemaVersion = 23 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { @@ -688,10 +688,68 @@ func MigrateMySQL(db *sql.DB) error { if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 22, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { return fmt.Errorf("记录 MySQL schema v22 失败: %w", err) } + current = 22 + } + if current < 23 { + if err := migrateMySQLV23(db); err != nil { + return fmt.Errorf("执行 MySQL schema v23 失败: %w", err) + } + if err := checkMySQLV23Shape(db); err != nil { + return fmt.Errorf("MySQL schema v23 自检失败,未记录版本: %w", err) + } + if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 23, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("记录 MySQL schema v23 失败: %w", err) + } } return CheckMySQLSchema(db) } +// migrateMySQLV23 建立档口入库码最小闭环使用的唯一业务表。 +// Excel 原文件、批次和每次尝试不会另建表保存。 +func migrateMySQLV23(db *sql.DB) error { + _, err := db.Exec(`CREATE TABLE IF NOT EXISTS syb_inner_code_records ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + business_date DATE NOT NULL, + source_row INT NOT NULL, + print_sequence INT NULL, + order_number VARCHAR(64) COLLATE utf8mb4_bin NOT NULL, + shop_name VARCHAR(191) NULL, + stall VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + spec_raw VARCHAR(500) NOT NULL, + spec_key VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + inner_code VARCHAR(128) COLLATE utf8mb4_bin NOT NULL, + source_duplicate_count INT NOT NULL DEFAULT 1, + stock_id BIGINT NULL, + detail_id BIGINT NULL, + syb_spec VARCHAR(500) NULL, + syb_sku VARCHAR(191) NULL, + syb_variation_sku VARCHAR(191) NULL, + purchase_platform VARCHAR(191) NULL, + purchase_code VARCHAR(191) NULL, + remote_inner_code VARCHAR(128) NULL, + status VARCHAR(32) COLLATE utf8mb4_bin NOT NULL DEFAULT 'pending', + result_message VARCHAR(500) NULL, + created_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NOT NULL, + applied_by_user_id VARCHAR(191) COLLATE utf8mb4_bin NULL, + planned_at VARCHAR(35) NULL, + apply_started_at VARCHAR(35) NULL, + applied_at VARCHAR(35) NULL, + created_at VARCHAR(35) NOT NULL, + updated_at VARCHAR(35) NOT NULL, + UNIQUE KEY uq_inner_code_business (business_date,order_number,stall,spec_key), + UNIQUE KEY uq_inner_code_date_code (business_date,inner_code), + KEY idx_inner_code_list (business_date,status,id DESC), + KEY idx_inner_code_order (order_number,business_date,id), + KEY idx_inner_code_detail (detail_id,status,id), + CONSTRAINT fk_inner_code_created_by FOREIGN KEY (created_by_user_id) REFERENCES users(user_id), + CONSTRAINT fk_inner_code_applied_by FOREIGN KEY (applied_by_user_id) REFERENCES users(user_id), + CONSTRAINT chk_inner_code_status CHECK (status IN ('pending','ready','applying','updated','already_filled','skipped','failed','needs_check')), + CONSTRAINT chk_inner_code_source_row CHECK (source_row > 0), + CONSTRAINT chk_inner_code_duplicate_count CHECK (source_duplicate_count > 0) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`) + return err +} + func migrateMySQLV22(db *sql.DB) error { statements := []string{ `CREATE TABLE IF NOT EXISTS ai_match_batches ( @@ -2071,7 +2129,42 @@ func CheckMySQLSchema(db *sql.DB) error { if err := checkMySQLV21Shape(db); err != nil { return err } - return checkMySQLV22Shape(db) + if err := checkMySQLV22Shape(db); err != nil { + return err + } + return checkMySQLV23Shape(db) +} + +func checkMySQLV23Shape(db *sql.DB) error { + if err := checkMySQLSchema(db, []string{"syb_inner_code_records"}); err != nil { + return err + } + for _, name := range []string{"business_date", "order_number", "stall", "spec_key", "inner_code", + "source_duplicate_count", "stock_id", "detail_id", "status", "created_by_user_id", "applied_by_user_id"} { + exists, err := mysqlColumnExists(db, "syb_inner_code_records", name) + if err != nil || !exists { + return fmt.Errorf("档口入库码字段 %s 缺失: %v", name, err) + } + } + for _, item := range []struct{ kind, name string }{ + {"index", "uq_inner_code_business"}, + {"index", "uq_inner_code_date_code"}, + {"index", "idx_inner_code_list"}, + {"constraint", "chk_inner_code_status"}, + {"constraint", "fk_inner_code_created_by"}, + } { + var exists bool + var err error + if item.kind == "index" { + exists, err = mysqlIndexExists(db, "syb_inner_code_records", item.name) + } else { + exists, err = mysqlConstraintExists(db, "syb_inner_code_records", item.name) + } + if err != nil || !exists { + return fmt.Errorf("档口入库码%s %s 缺失: %v", item.kind, item.name, err) + } + } + return nil } func checkMySQLV22Shape(db *sql.DB) error { diff --git a/admin/service/inner_code_import.go b/admin/service/inner_code_import.go new file mode 100644 index 0000000..9e3cf0a --- /dev/null +++ b/admin/service/inner_code_import.go @@ -0,0 +1,324 @@ +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 { + 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 +} diff --git a/admin/service/inner_code_import_test.go b/admin/service/inner_code_import_test.go new file mode 100644 index 0000000..7c9ad5b --- /dev/null +++ b/admin/service/inner_code_import_test.go @@ -0,0 +1,150 @@ +package service + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestNormalizeInnerCodeSpecKey(t *testing.T) { + tests := map[string]string{ + "A-027 黑色,M碼 建議45-55公斤": "黑色,M", + "杏色(预售), 2xl【备注】": "杏色,2XL", + "白 色,均码": "白色,均码", + "无逗号 规格": "无逗号规格", + "": "", + } + for input, want := range tests { + if got := NormalizeInnerCodeSpecKey(input); got != want { + t.Errorf("NormalizeInnerCodeSpecKey(%q)=%q,期望 %q", input, got, want) + } + } +} + +func TestParseInnerCodeWorkbook_表头前置行与重复业务组(t *testing.T) { + book := excelize.NewFile() + defaultSheet := book.GetSheetList()[0] + if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil { + t.Fatal(err) + } + 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"}, + } + setInnerCodeWorkbookRows(t, book, rows) + + result, imported, err := parseInnerCodeWorkbook(book, "2026-08-15", "user-1") + if err != nil { + t.Fatal(err) + } + if result.TotalRows != 3 || result.DuplicateRows != 1 || len(imported) != 2 { + t.Fatalf("统计不正确: result=%+v rows=%d", result, len(imported)) + } + first := imported[0] + if first.SpecKey != "黑色,M" || first.Stall != "A档#027" || first.SourceDuplicateCount != 2 { + t.Fatalf("首行业务字段不正确: %+v", first) + } + if first.SourceRow != 3 || first.PrintSequence != 1 || first.ShopName != "shop-a" { + t.Fatalf("首行来源字段不正确: %+v", first) + } +} + +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"}, + {"DK001", "ORDER-2", "白色,L", "B#2"}, + }) + _, _, err := parseInnerCodeWorkbook(book, "2026-08-15", "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"}, + {"DK002", "ORDER-1", "黑色,M", "A#1"}, + }) + _, _, err := parseInnerCodeWorkbook(book, "2026-08-15", "user-1") + if err == nil || !strings.Contains(err.Error(), "业务键相同") { + t.Fatalf("期望业务键冲突错误,实际 %v", err) + } +} + +func TestValidateInnerCodeUpload(t *testing.T) { + if err := ValidateInnerCodeUpload("labels.xlsx", 100, []byte{'P', 'K', 3, 4}); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + size int64 + head []byte + }{ + {"labels.xls", 100, []byte{'P', 'K', 3, 4}}, + {"labels.xlsx", 0, []byte{'P', 'K', 3, 4}}, + {"labels.xlsx", MaxInnerCodeUploadBytes + 1, []byte{'P', 'K', 3, 4}}, + {"labels.xlsx", 100, []byte("text")}, + } { + if err := ValidateInnerCodeUpload(tc.name, tc.size, tc.head); err == nil { + t.Errorf("%+v 应被拒绝", tc) + } + } +} + +func TestParseInnerCodeWorkbook_脱敏文件可重开(t *testing.T) { + path := filepath.Join(t.TempDir(), "inner-code.xlsx") + book := excelize.NewFile() + defaultSheet := book.GetSheetList()[0] + if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil { + t.Fatal(err) + } + setInnerCodeWorkbookRows(t, book, [][]string{ + {"内部档口入库码", "Shopee订单编号", "原始产品规格(源Excel D列,原样直拷)", "档口及货号"}, + {"DK-TEST-1", "ORDER-TEST-1", "蓝色,L", "T#001"}, + }) + if err := book.SaveAs(path); err != nil { + t.Fatal(err) + } + book.Close() + reopened, err := excelize.OpenFile(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + _, imported, err := parseInnerCodeWorkbook(reopened, "2026-08-15", "user-1") + if err != nil || len(imported) != 1 || imported[0].SpecKey != "蓝色,L" { + t.Fatalf("重开解析失败 rows=%+v err=%v", imported, err) + } +} + +func setInnerCodeWorkbookRows(t *testing.T, book *excelize.File, rows [][]string) { + t.Helper() + for rowIndex, row := range rows { + for columnIndex, value := range row { + cell, err := excelize.CoordinatesToCellName(columnIndex+1, rowIndex+1) + if err != nil { + t.Fatal(err) + } + if err := book.SetCellValue(innerCodeSheetName, cell, value); err != nil { + t.Fatal(err) + } + } + } +} diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 660b012..5018262 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -1021,3 +1021,20 @@ CREATE UNIQUE INDEX idx_client_assignment_current 批次记录用于进度、故障恢复和操作审计;具体模型决策证据仍只追加到 `ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经 成功写入的 `spec_mappings` 不回滚。 + +## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23) + +档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统 +不保存原文件、文件哈希,不再拆批次表或尝试记录表。 + +- 业务键为 `(business_date, order_number, stall, spec_key)`;同一业务日期的 + `inner_code` 另设唯一约束,防止一张标签被分配给两个商品。 +- `spec_raw` 永远保留 Excel 原文,`spec_key` 只用于确定性匹配; + `source_duplicate_count > 1` 表示同一文件存在重复业务组,匹配时整组跳过。 +- `stock_id/detail_id` 和 `syb_*`、`purchase_*`、`remote_inner_code` 保存最近一次远端 + 规划快照,不替代 `syb_orders`,也不修改采购数据。 +- 状态固定为 `pending/ready/applying/updated/already_filled/skipped/failed/needs_check`。 + 重复导入可以重置尚未完成的记录,但不得覆盖 `updated`、`already_filled`、`applying` + 或 `needs_check` 的远端结果和审计状态。 +- `created_by_user_id` 记录首次导入账号,`applied_by_user_id` 记录实际回写账号;时间字段 + 使用 UTC ISO 8601。用户只停用不物理删除,因此外键不会阻塞账号生命周期。