From 9dace6ac89a8097a28a101a32b9b6f07e30ef69b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 28 Sep 2026 10:35:16 +0800 Subject: [PATCH] fix(sybinnercode): add identity/stall/id guards to #289 assignment (#344) Port the remaining consistency guards from cmautobuy's planExistingMatchedInnerCodeItems into assignExistingBoundItems: - Reject the whole multi-piece group if any candidate's raw ProductSpec, sku or variationSku differs from the lowest-ID candidate. Candidates are matched via NormalizeSpecKey, so raw values can legitimately differ even when normalized keys agree; auto-assigning across genuinely different items must be blocked. - Re-verify each candidate against stallMatches when record.Stall is non-empty, since the no-SKU fallback path in matchEvidence can hand back candidates that were never stall-checked. - Reject candidates with a non-positive or duplicate detail ID. Added one regression test per guard plus a happy-path test confirming legitimate multi-piece assignment still succeeds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/sybinnercode/match.go | 32 ++++++++++- server/app/goauto/sybinnercode/match_test.go | 57 +++++++++++++++++++- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/server/app/goauto/sybinnercode/match.go b/server/app/goauto/sybinnercode/match.go index 7e3ab4c..544b6f5 100644 --- a/server/app/goauto/sybinnercode/match.go +++ b/server/app/goauto/sybinnercode/match.go @@ -203,7 +203,7 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC for i, it := range record.Items { codes[i] = it.Code } - assigned, reason := assignExistingBoundItems(codes, matches) + assigned, reason := assignExistingBoundItems(record.Stall, codes, matches) if reason != "" { return nil, models.SYBInnerCodeSkipped, reason, nil } @@ -259,9 +259,37 @@ func planRecord(ctx context.Context, reader MatchReader, record models.SYBInnerC // 对应,会把已经正确绑定某个目标码的明细错误地重新分配给另一个码。这里先按“候选 // 明细已有的入库码值”精确匹配对应的目标码,保留既有正确绑定不动;再把剩余尚未 // 写入任何码的空白明细(按 ID 排序)依次填充给还没有候选的目标码位置。 -func assignExistingBoundItems(codes []string, matches []sybclient.DetailItem) ([]sybclient.DetailItem, string) { +// 移植自 cmautobuy `planExistingMatchedInnerCodeItems` 的一致性护栏(代码评审补充): +// 候选来自 NormalizeSpecKey 归一化匹配,原始 ProductSpec/sku/variationSku 可能在 +// 归一化后相同但原始值不同,必须逐一比对最低 ID 候选,避免跨真正不同商品自动分配; +// 无 SKU 回退路径可能返回从未做过档口校验的候选,这里逐一重新校验;同时拒绝无效 +// 或重复的商品明细 ID。 +func assignExistingBoundItems(stall string, codes []string, matches []sybclient.DetailItem) ([]sybclient.DetailItem, string) { sorted := append([]sybclient.DetailItem(nil), matches...) sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) + seenIDs := make(map[int64]bool, len(sorted)) + for _, item := range sorted { + if item.ID <= 0 || seenIDs[item.ID] { + return nil, "重复候选包含无效或重复的商品明细 ID,不能自动逐件分配" + } + seenIDs[item.ID] = true + } + first := sorted[0] + wantSpec := first.ProductSpec + wantSKU := rawText(first.Raw["sku"]) + wantVariationSKU := rawText(first.Raw["variationSku"]) + for _, item := range sorted { + if item.ProductSpec != wantSpec || rawText(item.Raw["sku"]) != wantSKU || rawText(item.Raw["variationSku"]) != wantVariationSKU { + return nil, "重复候选的规格或 SKU 身份不一致,不能自动逐件分配" + } + } + if strings.TrimSpace(stall) != "" { + for _, item := range sorted { + if !stallMatches(stall, item) { + return nil, "重复候选的档口及货号不一致,不能自动逐件分配" + } + } + } assigned := make([]sybclient.DetailItem, len(codes)) taken := make([]bool, len(codes)) codeIndex := make(map[string]int, len(codes)) diff --git a/server/app/goauto/sybinnercode/match_test.go b/server/app/goauto/sybinnercode/match_test.go index 61e79d2..ab920d6 100644 --- a/server/app/goauto/sybinnercode/match_test.go +++ b/server/app/goauto/sybinnercode/match_test.go @@ -228,7 +228,7 @@ func TestAssignExistingBoundItemsPreservesOutOfOrderBindings(t *testing.T) { // existing correct binding from detail 20 and try to overwrite it. d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2") d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") - assigned, reason := assignExistingBoundItems([]string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) if reason != "" { t.Fatalf("unexpected reason: %s", reason) } @@ -243,12 +243,65 @@ func TestAssignExistingBoundItemsPreservesOutOfOrderBindings(t *testing.T) { func TestAssignExistingBoundItemsRejectsForeignCode(t *testing.T) { d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-OTHER") d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") - _, reason := assignExistingBoundItems([]string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) if reason == "" { t.Fatalf("expected rejection for detail already holding a non-target code") } } +func TestAssignExistingBoundItemsRejectsInconsistentIdentity(t *testing.T) { + // Candidates can share a NormalizeSpecKey-normalized spec while their raw + // ProductSpec/sku/variationSku differ; auto-assignment across genuinely + // different items must be rejected. + d20 := detail(20, "黑色, L", 1, "SKU-1", "A#1", "") + d21 := detail(21, "黑色,L", 1, "SKU-2", "A#1", "") + _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason != "重复候选的规格或 SKU 身份不一致,不能自动逐件分配" { + t.Fatalf("expected identity-mismatch rejection, got %q", reason) + } +} + +func TestAssignExistingBoundItemsRejectsCandidateFailingStallCheck(t *testing.T) { + // The no-SKU fallback path in matchEvidence can hand back candidates that + // were never checked against the stall at all. Both candidates share an + // identical identity (so the identity guard passes) but neither one's + // sku/spec actually satisfies the record's stall/article requirement. + d20 := detail(20, "黑色,L", 1, "SKU-1", "ZZZ", "") + d21 := detail(21, "黑色,L", 1, "SKU-1", "ZZZ", "") + _, reason := assignExistingBoundItems("甲档口#88", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason != "重复候选的档口及货号不一致,不能自动逐件分配" { + t.Fatalf("expected stall-mismatch rejection, got %q", reason) + } +} + +func TestAssignExistingBoundItemsRejectsInvalidOrDuplicateDetailID(t *testing.T) { + invalidID := detail(0, "黑色,L", 1, "SKU-1", "A#1", "") + valid := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") + if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{invalidID, valid}); reason == "" { + t.Fatalf("expected rejection for non-positive detail ID") + } + dup1 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "") + dup2 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "") + if _, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{dup1, dup2}); reason == "" { + t.Fatalf("expected rejection for duplicate detail IDs") + } +} + +func TestAssignExistingBoundItemsHappyPathStillAssignsWithGuards(t *testing.T) { + // Both candidates share identical raw spec/sku/variationSku and both + // individually satisfy the stall check; the guards must not block the + // legitimate happy path. + d20 := detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2") + d21 := detail(21, "黑色,L", 1, "SKU-1", "A#1", "") + assigned, reason := assignExistingBoundItems("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21}) + if reason != "" { + t.Fatalf("unexpected reason: %s", reason) + } + if assigned[0].ID != 21 || assigned[1].ID != 20 { + t.Fatalf("assigned=%+v", assigned) + } +} + func TestRunMatchJobPreservesExistingBindingWhenCandidateOrderDiffers(t *testing.T) { // End-to-end regression for #289: N=2 single-piece candidates already // carrying one previously bound code, with the bound detail's ID not