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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user