340 lines
17 KiB
Go
340 lines
17 KiB
Go
package purchase
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"go-admin/app/goauto/aimatching"
|
|
"go-admin/app/goauto/models"
|
|
"go-admin/app/goauto/shopeeproduct"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type batchSpecMatcher struct {
|
|
results []aimatching.MatchResult
|
|
errs []error
|
|
calls int
|
|
}
|
|
|
|
func (matcher *batchSpecMatcher) Resolve(_ context.Context, _ aimatching.MatchRequest) (aimatching.MatchResult, error) {
|
|
at := matcher.calls
|
|
matcher.calls++
|
|
if at < len(matcher.errs) && matcher.errs[at] != nil {
|
|
return aimatching.MatchResult{}, matcher.errs[at]
|
|
}
|
|
if at >= len(matcher.results) {
|
|
return aimatching.MatchResult{}, errors.New("unexpected matcher call")
|
|
}
|
|
return matcher.results[at], nil
|
|
}
|
|
|
|
func unresolvedBatchSpecFixture(t *testing.T) (*Service, fixture) {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
f := seed(t, db, liveCaps(), true)
|
|
pddSpecs := `[{"name":"颜色","role":"color","values":[{"name":"米白色","selectable":true,"priceCent":2000},{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`
|
|
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", pddSpecs).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shopeeSpecs := `[{"name":"颜色分类","role":"color","values":[{"name":"象牙白","source":"import","mapping":{"pddValue":"旧白色","source":"manual","status":"confirmed"}}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import","mapping":{"pddValue":"XL","source":"manual","status":"confirmed"}}]}]`
|
|
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", shopeeSpecs).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.syb.TargetColor = "象牙白"
|
|
seedAvailableSKUCombinations(t, db, f.pdd.ID, []pddSKUCombination{{Color: "米白色", Size: "XL"}, {Color: "白色", Size: "XL"}})
|
|
return testService(db), f
|
|
}
|
|
|
|
func seedAvailableSKUCombinations(t *testing.T, db *gorm.DB, pddID uint64, combinations []pddSKUCombination) {
|
|
t.Helper()
|
|
rule := models.CollectionRule{Name: "SKU 组合测试规则", ContentJSON: `{}`}
|
|
if err := db.Create(&rule).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := models.CollectionTask{PDDProductID: &pddID, RuleID: rule.ID, Status: models.TaskStatusCompleted, URLSnapshot: "https://mobile.yangkeduo.com/goods.html?goods_id=test", GoodsIDSnapshot: "test", RuleSnapshot: `{}`}
|
|
if err := db.Create(&task).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
colorDimension := models.CollectionDimension{TaskID: task.ID, Key: "color", Name: "颜色", SortOrder: 0}
|
|
sizeDimension := models.CollectionDimension{TaskID: task.ID, Key: "size", Name: "尺码", SortOrder: 1}
|
|
if err := db.Create(&colorDimension).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(&sizeDimension).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
colorValues := make(map[string]uint64)
|
|
sizeValues := make(map[string]uint64)
|
|
for index, combination := range combinations {
|
|
if colorValues[combination.Color] == 0 {
|
|
value := models.CollectionDimensionValue{DimensionID: colorDimension.ID, Value: combination.Color, SortOrder: index}
|
|
if err := db.Create(&value).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
colorValues[combination.Color] = value.ID
|
|
}
|
|
if sizeValues[combination.Size] == 0 {
|
|
value := models.CollectionDimensionValue{DimensionID: sizeDimension.ID, Value: combination.Size, SortOrder: index}
|
|
if err := db.Create(&value).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sizeValues[combination.Size] = value.ID
|
|
}
|
|
sku := models.CollectionSKU{TaskID: task.ID, SpecKey: combination.Color + "\x00" + combination.Size, PriceCent: 2000, Available: true, Complete: true}
|
|
if err := db.Create(&sku).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, valueID := range []uint64{colorValues[combination.Color], sizeValues[combination.Size]} {
|
|
if err := db.Create(&models.CollectionSKUValue{SKUID: sku.ID, DimensionValueID: valueID}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
if err := db.Delete(&rule).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func aiBatchResult(confidence float64) aimatching.MatchResult {
|
|
request := aimatching.MatchRequest{TargetColor: "象牙白", Colors: []string{"米白色", "白色"}, Sizes: []string{"XL"}}
|
|
result := aimatching.RecordedMatch(request, aimatching.SourceAI, "米白色", "", "颜色语义与候选唯一对应")
|
|
result.Decision.Confidence = &confidence
|
|
return result
|
|
}
|
|
|
|
func savedColorMapping(t *testing.T, service *Service, productID uint64) *shopeeproduct.Mapping {
|
|
t.Helper()
|
|
var product models.ShopeeProduct
|
|
if err := service.DB.First(&product, productID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return specs[0].Values[0].Mapping
|
|
}
|
|
|
|
func exactBatchSpecFixture(t *testing.T) (*Service, fixture) {
|
|
t.Helper()
|
|
db := testDB(t)
|
|
f := seed(t, db, liveCaps(), false)
|
|
pddSpecs := `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`
|
|
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", pddSpecs).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
shopeeSpecs := `[{"name":"颜色","role":"color","values":[{"name":"黑色","source":"import"}]},{"name":"尺码","role":"size","values":[{"name":"XL","source":"import"}]}]`
|
|
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", shopeeSpecs).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedAvailableSKUCombinations(t, db, f.pdd.ID, []pddSKUCombination{{Color: "黑色", Size: "XL"}})
|
|
return testService(db), f
|
|
}
|
|
|
|
func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) {
|
|
service, f := exactBatchSpecFixture(t)
|
|
matcher := &batchSpecMatcher{}
|
|
service.Matcher = matcher
|
|
|
|
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
|
t.Fatalf("unpersisted exact match was not gated: %+v err=%v", before, err)
|
|
}
|
|
blocked, err := service.BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID})
|
|
if err != nil || blocked.CreatedCount != 0 || blocked.FailedCount != 1 || blocked.Items[0].ReasonCode != CodeMappingRequired {
|
|
t.Fatalf("batch create bypassed persisted mapping gate: %+v err=%v", blocked, err)
|
|
}
|
|
|
|
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || matched.AutoConfirmedCount != 1 || matched.Items[0].Source != aimatching.SourceExact || matcher.calls != 0 {
|
|
t.Fatalf("exact match was not persisted locally: %+v calls=%d err=%v", matched, matcher.calls, err)
|
|
}
|
|
var product models.ShopeeProduct
|
|
if err := service.DB.First(&product, f.shopee.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
specs, err := shopeeproduct.Unmarshal(product.SpecsJSON)
|
|
if err != nil || len(specs) != 2 || specs[0].Values[0].Mapping == nil || specs[1].Values[0].Mapping == nil {
|
|
t.Fatalf("exact mappings were not saved: %+v err=%v", specs, err)
|
|
}
|
|
for _, dimension := range specs {
|
|
mapping := dimension.Values[0].Mapping
|
|
if mapping.Source != shopeeproduct.MappingSourceExactMatch || mapping.Status != shopeeproduct.MappingStatusConfirmed {
|
|
t.Fatalf("unexpected saved exact mapping: %+v", mapping)
|
|
}
|
|
}
|
|
after, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || !after.Items[0].Eligible || after.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
|
t.Fatalf("persisted exact match did not unlock purchase: %+v err=%v", after, err)
|
|
}
|
|
}
|
|
|
|
func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
|
|
service, f := exactBatchSpecFixture(t)
|
|
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
|
|
"parse_status": models.SYBParseStatusUncertain, "manually_confirmed": false,
|
|
}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
matcher := &batchSpecMatcher{}
|
|
service.Matcher = matcher
|
|
|
|
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
|
t.Fatalf("extracted uncertain specs did not enter matching: %+v err=%v", before, err)
|
|
}
|
|
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || matched.AutoConfirmedCount != 1 || matcher.calls != 0 {
|
|
t.Fatalf("extracted uncertain specs did not allow exact match: %+v calls=%d err=%v", matched, matcher.calls, err)
|
|
}
|
|
after, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || !after.Items[0].Eligible || after.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
|
t.Fatalf("saved mapping did not unlock extracted uncertain specs: %+v err=%v", after, err)
|
|
}
|
|
}
|
|
|
|
func TestBatchSpecMatchAutoConfirmsHighConfidenceAndRefreshesReadiness(t *testing.T) {
|
|
service, f := unresolvedBatchSpecFixture(t)
|
|
duplicate := f.syb
|
|
duplicate.ID = 0
|
|
duplicate.OrderCode = "SYB-2"
|
|
duplicate.DetailID++
|
|
if err := service.DB.Create(&duplicate).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
matcher := &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.95)}}
|
|
service.Matcher = matcher
|
|
|
|
response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID, duplicate.ID}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response.AutoConfirmedCount != 1 || response.SkippedCount != 1 || matcher.calls != 1 {
|
|
t.Fatalf("unexpected mixed result: %+v calls=%d", response, matcher.calls)
|
|
}
|
|
mapping := savedColorMapping(t, service, f.shopee.ID)
|
|
if mapping == nil || mapping.Status != shopeeproduct.MappingStatusConfirmed || mapping.Source != shopeeproduct.MappingSourceAIMatch || mapping.Confidence == nil || *mapping.Confidence != 0.95 {
|
|
t.Fatalf("high-confidence mapping was not independently confirmed: %+v", mapping)
|
|
}
|
|
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
|
t.Fatalf("readiness did not reflect confirmed mapping: %+v err=%v", preview, err)
|
|
}
|
|
}
|
|
|
|
func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
|
|
service, f := unresolvedBatchSpecFixture(t)
|
|
|
|
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || len(preview.Items) != 1 {
|
|
t.Fatalf("preview failed: %+v err=%v", preview, err)
|
|
}
|
|
item := preview.Items[0]
|
|
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || item.Eligible || item.ProcessStage != ProcessStageColorMapping {
|
|
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
|
|
}
|
|
}
|
|
|
|
// #190 放开了 AI 匹配的解析状态与可售 SKU 组合前置:两者都不再阻断匹配,
|
|
// 采购侧自身的解析门禁(previewFromDataset)不在本次放开范围内,仍然生效。
|
|
func TestBatchPreviewAllowsUncertainParseAndMissingSKUCombination(t *testing.T) {
|
|
service, f := unresolvedBatchSpecFixture(t)
|
|
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusUncertain).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || !preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "" {
|
|
t.Fatalf("uncertain parse still blocked AI matching: %+v err=%v", preview, err)
|
|
}
|
|
|
|
if err := service.DB.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("parse_status", models.SYBParseStatusSuccess).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.DB.Where("pdd_product_id = ?", f.pdd.ID).Delete(&models.CollectionTask{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
preview, err = service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || !preview.Items[0].AIMatchEligible || preview.Items[0].AIMatchDisabledReason != "" {
|
|
t.Fatalf("missing SKU combination still blocked AI matching: %+v err=%v", preview, err)
|
|
}
|
|
}
|
|
|
|
func TestBatchSpecMatchAndPreviewAllowMissingSKUCombinationEvidence(t *testing.T) {
|
|
service, f := unresolvedBatchSpecFixture(t)
|
|
if err := service.DB.Where("pdd_product_id = ?", f.pdd.ID).Delete(&models.CollectionTask{}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}
|
|
|
|
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || matched.AutoConfirmedCount != 1 || matched.PendingCount != 0 {
|
|
t.Fatalf("AI result without SKU evidence was not confirmed: %+v err=%v", matched, err)
|
|
}
|
|
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || len(preview.Items) != 1 || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
|
t.Fatalf("missing SKU evidence unexpectedly blocked purchase: %+v err=%v", preview, err)
|
|
}
|
|
}
|
|
|
|
func TestBatchSpecMatchAutoConfirmsReturnedAIMatchRegardlessOfConfidence(t *testing.T) {
|
|
service, f := unresolvedBatchSpecFixture(t)
|
|
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.6)}}
|
|
|
|
response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || response.PendingCount != 0 || response.AutoConfirmedCount != 1 {
|
|
t.Fatalf("low-confidence AI result was not auto-confirmed: %+v err=%v", response, err)
|
|
}
|
|
mapping := savedColorMapping(t, service, f.shopee.ID)
|
|
if mapping == nil || mapping.PDDValue != "米白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed || mapping.Confidence == nil || *mapping.Confidence != 0.6 {
|
|
t.Fatalf("AI result was not saved as confirmed: %+v", mapping)
|
|
}
|
|
preview, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || !preview.Items[0].Eligible || preview.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
|
t.Fatalf("saved AI result did not become purchase-ready: %+v err=%v", preview, err)
|
|
}
|
|
}
|
|
|
|
func TestBatchSpecMatchReportsProviderFailureWithoutWriting(t *testing.T) {
|
|
service, f := unresolvedBatchSpecFixture(t)
|
|
service.Matcher = &batchSpecMatcher{errs: []error{&aimatching.Error{Code: aimatching.CodeProviderUnavailable, Message: "provider down"}}}
|
|
|
|
response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || response.FailedCount != 1 || response.Items[0].Status != BatchSpecMatchFailed {
|
|
t.Fatalf("unexpected provider failure result: %+v err=%v", response, err)
|
|
}
|
|
if mapping := savedColorMapping(t, service, f.shopee.ID); mapping == nil || mapping.PDDValue != "旧白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed {
|
|
t.Fatalf("provider failure changed the existing mapping: %+v", mapping)
|
|
}
|
|
}
|
|
|
|
func TestBatchSpecMatchDoesNotConfirmInvalidSKUCombination(t *testing.T) {
|
|
service, f := unresolvedBatchSpecFixture(t)
|
|
pddSpecs := `[{"name":"颜色","role":"color","values":[{"name":"米白色","selectable":true,"priceCent":2000},{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true},{"name":"L","selectable":true}]}]`
|
|
if err := service.DB.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", pddSpecs).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedAvailableSKUCombinations(t, service.DB, f.pdd.ID, []pddSKUCombination{{Color: "米白色", Size: "L"}, {Color: "白色", Size: "XL"}})
|
|
service.Matcher = &batchSpecMatcher{results: []aimatching.MatchResult{aiBatchResult(0.95)}}
|
|
|
|
response, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
|
if err != nil || response.PendingCount != 1 || response.AutoConfirmedCount != 0 || response.Items[0].Reason != "AI 结果不是当前可售的 PDD 规格组合,请人工确认" {
|
|
t.Fatalf("invalid SKU combination was not held for manual review: %+v err=%v", response, err)
|
|
}
|
|
if mapping := savedColorMapping(t, service, f.shopee.ID); mapping == nil || mapping.PDDValue != "旧白色" {
|
|
t.Fatalf("invalid combination changed the saved mapping: %+v", mapping)
|
|
}
|
|
}
|
|
|
|
func TestBatchSpecMatchErrorMessageDoesNotExposeProviderCause(t *testing.T) {
|
|
err := &aimatching.Error{Code: aimatching.CodeProviderUnavailable, Message: "safe", Cause: errors.New("secret upstream detail")}
|
|
if got := batchSpecMatchErrorMessage(err); got != "AI 匹配服务暂时不可用,请稍后重试" {
|
|
t.Fatalf("unsafe or unexpected message: %q", got)
|
|
}
|
|
}
|