Files
goauto/server/app/goauto/sybinnercode/match_test.go
T
QiuSWandClaude Opus 5.5 9dace6ac89 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
2026-09-28 10:35:16 +08:00

383 lines
17 KiB
Go

package sybinnercode
import (
"context"
"encoding/json"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/sybclient"
"github.com/google/uuid"
"gorm.io/gorm"
)
type fakeMatchReader struct {
rows map[string][]sybclient.StockRow
stocks map[int64]sybclient.StockDetail
listCalls, detailCalls int
}
func (f *fakeMatchReader) ListByOrderNumber(_ context.Context, order string) ([]sybclient.StockRow, error) {
f.listCalls++
return f.rows[order], nil
}
func (f *fakeMatchReader) DetailListByStock(_ context.Context, ids []int64) ([]sybclient.StockDetail, error) {
f.detailCalls++
result := make([]sybclient.StockDetail, 0, len(ids))
for _, id := range ids {
if stock, ok := f.stocks[id]; ok {
result = append(result, stock)
}
}
return result, nil
}
func createMatchJob(t *testing.T, db *gorm.DB, records []models.SYBInnerCodeRecord) string {
t.Helper()
for i := range records {
if err := db.Create(&records[i]).Error; err != nil {
t.Fatal(err)
}
}
id := uuid.NewString()
ids := make([]uint64, 0, len(records))
for _, record := range records {
ids = append(ids, record.ID)
}
raw, _ := json.Marshal(ids)
job := models.SYBInnerCodeMatchJob{ID: id, WorkKey: "test:" + id, BusinessDate: "2026-08-28", RecordIDsJSON: string(raw), Status: "pending", Total: len(records)}
if err := db.Create(&job).Error; err != nil {
t.Fatal(err)
}
return id
}
func TestRunMatchJobOnlyProcessesScopedRecords(t *testing.T) {
db := testDB(t)
selected := matchRecord("ORDER-1", "SKU-1", "A#1", "IC-1")
jobID := createMatchJob(t, db, []models.SYBInnerCodeRecord{selected})
outside := matchRecord("ORDER-2", "SKU-2", "B#2", "IC-2")
if err := db.Create(&outside).Error; err != nil {
t.Fatal(err)
}
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-1", "A#1", "")}}}}
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
t.Fatal(err)
}
var untouched models.SYBInnerCodeRecord
if err := db.First(&untouched, outside.ID).Error; err != nil {
t.Fatal(err)
}
if untouched.Status != models.SYBInnerCodePending {
t.Fatalf("out-of-scope record changed to %s", untouched.Status)
}
}
func detail(id int64, spec string, qty int, sku, variation, code string) sybclient.DetailItem {
return sybclient.DetailItem{ID: id, ProductSpec: spec, ProductQty: qty, Raw: map[string]any{"sku": sku, "variationSku": variation, "innerExpCode": code, "purchasePlatform": "", "purchaseCode": ""}}
}
func matchRecord(order, sku, stall, code string) models.SYBInnerCodeRecord {
return models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: order, Stall: stall, SpecKey: "黑色,L", SpecRaw: "黑色,L", SourceSKURaw: sku, Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: code, Ordinal: 1, SourceRow: 2}}}
}
func TestRunMatchJobPlansMultiItemWithoutAnyWriteSurface(t *testing.T) {
db := testDB(t)
record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", SourceSKURaw: "SKU-1", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}, {BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}}}
jobID := createMatchJob(t, db, []models.SYBInnerCodeRecord{record})
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Code: "ORDER-1", Details: []sybclient.DetailItem{detail(20, "黑色,L", 2, "SKU-1", "A#1", "")}}}}
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
t.Fatal(err)
}
var saved models.SYBInnerCodeRecord
if err := db.Preload("Items").First(&saved).Error; err != nil {
t.Fatal(err)
}
if saved.Status != models.SYBInnerCodeReady {
t.Fatalf("status=%s message=%s", saved.Status, saved.ResultMessage)
}
var plan models.SYBInnerCodePlan
if err := db.First(&plan, "record_id = ?", saved.ID).Error; err != nil {
t.Fatal(err)
}
if plan.DetailID != 20 || plan.PlaceholderCount != 1 || plan.EvidenceHash == "" {
t.Fatalf("plan=%+v", plan)
}
if reader.listCalls != 1 || reader.detailCalls != 1 {
t.Fatalf("calls list=%d detail=%d", reader.listCalls, reader.detailCalls)
}
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
t.Fatal(err)
}
if reader.listCalls != 1 {
t.Fatalf("completed job reran")
}
}
func TestRunMatchJobRejectsSKUAndStallConflict(t *testing.T) {
db := testDB(t)
record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "B#2", SpecKey: "黑色,L", SpecRaw: "黑色,L", SourceSKURaw: "SKU-A", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}}}
jobID := createMatchJob(t, db, []models.SYBInnerCodeRecord{record})
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: {ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-A", "A#1", ""), detail(21, "黑色,L", 1, "SKU-B", "B#2", "")}}}}
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
t.Fatal(err)
}
var saved models.SYBInnerCodeRecord
db.First(&saved)
if saved.Status != models.SYBInnerCodeSkipped || saved.ResultMessage != "原始 SKU 与档口货号冲突,不能自动选择" {
t.Fatalf("record=%+v", saved)
}
}
func TestRunMatchJobReservesDifferentDetailsForRecordsInOneBatch(t *testing.T) {
db := testDB(t)
base := func(order, sku, stall, code string) models.SYBInnerCodeRecord {
return models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: order, Stall: stall, SpecKey: "黑色,L", SpecRaw: "黑色,L", SourceSKURaw: sku, Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: code, Ordinal: 1, SourceRow: 2}}}
}
jobID := createMatchJob(t, db, []models.SYBInnerCodeRecord{base("ORDER-1", "SKU-A", "A#1", "IC-A"), base("ORDER-1", "SKU-B", "B#2", "IC-B")})
stock := sybclient.StockDetail{ID: 10, Details: []sybclient.DetailItem{detail(20, "黑色,L", 1, "SKU-A", "A#1", ""), detail(21, "黑色,L", 1, "SKU-B", "B#2", "")}}
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: stock}}
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
t.Fatal(err)
}
var plans []models.SYBInnerCodePlan
db.Order("record_id").Find(&plans)
if len(plans) != 2 || plans[0].DetailID == plans[1].DetailID {
t.Fatalf("plans=%+v", plans)
}
}
// --- #259/#273/#289 stall-matching regression tests (ported from cmautobuy) ---
func TestStallMatchesIgnoresWeightLikeNumberAsArticle(t *testing.T) {
// #273: "50公斤" must not be treated as if the article were the bare number 50.
item := detail(1, "50公斤,黑色", 1, "SKU-X", "", "")
if stallMatches("档口甲#50", item) {
t.Fatalf("weight-like text must not match numeric article 50")
}
}
func TestStallMatchesRejectsSubstringInsideLongCode(t *testing.T) {
// #273: a long code with internal digits (PDD256437) must not spuriously
// match a short numeric article (256) via substring containment.
item := detail(1, "黑色,L", 1, "PDD256437", "档口甲", "")
if stallMatches("档口甲#256", item) {
t.Fatalf("long code must not match numeric article 256 via substring")
}
}
func TestStallMatchesLeadingZeroEquivalenceRequiresStallName(t *testing.T) {
// #259/#273: "067" and "67" are equivalent articles only when the stall
// name also matches; a different stall name must not match.
sameStall := detail(1, "黑色,L", 1, "档口甲-067", "", "")
if !stallMatches("档口甲#67", sameStall) {
t.Fatalf("067 should be treated as equivalent to 67 when stall name matches")
}
differentStall := detail(2, "黑色,L", 1, "档口乙-067", "", "")
if stallMatches("档口甲#67", differentStall) {
t.Fatalf("067 must not match 67 when the stall name differs")
}
}
func TestStallMatchesNonNumericArticleRequiresExactToken(t *testing.T) {
item := detail(1, "黑色,L", 1, "ABC12", "", "")
if stallMatches("档口甲#AB", item) {
t.Fatalf("non-numeric article must require an exact token match, not substring")
}
exact := detail(2, "黑色,L", 1, "AB", "", "")
if !stallMatches("档口甲#AB", exact) {
t.Fatalf("exact non-numeric token should match")
}
}
func TestStallMatchesHandlesHashInsideStallName(t *testing.T) {
// Splits on the LAST '#' so a stall name that itself contains '#' still
// yields the correct article.
item := detail(1, "黑色,L", 1, "档口#甲-67", "", "")
if !stallMatches("档口#甲#67", item) {
t.Fatalf("stall name containing '#' should still resolve article via last '#'")
}
}
func TestStallMatchesProductSpecPrefixMatchesArticle(t *testing.T) {
// Non-numeric article: ProductSpec prefix match does not additionally
// require the stall name to appear in sku/variationSku.
item := detail(1, "AB 黑色,L", 1, "", "", "")
if !stallMatches("档口甲#AB", item) {
t.Fatalf("ProductSpec starting with the article should match")
}
notPrefix := detail(2, "黑色,ABL", 1, "", "", "")
if stallMatches("档口甲#AB", notPrefix) {
t.Fatalf("article appearing mid-spec (not as prefix) must not match")
}
}
func TestStallMatchesEmptyArticleFallsBackToStallName(t *testing.T) {
item := detail(1, "黑色,L", 1, "档口甲专柜", "", "")
if !stallMatches("档口甲#", item) {
t.Fatalf("empty article should fall back to stall-name containment")
}
}
// --- #289: existing-binding-preserving multi-piece assignment ---
func TestAssignExistingBoundItemsPreservesOutOfOrderBindings(t *testing.T) {
// Two single-piece candidates already carry codes, but the previously
// bound code (IC-2) sits on the LOWER-ID detail while the target order
// expects it second. A naive ID-order/index assignment would strip the
// 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("A#1", []string{"IC-1", "IC-2"}, []sybclient.DetailItem{d20, d21})
if reason != "" {
t.Fatalf("unexpected reason: %s", reason)
}
if assigned[1].ID != 20 {
t.Fatalf("expected detail 20 (already bound to IC-2) preserved at index 1, got %+v", assigned[1])
}
if assigned[0].ID != 21 {
t.Fatalf("expected the blank detail 21 filled in at index 0, got %+v", assigned[0])
}
}
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("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
// matching sequential/ID order relative to the target codes. The plan
// must leave the already-correct binding untouched and only place the
// missing code onto the still-blank detail.
db := testDB(t)
// SourceSKURaw is intentionally left blank: with the same source SKU on
// both candidate rows the source-SKU path would reject as "duplicate"
// before ever reaching the stall-based multi-item assignment being
// regression-tested here.
record := models.SYBInnerCodeRecord{BusinessDate: "2026-08-28", OrderNumber: "ORDER-1", Stall: "A#1", SpecKey: "黑色,L", SpecRaw: "黑色,L", Status: models.SYBInnerCodePending, CreatedBy: 1, ImportRequestID: uuid.NewString(), Items: []models.SYBInnerCodeItem{{BusinessDate: "2026-08-28", Code: "IC-1", Ordinal: 1, SourceRow: 2}, {BusinessDate: "2026-08-28", Code: "IC-2", Ordinal: 2, SourceRow: 3}}}
records := []models.SYBInnerCodeRecord{record}
jobID := createMatchJob(t, db, records)
recordID := records[0].ID
// Detail 20 (lower ID) already carries IC-2 (bound out of sequence);
// detail 21 (higher ID) is still blank and should receive IC-1.
stock := sybclient.StockDetail{ID: 10, Details: []sybclient.DetailItem{
detail(20, "黑色,L", 1, "SKU-1", "A#1", "IC-2"),
detail(21, "黑色,L", 1, "SKU-1", "A#1", ""),
}}
reader := &fakeMatchReader{rows: map[string][]sybclient.StockRow{"ORDER-1": {{ID: 10, Code: "ORDER-1"}}}, stocks: map[int64]sybclient.StockDetail{10: stock}}
if err := RunMatchJob(context.Background(), db, reader, jobID); err != nil {
t.Fatal(err)
}
var plan models.SYBInnerCodePlan
if err := db.First(&plan, "record_id = ?", recordID).Error; err != nil {
t.Fatal(err)
}
var items []plannedRemoteItem
if err := json.Unmarshal([]byte(plan.RemoteItemsJSON), &items); err != nil {
t.Fatal(err)
}
if len(items) != 2 {
t.Fatalf("items=%+v", items)
}
byCode := map[string]plannedRemoteItem{}
for _, item := range items {
byCode[item.Code] = item
}
if byCode["IC-2"].DetailID != 20 {
t.Fatalf("existing binding for IC-2 must stay on detail 20, got %+v", byCode["IC-2"])
}
if byCode["IC-1"].DetailID != 21 {
t.Fatalf("missing IC-1 should be assigned to the blank detail 21, got %+v", byCode["IC-1"])
}
}
type captureStarter struct {
jobID string
committed bool
}
func (c *captureStarter) Start(_ context.Context, db *gorm.DB, jobID string) error {
c.jobID = jobID
var count int64
db.Model(&models.SYBInnerCodeRecord{}).Count(&count)
c.committed = count > 0
return nil
}
func TestImportCreatesOneMatchJobAndStartsAfterCommit(t *testing.T) {
db := testDB(t)
starter := &captureStarter{}
service := NewService(db).WithMatchEnqueuer(starter)
rows := [][]any{{"2026-08-28", "ORDER-1", "店铺", "A#1", "SKU-1", "黑色,L", "IC-01", 1}}
result, err := importRows(t, service, uuid.NewString(), rows)
if err != nil {
t.Fatal(err)
}
if result.MatchJobID == "" || starter.jobID != result.MatchJobID || !starter.committed {
t.Fatalf("result=%+v starter=%+v", result, starter)
}
var count int64
db.Model(&models.SYBInnerCodeMatchJob{}).Count(&count)
if count != 1 {
t.Fatalf("jobs=%d", count)
}
}