feat: 增加档口入库码单表导入 (#231)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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。用户只停用不物理删除,因此外键不会阻塞账号生命周期。
|
||||
|
||||
Reference in New Issue
Block a user