feat: 增加档口入库码单表导入 (#231)

This commit is contained in:
chengma
2026-08-15 08:47:22 +08:00
parent dd8a71850d
commit 5a9c0f0e6c
6 changed files with 733 additions and 2 deletions
+63
View File
@@ -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
}
+84
View File
@@ -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
}
+95 -2
View File
@@ -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 {
+324
View File
@@ -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
}
+150
View File
@@ -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)
}
}
}
}
+17
View File
@@ -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。用户只停用不物理删除,因此外键不会阻塞账号生命周期。