package service import ( "context" "errors" "testing" "time" "cmautobuy/admin/model" "cmautobuy/admin/repository" ) func TestAISpecMatchIdentity_相同业务上下文去重且数据变化后失效(t *testing.T) { base := repository.SybOrderContext{ Order: model.SybOrder{SybID: "SYB-1", ShopeeGoodsID: "SP-1", SpecKey: "黑色,M", ProductSpec: "黑色,M"}, PddGoodsID: "737116531267", PddUpdatedAt: "2026-08-14T01:00:00Z", PddSkusJSON: collectedAIChoices, } same := base same.Order.SybID = "SYB-2" same.Order.OrderNo = "ORDER-2" if aiSpecMatchIdentity(base) != aiSpecMatchIdentity(same) { t.Fatal("同一蝦皮商品、来源规格和 PDD 上下文应归为同一个批次调用") } changed := same changed.PddSkusJSON = collectedRuleChoices if aiSpecMatchIdentity(base) == aiSpecMatchIdentity(changed) { t.Fatal("PDD 候选变化后不得复用旧批次结果") } } func TestBatchItemFromMatch_结果分类和置信度零值(t *testing.T) { item := model.AIMatchBatchItem{ItemID: "AII-1"} got := batchItemFromMatch(item, AISpecMatchResult{Outcome: "rejected", Message: "需要人工", ConfidenceBPS: 0}, nil) if got.Status != "manual" || got.ConfidenceSet { t.Fatalf("拒绝结果应进入人工队列且未提供置信度: %+v", got) } got = batchItemFromMatch(item, AISpecMatchResult{Outcome: "ai_saved", Source: "ai", ConfidenceBPS: 0, ConfidenceSet: true}, nil) if got.Status != "succeeded" || !got.ConfidenceSet || got.ConfidenceBPS != 0 { t.Fatalf("模型明确返回的 0%% 置信度也应被准确记录: %+v", got) } got = batchItemFromMatch(item, AISpecMatchResult{}, errors.New("provider failed")) if got.Status != "failed" || got.Outcome != "failed" { t.Fatalf("调用错误应记为失败: %+v", got) } } func TestAIMatchBatch_同规格只调用一次并记录逐条结果(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContext(t, db, "SYB-BATCH-1") seedWorkflowOrder(t, db, "SYB-BATCH-2", "SP-AI", "黑色,M") fake := &fakeAIModelClient{response: AIModelMatchResponse{ Conclusion: "match", CandidateID: "C01", ConfidenceBPS: 9200, Reason: "颜色和尺码一致", }} snapshot := testAIMatchSnapshot(fake) snapshot.Provider.TimeoutSeconds = 30 snapshot.Provider.MaxConcurrency = 2 batch, err := CreateAIMatchBatch(db, &actor, []string{"SYB-BATCH-1", "SYB-BATCH-2"}, snapshot, time.Now()) if err != nil { t.Fatal(err) } if err := RunAIMatchBatch(context.Background(), db, actor, batch.BatchID, snapshot); err != nil { t.Fatal(err) } if fake.calls != 1 { t.Fatalf("相同业务上下文应只调用模型一次,实际 %d 次", fake.calls) } view, err := GetAIMatchBatchView(db, &actor, batch.BatchID) if err != nil { t.Fatal(err) } if view.Status != "succeeded" || view.Processed != 2 || view.Success != 1 || view.Reused != 1 { t.Fatalf("批次汇总错误: %+v", view) } if len(view.Items) != 2 || view.Items[0].SourceText != "AI匹配" || view.Items[1].Status != "reused" { t.Fatalf("逐条结果或来源错误: %+v", view.Items) } } func TestAIMatchBatch_重启中断和查看权限(t *testing.T) { db := newTestDB(t) actor := seedAIMatchContext(t, db, "SYB-BATCH-INTERRUPT") snapshot := testAIMatchSnapshot(&fakeAIModelClient{}) snapshot.Provider.TimeoutSeconds = 30 snapshot.Provider.MaxConcurrency = 1 batch, err := CreateAIMatchBatch(db, &actor, []string{"SYB-BATCH-INTERRUPT"}, snapshot, time.Now()) if err != nil { t.Fatal(err) } if count, err := InterruptRunningAIMatchBatches(db, time.Now()); err != nil || count != 1 { t.Fatalf("中断未完成批次失败: count=%d err=%v", count, err) } view, err := GetAIMatchBatchView(db, &actor, batch.BatchID) if err != nil || view.Status != "interrupted" || view.Failed != 1 || view.Items[0].Status != "interrupted" { t.Fatalf("中断状态错误: view=%+v err=%v", view, err) } other := model.User{UserID: "USR-OTHER", Username: "other", PasswordHash: "test-hash", Role: model.RolePurchaser, Status: model.UserActive, PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()} if err := repository.CreateUser(db, other); err != nil { t.Fatal(err) } if _, err := GetAIMatchBatchView(db, &other, batch.BatchID); !errors.Is(err, ErrForbidden) { t.Fatalf("其他采购员不应查看本批次: %v", err) } admin := other admin.Role = model.RoleAdmin if _, err := GetAIMatchBatchView(db, &admin, batch.BatchID); err != nil { t.Fatalf("管理员应可查看任意批次: %v", err) } }