fix: 使用原始SKU辅助档口匹配 (#259)

This commit is contained in:
chengma
2026-08-18 15:25:58 +08:00
parent 103610a012
commit 4383bb87df
9 changed files with 237 additions and 33 deletions
+2
View File
@@ -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
+18 -11
View File
@@ -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
}
+34 -2
View File
@@ -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)
}
+41 -7
View File
@@ -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 {
+29
View File
@@ -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]
+53 -3
View File
@@ -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"])
+44
View File
@@ -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},
+7 -1
View File
@@ -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)