diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 0afd564..dc0aa57 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 214ac317abfef23d71362968d5b30f078fa528b6 -synchronized_at: 2026-09-29T03:47:42Z +wiki_revision: c483a1f3fff3144dfad1ae24e6ca0b1de643ac12 +synchronized_at: 2026-09-29T06:14:25Z @@ -740,3 +740,7 @@ SYB 回填与资料完整性独立:正式SYB任务已有单号且仍关联有 - 超时/中断以及启动恢复把未完成 pending/matching 改为明确失败并将旧批次结束;既有 ready/updated/回写证据和范围外记录不修改,用户可手动重试。恢复不调用 SYB 写接口,不自动回写、不删除成功结果。 实现绑定:`fix/350-inner-code-matching`,源码提交 `9c5e20e`;未合并 main、未部署,正式环境仍需发布后启用。 + + +### 档口入库码排版字符兼容(#350 v2) +匹配比较时,双方原始 SKU、variationSku 及档口名称仅去除 CR/LF/TAB 排版控制字符,并沿用首尾空白 trim;普通内部空格、大小写、标点与货号不变。导入原始值和回写计划中的 SYB 原始证据保持原样。归一化后重复候选、SKU/档口冲突仍拒绝,原精确规格、货号、数量与占用校验不放宽。成功匹配只形成可回写计划,不自动执行 SYB 写入。 diff --git a/server/app/goauto/sybinnercode/match.go b/server/app/goauto/sybinnercode/match.go index 9665fba..f39476e 100644 --- a/server/app/goauto/sybinnercode/match.go +++ b/server/app/goauto/sybinnercode/match.go @@ -417,12 +417,18 @@ func matchSpec(spec string, items []sybclient.DetailItem) []sybclient.DetailItem } return result } + +// Compare layout-only controls without changing stored evidence or ordinary spaces. +func normalizeMatchLayout(value string) string { + return strings.TrimSpace(strings.NewReplacer("\r", "", "\n", "", "\t", "").Replace(value)) +} + func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]sybclient.DetailItem, string) { - sourceSKU = strings.TrimSpace(sourceSKU) + sourceSKU = normalizeMatchLayout(sourceSKU) if sourceSKU != "" { skuMatches := []sybclient.DetailItem{} for _, item := range items { - if strings.TrimSpace(rawText(item.Raw["sku"])) == sourceSKU || strings.TrimSpace(rawText(item.Raw["variationSku"])) == sourceSKU { + if normalizeMatchLayout(rawText(item.Raw["sku"])) == sourceSKU || normalizeMatchLayout(rawText(item.Raw["variationSku"])) == sourceSKU { skuMatches = append(skuMatches, item) } } @@ -453,7 +459,7 @@ func matchEvidence(stall, sourceSKU string, items []sybclient.DetailItem) ([]syb return fallback, "" } func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailItem { - stall = strings.TrimSpace(stall) + stall = normalizeMatchLayout(stall) if stall == "" { return nil } @@ -471,8 +477,12 @@ func strictStall(stall string, items []sybclient.DetailItem) []sybclient.DetailI // 候选中同时包含档口名才允许前导零等价(如 "067"≡"67");非数字货号要求精确 // token 匹配;ProductSpec 只在以货号开头时才算命中;货号为空时回退为档口名包含判断。 func stallMatches(stall string, item sybclient.DetailItem) bool { - sku := rawText(item.Raw["sku"]) - variation := rawText(item.Raw["variationSku"]) + stall = normalizeMatchLayout(stall) + if stall == "" { + return false + } + sku := normalizeMatchLayout(rawText(item.Raw["sku"])) + variation := normalizeMatchLayout(rawText(item.Raw["variationSku"])) blob := sku + " " + variation + " " + item.ProductSpec if strings.Contains(blob, stall) { return true diff --git a/server/app/goauto/sybinnercode/match_layout_test.go b/server/app/goauto/sybinnercode/match_layout_test.go new file mode 100644 index 0000000..63a73fe --- /dev/null +++ b/server/app/goauto/sybinnercode/match_layout_test.go @@ -0,0 +1,68 @@ +package sybinnercode + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/sybclient" +) + +func TestMatchEvidenceNormalizesLayoutOnBothSides(t *testing.T) { + for _, layout := range []string{"\n", "\r", "\r\n", "\t"} { + for _, sourceHasLayout := range []bool{true, false} { + source, candidate := "市场-档口甲#731", "市场-档口甲#731" + if sourceHasLayout { + source = "市场-" + layout + "档口甲#731" + } else { + candidate = "市场-" + layout + "档口甲#731" + } + item := detail(1, "黑色,L", 1, "", candidate, "") + matches, reason := matchEvidence(source, source, []sybclient.DetailItem{item}) + if reason != "" || len(matches) != 1 || !stallMatches(source, item) { + t.Fatalf("layout=%q sourceLayout=%v: matches=%v reason=%s", layout, sourceHasLayout, matches, reason) + } + if item.Raw["variationSku"] != candidate { + t.Fatal("comparison modified raw candidate evidence") + } + } + } + item := detail(1, "黑色,L", 1, "SKU-\n1", "", "") + if matches, reason := matchEvidence("", "SKU-1", []sybclient.DetailItem{item}); reason != "" || len(matches) != 1 { + t.Fatalf("raw sku layout did not match: %v %s", matches, reason) + } +} + +func TestMatchLayoutPreservesIdentityAndAmbiguityChecks(t *testing.T) { + for _, candidate := range []string{"市场-档口乙#731", "市场-档口甲#732", "市场-档 口甲#731", "市场-档口甲#PDD7319"} { + item := detail(1, "黑色,L", 1, "", candidate, "") + matches, _ := matchEvidence("市场-\n档口甲#731", "市场-\n档口甲#731", []sybclient.DetailItem{item}) + if len(matches) != 0 { + t.Fatalf("different identity unexpectedly matched: %q", candidate) + } + } + items := []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "", ""), detail(2, "黑色,L", 1, "SKU-\n1", "", "")} + if matches, reason := matchEvidence("", "SKU-1", items); len(matches) != 0 || reason == "" { + t.Fatal("layout-equivalent duplicate SKUs must remain ambiguous") + } + items = []sybclient.DetailItem{detail(1, "黑色,L", 1, "SKU-1", "市场-档口乙#732", ""), detail(2, "黑色,L", 1, "OTHER", "市场-档口甲#731", "")} + if matches, reason := matchEvidence("市场-\n档口甲#731", "SKU-\t1", items); len(matches) != 0 || reason == "" { + t.Fatal("SKU/stall conflict must remain rejected") + } + if normalizeMatchLayout("A B") == normalizeMatchLayout("AB") || normalizeMatchLayout("a") == normalizeMatchLayout("A") { + t.Fatal("ordinary spaces and case must remain significant") + } +} + +func TestPlanRecordWithLayoutEvidenceKeepsOriginalValues(t *testing.T) { + record := matchRecord("ORDER-1", "市场-\n档口甲#731", "市场-\n档口甲#731", "IC-1") + item := detail(20, "黑色,L", 1, "", "市场-档口甲#731", "") + reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{item}}}} + plan, status, reason, err := planRecord(context.Background(), reader, record, map[int64]bool{}) + if err != nil || status != models.SYBInnerCodeReady || plan == nil || plan.DetailID != 20 { + t.Fatalf("expected unique ready plan, got %v %s %s %v", plan, status, reason, err) + } + if record.Stall != "市场-\n档口甲#731" || record.SourceSKURaw != record.Stall || plan.SYBVariationSKU != "市场-档口甲#731" { + t.Fatal("original import and plan evidence must remain unchanged") + } +}