fix: 使用原始SKU辅助档口匹配 (#259)
This commit is contained in:
@@ -26,6 +26,7 @@ type InnerCodeRecord struct {
|
|||||||
OrderNumber string
|
OrderNumber string
|
||||||
ShopName string
|
ShopName string
|
||||||
Stall string
|
Stall string
|
||||||
|
SourceSKURaw string
|
||||||
SpecRaw string
|
SpecRaw string
|
||||||
SpecKey string
|
SpecKey string
|
||||||
InnerCode string
|
InnerCode string
|
||||||
@@ -62,6 +63,7 @@ type InnerCodeImportRow struct {
|
|||||||
OrderNumber string
|
OrderNumber string
|
||||||
ShopName string
|
ShopName string
|
||||||
Stall string
|
Stall string
|
||||||
|
SourceSKURaw string
|
||||||
SpecRaw string
|
SpecRaw string
|
||||||
SpecKey string
|
SpecKey string
|
||||||
InnerCode string
|
InnerCode string
|
||||||
|
|||||||
@@ -128,12 +128,12 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
|||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
_, err = tx.Exec(`
|
_, err = tx.Exec(`
|
||||||
INSERT INTO syb_inner_code_records (
|
INSERT INTO syb_inner_code_records (
|
||||||
business_date,source_row,print_sequence,order_number,shop_name,stall,
|
business_date,source_row,print_sequence,order_number,shop_name,stall,source_sku_raw,
|
||||||
spec_raw,spec_key,inner_code,source_duplicate_count,status,
|
spec_raw,spec_key,inner_code,source_duplicate_count,status,
|
||||||
created_by_user_id,created_at,updated_at
|
created_by_user_id,created_at,updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',?,?,?)`,
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'pending',?,?,?)`,
|
||||||
row.BusinessDate, row.SourceRow, nullablePositiveInt(row.PrintSequence), row.OrderNumber,
|
row.BusinessDate, row.SourceRow, nullablePositiveInt(row.PrintSequence), row.OrderNumber,
|
||||||
nullableString(row.ShopName), row.Stall, row.SpecRaw, row.SpecKey, row.InnerCode,
|
nullableString(row.ShopName), row.Stall, nullableStringPreserveSpace(row.SourceSKURaw), row.SpecRaw, row.SpecKey, row.InnerCode,
|
||||||
row.SourceDuplicateCount, row.CreatedByUserID, now, now)
|
row.SourceDuplicateCount, row.CreatedByUserID, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", innerCodeImportWriteError("新增档口入库码记录失败", err)
|
return "", innerCodeImportWriteError("新增档口入库码记录失败", err)
|
||||||
@@ -147,20 +147,20 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
|||||||
ErrInnerCodeRestoreConflict, id, currentInnerCode, row.InnerCode)
|
ErrInnerCodeRestoreConflict, id, currentInnerCode, row.InnerCode)
|
||||||
}
|
}
|
||||||
_, err = tx.Exec(`UPDATE syb_inner_code_records
|
_, err = tx.Exec(`UPDATE syb_inner_code_records
|
||||||
SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,source_duplicate_count=?,
|
SET source_row=?,print_sequence=?,shop_name=?,source_sku_raw=?,spec_raw=?,source_duplicate_count=?,
|
||||||
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
|
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
|
||||||
WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName),
|
WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName),
|
||||||
row.SpecRaw, row.SourceDuplicateCount, now, id)
|
nullableStringPreserveSpace(row.SourceSKURaw), row.SpecRaw, row.SourceDuplicateCount, now, id)
|
||||||
} else {
|
} else {
|
||||||
_, err = tx.Exec(`UPDATE syb_inner_code_records
|
_, err = tx.Exec(`UPDATE syb_inner_code_records
|
||||||
SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,inner_code=?,source_duplicate_count=?,
|
SET source_row=?,print_sequence=?,shop_name=?,source_sku_raw=?,spec_raw=?,inner_code=?,source_duplicate_count=?,
|
||||||
status='pending',stock_id=NULL,detail_id=NULL,syb_spec=NULL,syb_sku=NULL,
|
status='pending',stock_id=NULL,detail_id=NULL,syb_spec=NULL,syb_sku=NULL,
|
||||||
syb_variation_sku=NULL,purchase_platform=NULL,purchase_code=NULL,
|
syb_variation_sku=NULL,purchase_platform=NULL,purchase_code=NULL,
|
||||||
remote_inner_code=NULL,remote_items_json=NULL,result_message=NULL,planned_at=NULL,
|
remote_inner_code=NULL,remote_items_json=NULL,result_message=NULL,planned_at=NULL,
|
||||||
apply_batch_id=NULL,apply_queued_at=NULL,apply_started_at=NULL,
|
apply_batch_id=NULL,apply_queued_at=NULL,apply_started_at=NULL,
|
||||||
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
|
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
|
||||||
WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName),
|
WHERE id=?`, row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName),
|
||||||
row.SpecRaw, row.InnerCode, row.SourceDuplicateCount, now, id)
|
nullableStringPreserveSpace(row.SourceSKURaw), row.SpecRaw, row.InnerCode, row.SourceDuplicateCount, now, id)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", innerCodeImportWriteError("恢复档口入库码导入记录失败", err)
|
return "", innerCodeImportWriteError("恢复档口入库码导入记录失败", err)
|
||||||
@@ -170,7 +170,7 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
|||||||
|
|
||||||
_, err = tx.Exec(`
|
_, err = tx.Exec(`
|
||||||
UPDATE syb_inner_code_records
|
UPDATE syb_inner_code_records
|
||||||
SET source_row=?,print_sequence=?,shop_name=?,spec_raw=?,inner_code=?,
|
SET source_row=?,print_sequence=?,shop_name=?,source_sku_raw=?,spec_raw=?,inner_code=?,
|
||||||
source_duplicate_count=?,
|
source_duplicate_count=?,
|
||||||
status=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check')
|
status=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check')
|
||||||
THEN status ELSE 'pending' END,
|
THEN status ELSE 'pending' END,
|
||||||
@@ -190,7 +190,7 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
|||||||
apply_started_at=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN apply_started_at ELSE NULL END,
|
apply_started_at=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check') THEN apply_started_at ELSE NULL END,
|
||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName), row.SpecRaw,
|
row.SourceRow, nullablePositiveInt(row.PrintSequence), nullableString(row.ShopName), nullableStringPreserveSpace(row.SourceSKURaw), row.SpecRaw,
|
||||||
row.InnerCode, row.SourceDuplicateCount, now, id)
|
row.InnerCode, row.SourceDuplicateCount, now, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", innerCodeImportWriteError("更新档口入库码导入记录失败", err)
|
return "", innerCodeImportWriteError("更新档口入库码导入记录失败", err)
|
||||||
@@ -402,7 +402,7 @@ func innerCodeStatusHoldsDetail(status model.InnerCodeStatus) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const innerCodeListColumns = `id,business_date,source_row,COALESCE(print_sequence,0),order_number,
|
const innerCodeListColumns = `id,business_date,source_row,COALESCE(print_sequence,0),order_number,
|
||||||
COALESCE(shop_name,''),stall,spec_raw,spec_key,inner_code,source_duplicate_count,
|
COALESCE(shop_name,''),stall,COALESCE(source_sku_raw,''),spec_raw,spec_key,inner_code,source_duplicate_count,
|
||||||
COALESCE(apply_batch_id,''),COALESCE(apply_queued_at,''),
|
COALESCE(apply_batch_id,''),COALESCE(apply_queued_at,''),
|
||||||
COALESCE(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''),
|
COALESCE(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''),
|
||||||
COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
|
COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
|
||||||
@@ -482,7 +482,7 @@ type innerCodeRowScanner interface {
|
|||||||
func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, error) {
|
func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, error) {
|
||||||
var row model.InnerCodeRecord
|
var row model.InnerCodeRecord
|
||||||
err := scanner.Scan(&row.ID, &row.BusinessDate, &row.SourceRow, &row.PrintSequence,
|
err := scanner.Scan(&row.ID, &row.BusinessDate, &row.SourceRow, &row.PrintSequence,
|
||||||
&row.OrderNumber, &row.ShopName, &row.Stall, &row.SpecRaw, &row.SpecKey,
|
&row.OrderNumber, &row.ShopName, &row.Stall, &row.SourceSKURaw, &row.SpecRaw, &row.SpecKey,
|
||||||
&row.InnerCode, &row.SourceDuplicateCount, &row.ApplyBatchID, &row.ApplyQueuedAt,
|
&row.InnerCode, &row.SourceDuplicateCount, &row.ApplyBatchID, &row.ApplyQueuedAt,
|
||||||
&row.StockID, &row.DetailID,
|
&row.StockID, &row.DetailID,
|
||||||
&row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
|
&row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
|
||||||
@@ -780,3 +780,10 @@ func nullableString(value string) any {
|
|||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullableStringPreserveSpace(value string) any {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"cmautobuy/admin/spec"
|
"cmautobuy/admin/spec"
|
||||||
)
|
)
|
||||||
|
|
||||||
const mysqlSchemaVersion = 27
|
const mysqlSchemaVersion = 28
|
||||||
|
|
||||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||||
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
|
||||||
@@ -748,9 +748,34 @@ func MigrateMySQL(db *sql.DB) error {
|
|||||||
return fmt.Errorf("记录 MySQL schema v27 失败: %w", err)
|
return fmt.Errorf("记录 MySQL schema v27 失败: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if current < 28 {
|
||||||
|
if err := migrateMySQLV28(db); err != nil {
|
||||||
|
return fmt.Errorf("执行 MySQL schema v28 失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := checkMySQLV28Shape(db); err != nil {
|
||||||
|
return fmt.Errorf("MySQL schema v28 自检失败,未记录版本: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 28, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
return fmt.Errorf("记录 MySQL schema v28 失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return CheckMySQLSchema(db)
|
return CheckMySQLSchema(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrateMySQLV28 保留 Excel 原始 SKU,作为确定性匹配的可审计证据。
|
||||||
|
func migrateMySQLV28(db *sql.DB) error {
|
||||||
|
exists, err := mysqlColumnExists(db, "syb_inner_code_records", "source_sku_raw")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`ALTER TABLE syb_inner_code_records
|
||||||
|
ADD COLUMN source_sku_raw VARCHAR(500) COLLATE utf8mb4_bin NULL AFTER stall`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// migrateMySQLV27 保存多件档口入库码逐件远端检查点,不拆分新的业务表。
|
// migrateMySQLV27 保存多件档口入库码逐件远端检查点,不拆分新的业务表。
|
||||||
func migrateMySQLV27(db *sql.DB) error {
|
func migrateMySQLV27(db *sql.DB) error {
|
||||||
exists, err := mysqlColumnExists(db, "syb_inner_code_records", "remote_items_json")
|
exists, err := mysqlColumnExists(db, "syb_inner_code_records", "remote_items_json")
|
||||||
@@ -2348,7 +2373,14 @@ func CheckMySQLSchema(db *sql.DB) error {
|
|||||||
if err := checkMySQLV26Shape(db); err != nil {
|
if err := checkMySQLV26Shape(db); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return checkMySQLV27Shape(db)
|
if err := checkMySQLV27Shape(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkMySQLV28Shape(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkMySQLV28Shape(db *sql.DB) error {
|
||||||
|
return checkMySQLVarcharColumn(db, "syb_inner_code_records", "source_sku_raw", 500, true, "utf8mb4_bin", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkMySQLV27Shape(db *sql.DB) error {
|
func checkMySQLV27Shape(db *sql.DB) error {
|
||||||
|
|||||||
@@ -1286,7 +1286,7 @@ func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
failedRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 8, OrderNumber: "ORDER-F",
|
failedRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 8, OrderNumber: "ORDER-F",
|
||||||
Stall: "A#1", SpecRaw: "黑色,M", SpecKey: "黑色,M", InnerCode: "DK-F-NEW",
|
Stall: "A#1", SourceSKURaw: " SOURCE-F ", SpecRaw: "黑色,M", SpecKey: "黑色,M", InnerCode: "DK-F-NEW",
|
||||||
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
|
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
|
||||||
tx, err := db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1302,17 +1302,17 @@ func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testin
|
|||||||
}
|
}
|
||||||
var status, innerCode string
|
var status, innerCode string
|
||||||
var stockID sql.NullInt64
|
var stockID sql.NullInt64
|
||||||
var deletedAt, message sql.NullString
|
var deletedAt, message, sourceSKU sql.NullString
|
||||||
if err := db.QueryRow(`SELECT status,inner_code,stock_id,deleted_at,result_message FROM syb_inner_code_records WHERE order_number='ORDER-F'`).
|
if err := db.QueryRow(`SELECT status,inner_code,stock_id,deleted_at,result_message,source_sku_raw FROM syb_inner_code_records WHERE order_number='ORDER-F'`).
|
||||||
Scan(&status, &innerCode, &stockID, &deletedAt, &message); err != nil {
|
Scan(&status, &innerCode, &stockID, &deletedAt, &message, &sourceSKU); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if status != "pending" || innerCode != "DK-F-NEW" || stockID.Valid || deletedAt.Valid || message.Valid {
|
if status != "pending" || innerCode != "DK-F-NEW" || stockID.Valid || deletedAt.Valid || message.Valid || sourceSKU.String != " SOURCE-F " {
|
||||||
t.Fatalf("未完成记录恢复后未重置: status=%s code=%s stock=%+v deleted=%+v message=%+v", status, innerCode, stockID, deletedAt, message)
|
t.Fatalf("未完成记录恢复后未重置: status=%s code=%s stock=%+v deleted=%+v message=%+v", status, innerCode, stockID, deletedAt, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
updatedRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 9, OrderNumber: "ORDER-U",
|
updatedRow := model.InnerCodeImportRow{BusinessDate: "2026-08-15", SourceRow: 9, OrderNumber: "ORDER-U",
|
||||||
Stall: "B#2", SpecRaw: "白色,L", SpecKey: "白色,L", InnerCode: "DK-U",
|
Stall: "B#2", SourceSKURaw: "SOURCE-U", SpecRaw: "白色,L", SpecKey: "白色,L", InnerCode: "DK-U",
|
||||||
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
|
SourceDuplicateCount: 1, CreatedByUserID: "restore-user"}
|
||||||
tx, err = db.Begin()
|
tx, err = db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1326,11 +1326,11 @@ func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testin
|
|||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := db.QueryRow(`SELECT status,stock_id,deleted_at,result_message FROM syb_inner_code_records WHERE order_number='ORDER-U'`).
|
if err := db.QueryRow(`SELECT status,stock_id,deleted_at,result_message,source_sku_raw FROM syb_inner_code_records WHERE order_number='ORDER-U'`).
|
||||||
Scan(&status, &stockID, &deletedAt, &message); err != nil {
|
Scan(&status, &stockID, &deletedAt, &message, &sourceSKU); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if status != "updated" || !stockID.Valid || deletedAt.Valid || !message.Valid || message.String != "旧审计" {
|
if status != "updated" || !stockID.Valid || deletedAt.Valid || !message.Valid || message.String != "旧审计" || sourceSKU.String != "SOURCE-U" {
|
||||||
t.Fatalf("已完成记录恢复后未保留审计: status=%s stock=%+v deleted=%+v message=%+v", status, stockID, deletedAt, message)
|
t.Fatalf("已完成记录恢复后未保留审计: status=%s stock=%+v deleted=%+v message=%+v", status, stockID, deletedAt, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
MaxInnerCodeUploadBytes = 10 * 1024 * 1024
|
MaxInnerCodeUploadBytes = 10 * 1024 * 1024
|
||||||
MaxInnerCodeImportRows = 5000
|
MaxInnerCodeImportRows = 5000
|
||||||
MaxInnerCodeValueRunes = 128
|
MaxInnerCodeValueRunes = 128
|
||||||
|
MaxInnerCodeSourceSKURunes = 500
|
||||||
innerCodeSheetName = "标签入库码映射"
|
innerCodeSheetName = "标签入库码映射"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -270,6 +271,7 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
|||||||
specRaw := strings.ReplaceAll(innerCodeCell(columns, specColumn), "\r", " ")
|
specRaw := strings.ReplaceAll(innerCodeCell(columns, specColumn), "\r", " ")
|
||||||
specRaw = strings.TrimSpace(strings.ReplaceAll(specRaw, "\n", " "))
|
specRaw = strings.TrimSpace(strings.ReplaceAll(specRaw, "\n", " "))
|
||||||
stall := innerCodeStall(columns, headers)
|
stall := innerCodeStall(columns, headers)
|
||||||
|
sourceSKURaw := innerCodeRawNamedCell(columns, headers, "原始SKU")
|
||||||
shop := innerCodeNamedCell(columns, headers, "店铺名称")
|
shop := innerCodeNamedCell(columns, headers, "店铺名称")
|
||||||
printSequence := 0
|
printSequence := 0
|
||||||
if raw := innerCodeNamedCell(columns, headers, "标签打印序号"); raw != "" {
|
if raw := innerCodeNamedCell(columns, headers, "标签打印序号"); raw != "" {
|
||||||
@@ -277,7 +279,7 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
|||||||
printSequence = value
|
printSequence = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := validateInnerCodeFields(rowNumber, orderNumber, shop, stall, specRaw, innerCode); err != nil {
|
if err := validateInnerCodeFields(rowNumber, orderNumber, shop, stall, sourceSKURaw, specRaw, innerCode); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
specKey := NormalizeInnerCodeSpecKey(specRaw)
|
specKey := NormalizeInnerCodeSpecKey(specRaw)
|
||||||
@@ -285,6 +287,7 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
|||||||
parsed = append(parsed, innerCodeParsedRow{InnerCodeImportRow: model.InnerCodeImportRow{
|
parsed = append(parsed, innerCodeParsedRow{InnerCodeImportRow: model.InnerCodeImportRow{
|
||||||
BusinessDate: businessDate, SourceRow: rowNumber, PrintSequence: printSequence,
|
BusinessDate: businessDate, SourceRow: rowNumber, PrintSequence: printSequence,
|
||||||
OrderNumber: orderNumber, ShopName: shop, Stall: stall, SpecRaw: specRaw,
|
OrderNumber: orderNumber, ShopName: shop, Stall: stall, SpecRaw: specRaw,
|
||||||
|
SourceSKURaw: sourceSKURaw,
|
||||||
SpecKey: specKey, InnerCode: innerCode, SourceDuplicateCount: 1,
|
SpecKey: specKey, InnerCode: innerCode, SourceDuplicateCount: 1,
|
||||||
CreatedByUserID: actorUserID,
|
CreatedByUserID: actorUserID,
|
||||||
}, businessKey: key})
|
}, businessKey: key})
|
||||||
@@ -318,6 +321,20 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
|||||||
seen[original.businessKey] = true
|
seen[original.businessKey] = true
|
||||||
group := groups[original.businessKey]
|
group := groups[original.businessKey]
|
||||||
row := original.InnerCodeImportRow
|
row := original.InnerCodeImportRow
|
||||||
|
var sourceSKUCompare string
|
||||||
|
for _, member := range group {
|
||||||
|
candidate := normalizeInnerCodeSourceSKU(member.SourceSKURaw)
|
||||||
|
if candidate == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sourceSKUCompare != "" && candidate != sourceSKUCompare {
|
||||||
|
return nil, nil, fmt.Errorf("第 %d 行起的同业务键包含不同原始 SKU,请拆分或修正源数据", row.SourceRow)
|
||||||
|
}
|
||||||
|
if sourceSKUCompare == "" {
|
||||||
|
sourceSKUCompare = candidate
|
||||||
|
row.SourceSKURaw = member.SourceSKURaw
|
||||||
|
}
|
||||||
|
}
|
||||||
codes := make([]string, 0, len(group))
|
codes := make([]string, 0, len(group))
|
||||||
seenCodes := make(map[string]bool, len(group))
|
seenCodes := make(map[string]bool, len(group))
|
||||||
for _, member := range group {
|
for _, member := range group {
|
||||||
@@ -389,6 +406,10 @@ func innerCodeHeaderMap(columns []string) (map[string]int, int) {
|
|||||||
for index, raw := range columns {
|
for index, raw := range columns {
|
||||||
name := strings.TrimSpace(raw)
|
name := strings.TrimSpace(raw)
|
||||||
result[name] = index
|
result[name] = index
|
||||||
|
compactName := strings.ToLower(innerCodeSpaces.ReplaceAllString(name, ""))
|
||||||
|
if strings.HasPrefix(compactName, "原始sku") {
|
||||||
|
result["原始SKU"] = index
|
||||||
|
}
|
||||||
if name == "清洗后规格" || (specColumn < 0 && strings.HasPrefix(name, "原始产品规格")) {
|
if name == "清洗后规格" || (specColumn < 0 && strings.HasPrefix(name, "原始产品规格")) {
|
||||||
specColumn = index
|
specColumn = index
|
||||||
}
|
}
|
||||||
@@ -416,6 +437,18 @@ func innerCodeNamedCell(columns []string, headers map[string]int, name string) s
|
|||||||
return innerCodeCell(columns, index)
|
return innerCodeCell(columns, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func innerCodeRawNamedCell(columns []string, headers map[string]int, name string) string {
|
||||||
|
index, ok := headers[name]
|
||||||
|
if !ok || index < 0 || index >= len(columns) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return columns[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInnerCodeSourceSKU(value string) string {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
func innerCodeCell(columns []string, index int) string {
|
func innerCodeCell(columns []string, index int) string {
|
||||||
if index < 0 || index >= len(columns) {
|
if index < 0 || index >= len(columns) {
|
||||||
return ""
|
return ""
|
||||||
@@ -423,7 +456,7 @@ func innerCodeCell(columns []string, index int) string {
|
|||||||
return strings.TrimSpace(columns[index])
|
return strings.TrimSpace(columns[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateInnerCodeFields(row int, order, shop, stall, spec, code string) error {
|
func validateInnerCodeFields(row int, order, shop, stall, sourceSKU, spec, code string) error {
|
||||||
if strings.Contains(code, ",") {
|
if strings.Contains(code, ",") {
|
||||||
return fmt.Errorf("第 %d 行内部档口入库码不能包含英文逗号", row)
|
return fmt.Errorf("第 %d 行内部档口入库码不能包含英文逗号", row)
|
||||||
}
|
}
|
||||||
@@ -437,6 +470,7 @@ func validateInnerCodeFields(row int, order, shop, stall, spec, code string) err
|
|||||||
max int
|
max int
|
||||||
}{
|
}{
|
||||||
{"Shopee订单编号", order, 64}, {"店铺名称", shop, 191}, {"档口及货号", stall, 191},
|
{"Shopee订单编号", order, 64}, {"店铺名称", shop, 191}, {"档口及货号", stall, 191},
|
||||||
|
{"原始 SKU", sourceSKU, MaxInnerCodeSourceSKURunes},
|
||||||
{"规格", spec, 500}, {"内部档口入库码", code, MaxInnerCodeValueRunes},
|
{"规格", spec, 500}, {"内部档口入库码", code, MaxInnerCodeValueRunes},
|
||||||
} {
|
} {
|
||||||
if utf8.RuneCountInString(field.value) > field.max {
|
if utf8.RuneCountInString(field.value) > field.max {
|
||||||
|
|||||||
@@ -63,6 +63,35 @@ func TestParseInnerCodeWorkbook_表头前置行与重复业务组(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseInnerCodeWorkbook_识别原始SKU说明表头并保留原文(t *testing.T) {
|
||||||
|
book := excelize.NewFile()
|
||||||
|
defaultSheet := book.GetSheetList()[0]
|
||||||
|
if err := book.SetSheetName(defaultSheet, innerCodeSheetName); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sourceSKU := " 上寮-青春网购工厂店8916##(&9)\n"
|
||||||
|
setInnerCodeWorkbookRows(t, book, [][]string{
|
||||||
|
{"内部档口入库码", "Shopee订单编号", "清洗后规格", "档口名称", "档口货号", "生成日期", "原始 SKU(源Excel C列,原样直拷)"},
|
||||||
|
{"DK001", "26081387A2EC2N", "紫色,XL", "上寮-青春网购工厂店", "8916", "2026-08-17", sourceSKU},
|
||||||
|
})
|
||||||
|
_, rows, err := parseInnerCodeWorkbook(book, "labels.xlsx", "user-1")
|
||||||
|
if err != nil || len(rows) != 1 {
|
||||||
|
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||||
|
}
|
||||||
|
if rows[0].SourceSKURaw != sourceSKU || normalizeInnerCodeSourceSKU(rows[0].SourceSKURaw) != "上寮-青春网购工厂店8916##(&9)" {
|
||||||
|
t.Fatalf("原始 SKU 未按要求保留或比较: %q", rows[0].SourceSKURaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInnerCodeHeaderMap_兼容原始SKU大小写和空格(t *testing.T) {
|
||||||
|
for _, header := range []string{"原始SKU", "原始sku", "原始 SKU", "原始sku(源Excel C列,原样直拷)"} {
|
||||||
|
headers, _ := innerCodeHeaderMap([]string{header})
|
||||||
|
if headers["原始SKU"] != 0 {
|
||||||
|
t.Fatalf("未识别表头 %q: %+v", header, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseInnerCodeWorkbook_同日入库码指向不同业务键时报错(t *testing.T) {
|
func TestParseInnerCodeWorkbook_同日入库码指向不同业务键时报错(t *testing.T) {
|
||||||
book := excelize.NewFile()
|
book := excelize.NewFile()
|
||||||
defaultSheet := book.GetSheetList()[0]
|
defaultSheet := book.GetSheetList()[0]
|
||||||
|
|||||||
@@ -263,10 +263,15 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
|
|||||||
plans = append(plans, innerCodeSkippedPlan(plan, message))
|
plans = append(plans, innerCodeSkippedPlan(plan, message))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
matches := matchInnerCodeItems(record.SpecRaw, record.Stall, eligible)
|
specMatches := matchInnerCodeSpecItems(record.SpecRaw, eligible)
|
||||||
|
matches, evidenceReason := matchInnerCodeItemsWithEvidence(record.Stall, record.SourceSKURaw, specMatches)
|
||||||
|
if evidenceReason != "" {
|
||||||
|
plans = append(plans, innerCodeSkippedPlan(plan, evidenceReason))
|
||||||
|
continue
|
||||||
|
}
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
reason := "候选商品中没有相同规格"
|
reason := "候选商品中没有相同规格"
|
||||||
if len(matchInnerCodeItems(record.SpecRaw, "", eligible)) > 0 {
|
if len(specMatches) > 0 {
|
||||||
reason = "规格能匹配,但档口及货号与候选商品不一致"
|
reason = "规格能匹配,但档口及货号与候选商品不一致"
|
||||||
}
|
}
|
||||||
plans = append(plans, innerCodeSkippedPlan(plan, reason))
|
plans = append(plans, innerCodeSkippedPlan(plan, reason))
|
||||||
@@ -320,6 +325,10 @@ func innerCodeSkippedPlan(plan model.InnerCodeRecord, message string) model.Inne
|
|||||||
}
|
}
|
||||||
|
|
||||||
func matchInnerCodeItems(spec, stall string, eligible []syb.DetailItem) []syb.DetailItem {
|
func matchInnerCodeItems(spec, stall string, eligible []syb.DetailItem) []syb.DetailItem {
|
||||||
|
return filterInnerCodeItemsByStall(matchInnerCodeSpecItems(spec, eligible), stall)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchInnerCodeSpecItems(spec string, eligible []syb.DetailItem) []syb.DetailItem {
|
||||||
matches := make([]syb.DetailItem, 0)
|
matches := make([]syb.DetailItem, 0)
|
||||||
for _, item := range eligible {
|
for _, item := range eligible {
|
||||||
if item.ProductSpec == spec {
|
if item.ProductSpec == spec {
|
||||||
@@ -337,7 +346,34 @@ func matchInnerCodeItems(spec, stall string, eligible []syb.DetailItem) []syb.De
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return filterInnerCodeItemsByStall(matches, stall)
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchInnerCodeItemsWithEvidence(stall, sourceSKURaw string, specMatches []syb.DetailItem) ([]syb.DetailItem, string) {
|
||||||
|
sourceSKU := normalizeInnerCodeSourceSKU(sourceSKURaw)
|
||||||
|
if sourceSKU != "" {
|
||||||
|
sourceMatches := make([]syb.DetailItem, 0)
|
||||||
|
for _, item := range specMatches {
|
||||||
|
if normalizeInnerCodeSourceSKU(innerCodeRawText(item.Raw["sku"])) == sourceSKU ||
|
||||||
|
normalizeInnerCodeSourceSKU(innerCodeRawText(item.Raw["variationSku"])) == sourceSKU {
|
||||||
|
sourceMatches = append(sourceMatches, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(sourceMatches) > 1 {
|
||||||
|
return nil, "原始 SKU 候选重复,不能自动选择"
|
||||||
|
}
|
||||||
|
if len(sourceMatches) == 1 {
|
||||||
|
stallMatches := filterInnerCodeItemsByStallStrict(specMatches, stall)
|
||||||
|
if len(stallMatches) > 1 {
|
||||||
|
return nil, "档口货号候选重复,不能自动选择"
|
||||||
|
}
|
||||||
|
if len(stallMatches) == 1 && stallMatches[0].ID != sourceMatches[0].ID {
|
||||||
|
return nil, "原始 SKU 与档口货号冲突,不能自动选择"
|
||||||
|
}
|
||||||
|
return sourceMatches, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filterInnerCodeItemsByStall(specMatches, stall), ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterInnerCodeItemsByStall(items []syb.DetailItem, stall string) []syb.DetailItem {
|
func filterInnerCodeItemsByStall(items []syb.DetailItem, stall string) []syb.DetailItem {
|
||||||
@@ -362,6 +398,20 @@ func filterInnerCodeItemsByStall(items []syb.DetailItem, stall string) []syb.Det
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterInnerCodeItemsByStallStrict(items []syb.DetailItem, stall string) []syb.DetailItem {
|
||||||
|
stall = strings.TrimSpace(stall)
|
||||||
|
if stall == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
matched := make([]syb.DetailItem, 0)
|
||||||
|
for _, item := range items {
|
||||||
|
if innerCodeStallMatches(stall, item) {
|
||||||
|
matched = append(matched, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
func innerCodeStallMatches(stall string, item syb.DetailItem) bool {
|
func innerCodeStallMatches(stall string, item syb.DetailItem) bool {
|
||||||
sku := innerCodeRawText(item.Raw["sku"])
|
sku := innerCodeRawText(item.Raw["sku"])
|
||||||
variation := innerCodeRawText(item.Raw["variationSku"])
|
variation := innerCodeRawText(item.Raw["variationSku"])
|
||||||
|
|||||||
@@ -179,6 +179,50 @@ func TestPlanInnerCodeRows_精确与标准化规格均受档口约束(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlanInnerCodeRows_原始SKU精确命中VariationSKU(t *testing.T) {
|
||||||
|
record := model.InnerCodeRecord{ID: 31, OrderNumber: "26081387A2EC2N",
|
||||||
|
Stall: "上寮-青春网购工厂店#8916", SourceSKURaw: "上寮-青春网购工厂店8916##(&9)",
|
||||||
|
SpecRaw: "紫色,XL", InnerCode: "DK-31", SourceDuplicateCount: 1}
|
||||||
|
item := innerCodeTestItem(310, "紫色,XL", map[string]any{"variationSku": "上寮-青春网购工厂店8916##(&9)"})
|
||||||
|
plans := planInnerCodeRows([]model.InnerCodeRecord{record},
|
||||||
|
map[string][]int64{record.OrderNumber: {300}}, map[int64]syb.StockDetail{300: {ID: 300, Details: []syb.DetailItem{item}}})
|
||||||
|
if len(plans) != 1 || plans[0].Status != model.InnerCodeReady || plans[0].DetailID != 310 {
|
||||||
|
t.Fatalf("原始 SKU 未生成可回写计划: %+v", plans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchInnerCodeItemsWithEvidence_原始SKU可命中SKU且保留旧规则兜底(t *testing.T) {
|
||||||
|
items := []syb.DetailItem{
|
||||||
|
innerCodeTestItem(1, "黑色,M", map[string]any{"sku": "源SKU-1"}),
|
||||||
|
innerCodeTestItem(2, "黑色,M", map[string]any{"sku": "B档#2"}),
|
||||||
|
}
|
||||||
|
matched, reason := matchInnerCodeItemsWithEvidence("不匹配#9", " 源SKU-1\n", items)
|
||||||
|
if reason != "" || len(matched) != 1 || matched[0].ID != 1 {
|
||||||
|
t.Fatalf("原始 SKU 精确命中失败 matched=%+v reason=%q", matched, reason)
|
||||||
|
}
|
||||||
|
matched, reason = matchInnerCodeItemsWithEvidence("B档#2", "不存在", items)
|
||||||
|
if reason != "" || len(matched) != 1 || matched[0].ID != 2 {
|
||||||
|
t.Fatalf("旧档口规则兜底失败 matched=%+v reason=%q", matched, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchInnerCodeItemsWithEvidence_重复和证据冲突均阻断(t *testing.T) {
|
||||||
|
duplicate := []syb.DetailItem{
|
||||||
|
innerCodeTestItem(1, "黑色,M", map[string]any{"sku": "同一SKU"}),
|
||||||
|
innerCodeTestItem(2, "黑色,M", map[string]any{"variationSku": "同一SKU"}),
|
||||||
|
}
|
||||||
|
if matched, reason := matchInnerCodeItemsWithEvidence("", "同一SKU", duplicate); len(matched) != 0 || reason != "原始 SKU 候选重复,不能自动选择" {
|
||||||
|
t.Fatalf("重复候选未阻断 matched=%+v reason=%q", matched, reason)
|
||||||
|
}
|
||||||
|
conflict := []syb.DetailItem{
|
||||||
|
innerCodeTestItem(1, "黑色,M", map[string]any{"sku": "源SKU"}),
|
||||||
|
innerCodeTestItem(2, "黑色,M", map[string]any{"sku": "B档#2"}),
|
||||||
|
}
|
||||||
|
if matched, reason := matchInnerCodeItemsWithEvidence("B档#2", "源SKU", conflict); len(matched) != 0 || reason != "原始 SKU 与档口货号冲突,不能自动选择" {
|
||||||
|
t.Fatalf("证据冲突未阻断 matched=%+v reason=%q", matched, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPlanInnerCodeRows_多件数量一致放行且其他异常继续阻断(t *testing.T) {
|
func TestPlanInnerCodeRows_多件数量一致放行且其他异常继续阻断(t *testing.T) {
|
||||||
records := []model.InnerCodeRecord{
|
records := []model.InnerCodeRecord{
|
||||||
{ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1,DK2", SourceDuplicateCount: 2},
|
{ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1,DK2", SourceDuplicateCount: 2},
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ v26(#254)新增 `purchase_spec_resolutions`,用一张表保存采购执行
|
|||||||
幂等身份和最终规格决策;不修改历史 SQLite migration,也不覆盖 PDD 商品主数据。
|
幂等身份和最终规格决策;不修改历史 SQLite migration,也不覆盖 PDD 商品主数据。
|
||||||
v27(#250)为 `syb_inner_code_records` 增加可空 JSON 字段 `remote_items_json`,保存
|
v27(#250)为 `syb_inner_code_records` 增加可空 JSON 字段 `remote_items_json`,保存
|
||||||
多件商品逐个入库码对应的远端明细、来源和动作状态;仍不拆分新的业务表。
|
多件商品逐个入库码对应的远端明细、来源和动作状态;仍不拆分新的业务表。
|
||||||
|
v28(#259)为该表增加可空 `source_sku_raw`,保留 Excel 原始 SKU,供匹配阶段与
|
||||||
|
顺运宝 `sku/variationSku` 做边界去空白后的精确比较。
|
||||||
|
|
||||||
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
|
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
|
||||||
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
||||||
@@ -1027,7 +1029,7 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
|||||||
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
|
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
|
||||||
成功写入的 `spec_mappings` 不回滚。
|
成功写入的 `spec_mappings` 不回滚。
|
||||||
|
|
||||||
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24,后台回写 v25,逐件检查点 v27)
|
## 17. `syb_inner_code_records` 档口入库码记录(MySQL v23,软删除 v24,后台回写 v25,逐件检查点 v27,原始 SKU v28)
|
||||||
|
|
||||||
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
|
档口入库码使用一张业务表完成导入、匹配、回写和异常恢复。Excel 只是导入载体,系统
|
||||||
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
|
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
|
||||||
@@ -1061,6 +1063,10 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
|||||||
不再接收聚合字符串:原商品明细承载一个码,额外单件使用数量 1、价格 0 的占位明细。
|
不再接收聚合字符串:原商品明细承载一个码,额外单件使用数量 1、价格 0 的占位明细。
|
||||||
JSON 数组按单件码保存 `detail_id/source/title/status` 检查点;新增或写入结果未知时立即
|
JSON 数组按单件码保存 `detail_id/source/title/status` 检查点;新增或写入结果未知时立即
|
||||||
转为 `needs_check`,重启后只读扫描整张货运单,不自动重发请求。
|
转为 `needs_check`,重启后只读扫描整张货运单,不自动重发请求。
|
||||||
|
- v28 增加可空 `source_sku_raw VARCHAR(500)`。数据库保留 Excel 单元格原文;匹配时仅
|
||||||
|
去除两端空白后,与规格候选的 `sku` 或 `variationSku` 完全相等比较。唯一命中时它是
|
||||||
|
高优先级确定性证据;重复命中或与档口货号唯一证据冲突时停止自动选择。缺失或零命中
|
||||||
|
继续使用原档口货号规则,不做去 `#`、包含匹配或短货号猜测。
|
||||||
|
|
||||||
## 18. `purchase_spec_resolutions` 采购运行时规格解析(MySQL v26)
|
## 18. `purchase_spec_resolutions` 采购运行时规格解析(MySQL v26)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user