From 4383bb87df5464d015f3f0da1ab9ad05be75948b Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 18 Aug 2026 15:25:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=E5=8E=9F=E5=A7=8BSKU?= =?UTF-8?q?=E8=BE=85=E5=8A=A9=E6=A1=A3=E5=8F=A3=E5=8C=B9=E9=85=8D=20(#259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/model/inner_code.go | 2 + admin/repository/inner_code.go | 29 ++++++---- admin/repository/mysql_db.go | 36 +++++++++++- admin/repository/mysql_db_integration_test.go | 18 +++--- admin/service/inner_code_import.go | 48 +++++++++++++--- admin/service/inner_code_import_test.go | 29 ++++++++++ admin/service/inner_code_match.go | 56 ++++++++++++++++++- admin/service/inner_code_match_test.go | 44 +++++++++++++++ docs/admin/03-data-model.md | 8 ++- 9 files changed, 237 insertions(+), 33 deletions(-) diff --git a/admin/model/inner_code.go b/admin/model/inner_code.go index f357567..4828992 100644 --- a/admin/model/inner_code.go +++ b/admin/model/inner_code.go @@ -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 diff --git a/admin/repository/inner_code.go b/admin/repository/inner_code.go index 94d21a7..78c4a16 100644 --- a/admin/repository/inner_code.go +++ b/admin/repository/inner_code.go @@ -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 +} diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index c13b3fe..b099300 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -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 { diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 9fe0d09..59b2a5a 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -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) } diff --git a/admin/service/inner_code_import.go b/admin/service/inner_code_import.go index eaf942b..87055c0 100644 --- a/admin/service/inner_code_import.go +++ b/admin/service/inner_code_import.go @@ -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 { diff --git a/admin/service/inner_code_import_test.go b/admin/service/inner_code_import_test.go index 93bb3e9..ca4b36e 100644 --- a/admin/service/inner_code_import_test.go +++ b/admin/service/inner_code_import_test.go @@ -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] diff --git a/admin/service/inner_code_match.go b/admin/service/inner_code_match.go index 7062bc2..cc9fb2d 100644 --- a/admin/service/inner_code_match.go +++ b/admin/service/inner_code_match.go @@ -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"]) diff --git a/admin/service/inner_code_match_test.go b/admin/service/inner_code_match_test.go index 08cdeea..c6f512a 100644 --- a/admin/service/inner_code_match_test.go +++ b/admin/service/inner_code_match_test.go @@ -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}, diff --git a/docs/admin/03-data-model.md b/docs/admin/03-data-model.md index 70fede8..7b5b534 100644 --- a/docs/admin/03-data-model.md +++ b/docs/admin/03-data-model.md @@ -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)