fix: 使用原始SKU辅助档口匹配 (#259)
This commit is contained in:
@@ -26,6 +26,7 @@ type InnerCodeRecord struct {
|
||||
OrderNumber string
|
||||
ShopName string
|
||||
Stall string
|
||||
SourceSKURaw string
|
||||
SpecRaw string
|
||||
SpecKey string
|
||||
InnerCode string
|
||||
@@ -62,6 +63,7 @@ type InnerCodeImportRow struct {
|
||||
OrderNumber string
|
||||
ShopName string
|
||||
Stall string
|
||||
SourceSKURaw string
|
||||
SpecRaw string
|
||||
SpecKey string
|
||||
InnerCode string
|
||||
|
||||
@@ -128,12 +128,12 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
||||
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,
|
||||
business_date,source_row,print_sequence,order_number,shop_name,stall,source_sku_raw,
|
||||
spec_raw,spec_key,inner_code,source_duplicate_count,status,
|
||||
created_by_user_id,created_at,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,'pending',?,?,?)`,
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,'pending',?,?,?)`,
|
||||
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)
|
||||
if err != nil {
|
||||
return "", innerCodeImportWriteError("新增档口入库码记录失败", err)
|
||||
@@ -147,20 +147,20 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
||||
ErrInnerCodeRestoreConflict, id, currentInnerCode, row.InnerCode)
|
||||
}
|
||||
_, 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=?
|
||||
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 {
|
||||
_, 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,
|
||||
syb_variation_sku=NULL,purchase_platform=NULL,purchase_code=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,
|
||||
deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
|
||||
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 {
|
||||
return "", innerCodeImportWriteError("恢复档口入库码导入记录失败", err)
|
||||
@@ -170,7 +170,7 @@ func UpsertInnerCodeImportRow(tx *sql.Tx, row model.InnerCodeImportRow, now stri
|
||||
|
||||
_, err = tx.Exec(`
|
||||
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=?,
|
||||
status=CASE WHEN status IN ('queued','updated','already_filled','applying','needs_check')
|
||||
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,
|
||||
updated_at=?
|
||||
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)
|
||||
if err != nil {
|
||||
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,
|
||||
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(stock_id,0),COALESCE(detail_id,0),COALESCE(syb_spec,''),COALESCE(syb_sku,''),
|
||||
COALESCE(syb_variation_sku,''),COALESCE(purchase_platform,''),COALESCE(purchase_code,''),
|
||||
@@ -482,7 +482,7 @@ type innerCodeRowScanner interface {
|
||||
func scanInnerCodeRecord(scanner innerCodeRowScanner) (model.InnerCodeRecord, error) {
|
||||
var row model.InnerCodeRecord
|
||||
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.StockID, &row.DetailID,
|
||||
&row.SybSpec, &row.SybSKU, &row.SybVariationSKU, &row.PurchasePlatform,
|
||||
@@ -780,3 +780,10 @@ func nullableString(value string) any {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func nullableStringPreserveSpace(value string) any {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"cmautobuy/admin/spec"
|
||||
)
|
||||
|
||||
const mysqlSchemaVersion = 27
|
||||
const mysqlSchemaVersion = 28
|
||||
|
||||
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 保存多件档口入库码逐件远端检查点,不拆分新的业务表。
|
||||
func migrateMySQLV27(db *sql.DB) error {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -1286,7 +1286,7 @@ func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testin
|
||||
}
|
||||
|
||||
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"}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
@@ -1302,17 +1302,17 @@ func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testin
|
||||
}
|
||||
var status, innerCode string
|
||||
var stockID sql.NullInt64
|
||||
var deletedAt, message 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'`).
|
||||
Scan(&status, &innerCode, &stockID, &deletedAt, &message); err != nil {
|
||||
var deletedAt, message, sourceSKU sql.NullString
|
||||
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, &sourceSKU); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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"}
|
||||
tx, err = db.Begin()
|
||||
if err != nil {
|
||||
@@ -1326,11 +1326,11 @@ func TestUpsertInnerCodeImportRow_软删除记录按状态安全恢复(t *testin
|
||||
if err := tx.Commit(); err != nil {
|
||||
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'`).
|
||||
Scan(&status, &stockID, &deletedAt, &message); err != nil {
|
||||
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, &sourceSKU); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
MaxInnerCodeUploadBytes = 10 * 1024 * 1024
|
||||
MaxInnerCodeImportRows = 5000
|
||||
MaxInnerCodeValueRunes = 128
|
||||
innerCodeSheetName = "标签入库码映射"
|
||||
MaxInnerCodeUploadBytes = 10 * 1024 * 1024
|
||||
MaxInnerCodeImportRows = 5000
|
||||
MaxInnerCodeValueRunes = 128
|
||||
MaxInnerCodeSourceSKURunes = 500
|
||||
innerCodeSheetName = "标签入库码映射"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -270,6 +271,7 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
||||
specRaw := strings.ReplaceAll(innerCodeCell(columns, specColumn), "\r", " ")
|
||||
specRaw = strings.TrimSpace(strings.ReplaceAll(specRaw, "\n", " "))
|
||||
stall := innerCodeStall(columns, headers)
|
||||
sourceSKURaw := innerCodeRawNamedCell(columns, headers, "原始SKU")
|
||||
shop := innerCodeNamedCell(columns, headers, "店铺名称")
|
||||
printSequence := 0
|
||||
if raw := innerCodeNamedCell(columns, headers, "标签打印序号"); raw != "" {
|
||||
@@ -277,7 +279,7 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
||||
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
|
||||
}
|
||||
specKey := NormalizeInnerCodeSpecKey(specRaw)
|
||||
@@ -285,7 +287,8 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
||||
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,
|
||||
SourceSKURaw: sourceSKURaw,
|
||||
SpecKey: specKey, InnerCode: innerCode, SourceDuplicateCount: 1,
|
||||
CreatedByUserID: actorUserID,
|
||||
}, businessKey: key})
|
||||
}
|
||||
@@ -318,6 +321,20 @@ func parseInnerCodeRows(rows *excelize.Rows, originalFilename, actorUserID strin
|
||||
seen[original.businessKey] = true
|
||||
group := groups[original.businessKey]
|
||||
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))
|
||||
seenCodes := make(map[string]bool, len(group))
|
||||
for _, member := range group {
|
||||
@@ -389,6 +406,10 @@ func innerCodeHeaderMap(columns []string) (map[string]int, int) {
|
||||
for index, raw := range columns {
|
||||
name := strings.TrimSpace(raw)
|
||||
result[name] = index
|
||||
compactName := strings.ToLower(innerCodeSpaces.ReplaceAllString(name, ""))
|
||||
if strings.HasPrefix(compactName, "原始sku") {
|
||||
result["原始SKU"] = index
|
||||
}
|
||||
if name == "清洗后规格" || (specColumn < 0 && strings.HasPrefix(name, "原始产品规格")) {
|
||||
specColumn = index
|
||||
}
|
||||
@@ -416,6 +437,18 @@ func innerCodeNamedCell(columns []string, headers map[string]int, name string) s
|
||||
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 {
|
||||
if index < 0 || index >= len(columns) {
|
||||
return ""
|
||||
@@ -423,7 +456,7 @@ func innerCodeCell(columns []string, index int) string {
|
||||
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, ",") {
|
||||
return fmt.Errorf("第 %d 行内部档口入库码不能包含英文逗号", row)
|
||||
}
|
||||
@@ -437,6 +470,7 @@ func validateInnerCodeFields(row int, order, shop, stall, spec, code string) err
|
||||
max int
|
||||
}{
|
||||
{"Shopee订单编号", order, 64}, {"店铺名称", shop, 191}, {"档口及货号", stall, 191},
|
||||
{"原始 SKU", sourceSKU, MaxInnerCodeSourceSKURunes},
|
||||
{"规格", spec, 500}, {"内部档口入库码", code, MaxInnerCodeValueRunes},
|
||||
} {
|
||||
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) {
|
||||
book := excelize.NewFile()
|
||||
defaultSheet := book.GetSheetList()[0]
|
||||
|
||||
@@ -263,10 +263,15 @@ func planInnerCodeRowsWithReserved(records []model.InnerCodeRecord, stockIDsByOr
|
||||
plans = append(plans, innerCodeSkippedPlan(plan, message))
|
||||
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 {
|
||||
reason := "候选商品中没有相同规格"
|
||||
if len(matchInnerCodeItems(record.SpecRaw, "", eligible)) > 0 {
|
||||
if len(specMatches) > 0 {
|
||||
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 {
|
||||
return filterInnerCodeItemsByStall(matchInnerCodeSpecItems(spec, eligible), stall)
|
||||
}
|
||||
|
||||
func matchInnerCodeSpecItems(spec string, eligible []syb.DetailItem) []syb.DetailItem {
|
||||
matches := make([]syb.DetailItem, 0)
|
||||
for _, item := range eligible {
|
||||
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 {
|
||||
@@ -362,6 +398,20 @@ func filterInnerCodeItemsByStall(items []syb.DetailItem, stall string) []syb.Det
|
||||
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 {
|
||||
sku := innerCodeRawText(item.Raw["sku"])
|
||||
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) {
|
||||
records := []model.InnerCodeRecord{
|
||||
{ID: 1, OrderNumber: "DUP", SpecRaw: "黑色,M", InnerCode: "DK1,DK2", SourceDuplicateCount: 2},
|
||||
|
||||
@@ -131,6 +131,8 @@ v26(#254)新增 `purchase_spec_resolutions`,用一张表保存采购执行
|
||||
幂等身份和最终规格决策;不修改历史 SQLite migration,也不覆盖 PDD 商品主数据。
|
||||
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`,
|
||||
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
||||
@@ -1027,7 +1029,7 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
||||
`ai_spec_match_decisions`。Admin 重启把未完成明细改为 `interrupted` 并完成批次计数,已经
|
||||
成功写入的 `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 只是导入载体,系统
|
||||
不保存原文件、文件哈希,不再拆批次表或尝试记录表。
|
||||
@@ -1061,6 +1063,10 @@ CREATE UNIQUE INDEX idx_client_assignment_current
|
||||
不再接收聚合字符串:原商品明细承载一个码,额外单件使用数量 1、价格 0 的占位明细。
|
||||
JSON 数组按单件码保存 `detail_id/source/title/status` 检查点;新增或写入结果未知时立即
|
||||
转为 `needs_check`,重启后只读扫描整张货运单,不自动重发请求。
|
||||
- v28 增加可空 `source_sku_raw VARCHAR(500)`。数据库保留 Excel 单元格原文;匹配时仅
|
||||
去除两端空白后,与规格候选的 `sku` 或 `variationSku` 完全相等比较。唯一命中时它是
|
||||
高优先级确定性证据;重复命中或与档口货号唯一证据冲突时停止自动选择。缺失或零命中
|
||||
继续使用原档口货号规则,不做去 `#`、包含匹配或短货号猜测。
|
||||
|
||||
## 18. `purchase_spec_resolutions` 采购运行时规格解析(MySQL v26)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user