feat: batch AI match SYB purchase specs (#188)

This commit is contained in:
QiuSW
2026-09-01 12:13:33 +08:00
parent 6ab2f62f39
commit 3ef0f72ff9
12 changed files with 553 additions and 27 deletions
+1
View File
@@ -97,6 +97,7 @@ var AdminAPIs = []APIPermission{
{"查看采购任务", "/api/admin/v1/purchase-tasks", "GET", true},
{"预检批量采购", "/api/admin/v1/purchase-tasks/batch-preview", "POST", true},
{"批量 AI 匹配采购规格", "/api/admin/v1/purchase-tasks/batch-spec-match", "POST", true},
{"批量创建采购任务", "/api/admin/v1/purchase-tasks/batch", "POST", true},
{"批量重试采购任务", "/api/admin/v1/purchase-tasks/batch-retry", "POST", true},
{"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true},
@@ -53,3 +53,12 @@ func TestPurchaserMayCreateStockPurchase(t *testing.T) {
}
t.Fatal("missing purchaser stock purchase permission")
}
func TestPurchaserMayRunBatchSpecMatch(t *testing.T) {
for _, permission := range PurchaserAPIs() {
if permission.Method == "POST" && permission.Path == "/api/admin/v1/purchase-tasks/batch-spec-match" {
return
}
}
t.Fatal("missing purchaser batch spec match permission")
}
@@ -0,0 +1,196 @@
package purchase
import (
"context"
"errors"
"fmt"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/purchaserule"
"go-admin/app/goauto/shopeeproduct"
"github.com/google/uuid"
)
const (
BatchSpecMatchAutoConfirmed = "auto_confirmed"
BatchSpecMatchPending = "pending"
BatchSpecMatchFailed = "failed"
BatchSpecMatchSkipped = "skipped"
)
type BatchSpecMatchRequest struct {
SYBProductIDs []uint64 `json:"sybProductIds"`
}
type BatchSpecMatchItem struct {
SYBProductID uint64 `json:"sybProductId"`
OrderCode string `json:"orderCode,omitempty"`
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
Status string `json:"status"`
Source string `json:"source,omitempty"`
Confidence *float64 `json:"confidence,omitempty"`
Reason string `json:"reason"`
}
type BatchSpecMatchResponse struct {
Items []BatchSpecMatchItem `json:"items"`
AutoConfirmedCount int `json:"autoConfirmedCount"`
PendingCount int `json:"pendingCount"`
FailedCount int `json:"failedCount"`
SkippedCount int `json:"skippedCount"`
}
func (s *Service) BatchSpecMatch(ctx context.Context, req BatchSpecMatchRequest) (BatchSpecMatchResponse, error) {
ids, err := validateBatchIDs(req.SYBProductIDs)
if err != nil {
return BatchSpecMatchResponse{}, err
}
dataset, err := s.loadBatchPreviewDataset(ctx, ids)
if err != nil {
return BatchSpecMatchResponse{}, internal(err)
}
guard, ruleReason, err := s.readOnlyPriceGuard(ctx)
if err != nil {
return BatchSpecMatchResponse{}, err
}
settings, err := aimatching.NewService(s.DB).Settings(ctx)
if err != nil {
return BatchSpecMatchResponse{}, internal(err)
}
response := BatchSpecMatchResponse{Items: make([]BatchSpecMatchItem, 0, len(ids))}
for _, id := range ids {
item := BatchSpecMatchItem{SYBProductID: id, Status: BatchSpecMatchFailed}
syb, found := dataset.sybByID[id]
if found {
item.OrderCode, item.ShopeeProductID = syb.OrderCode, syb.ShopeeProductID
}
preview := s.previewFromDataset(ctx, id, dataset, false, guard)
if ruleReason != "" && preview.Eligible {
preview.Eligible, preview.ReasonCode, preview.Reason, preview.NextAction = false, purchaserule.CodeCurrentRuleMissing, ruleReason, "refresh"
}
stage := processStageFromDataset(id, dataset, preview)
if stage.Stage != ProcessStageColorMapping {
item.Status, item.Reason = BatchSpecMatchSkipped, "当前不是颜色待匹配阶段,无需处理"
response.SkippedCount++
response.Items = append(response.Items, item)
continue
}
shopee := dataset.shopeeByID[*syb.ShopeeProductID]
pdd := dataset.pddByID[*shopee.PDDProductID]
candidates, usable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize)
if !usable {
item.Reason = "关联的 PDD 商品没有完整可选规格"
response.FailedCount++
response.Items = append(response.Items, item)
continue
}
mappedColor, mappedSize, _ := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
if mappedColor != "" && !containsValue(candidates.Colors, mappedColor) {
mappedColor = ""
}
if mappedSize != "" && !containsValue(candidates.Sizes, mappedSize) {
mappedSize = ""
}
request := aimatching.MatchRequest{Colors: candidates.Colors, Sizes: candidates.Sizes}
if mappedColor == "" {
request.TargetColor = syb.TargetColor
}
if mappedSize == "" {
request.TargetSize = syb.TargetSize
}
matched, matchErr := s.matcher().Resolve(ctx, request)
if matchErr != nil {
item.Reason = batchSpecMatchErrorMessage(matchErr)
response.FailedCount++
response.Items = append(response.Items, item)
continue
}
item.Source, item.Confidence = matched.Source, matched.Decision.Confidence
autoConfirm := matched.Source == aimatching.SourceAI && matched.Decision.Confidence != nil && *matched.Decision.Confidence >= settings.AutoConfirmMinConfidence && strings.TrimSpace(matched.Decision.Reason) != ""
if !autoConfirm {
item.Status, item.Reason = BatchSpecMatchPending, "匹配结果未达到自动确认阈值,请人工确认"
if strings.TrimSpace(matched.Decision.Reason) != "" {
item.Reason += ":" + strings.TrimSpace(matched.Decision.Reason)
}
response.PendingCount++
response.Items = append(response.Items, item)
continue
}
writes, buildErr := resolvedMappingWrites(shopee.SpecsJSON, request, matched, shopeeproduct.MappingStatusConfirmed)
if buildErr != nil {
item.Reason = buildErr.Error()
response.FailedCount++
response.Items = append(response.Items, item)
continue
}
if _, err := shopeeproduct.NewService(s.DB).ApplyResolvedMappings(ctx, shopee.ID, uuid.NewString(), settings.AutoConfirmMinConfidence, writes); err != nil {
item.Reason = "规格映射保存失败,请刷新后重试"
response.FailedCount++
response.Items = append(response.Items, item)
continue
}
if err := s.DB.WithContext(ctx).First(&shopee, shopee.ID).Error; err == nil {
dataset.shopeeByID[shopee.ID] = shopee
}
item.Status, item.Reason = BatchSpecMatchAutoConfirmed, strings.TrimSpace(matched.Decision.Reason)
response.AutoConfirmedCount++
response.Items = append(response.Items, item)
}
return response, nil
}
func resolvedMappingWrites(raw string, request aimatching.MatchRequest, matched aimatching.MatchResult, status string) ([]shopeeproduct.ResolvedMappingItem, error) {
specs, err := shopeeproduct.Unmarshal(raw)
if err != nil {
return nil, errors.New("蝦皮商品规格数据无效")
}
writes := make([]shopeeproduct.ResolvedMappingItem, 0, 2)
add := func(role, target, pddValue string) error {
if strings.TrimSpace(target) == "" {
return nil
}
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
if value.Name == target {
writes = append(writes, shopeeproduct.ResolvedMappingItem{Dimension: dimension.Name, ValueName: target, PDDValue: pddValue, Source: matched.Source, Status: status, Confidence: matched.Decision.Confidence, Reason: matched.Decision.Reason})
return nil
}
}
}
return fmt.Errorf("蝦皮商品中未找到目标规格“%s”", target)
}
if err := add(shopeeproduct.RoleColor, request.TargetColor, matched.MappedColor); err != nil {
return nil, err
}
if err := add(shopeeproduct.RoleSize, request.TargetSize, matched.MappedSize); err != nil {
return nil, err
}
if len(writes) == 0 {
return nil, errors.New("没有需要写入的规格映射")
}
return writes, nil
}
func batchSpecMatchErrorMessage(err error) string {
var matchErr *aimatching.Error
if errors.As(err, &matchErr) {
switch matchErr.Code {
case aimatching.CodeNotConfigured:
return "AI 匹配未启用或未配置,请先在设置中启用"
case aimatching.CodeNoMatch:
return "AI 未找到可靠的 PDD 规格"
case aimatching.CodeProviderUnavailable:
return "AI 匹配服务暂时不可用,请稍后重试"
case aimatching.CodeInvalidSetting:
return "AI 匹配设置无效,请检查配置"
}
}
return "规格匹配失败,请稍后重试"
}
@@ -0,0 +1,132 @@
package purchase
import (
"context"
"errors"
"testing"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
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]
}
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 = "象牙白"
return testService(db), f
}
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 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 TestBatchSpecMatchLeavesLowConfidenceForManualHandling(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 != 1 || response.AutoConfirmedCount != 0 {
t.Fatalf("unexpected low-confidence result: %+v err=%v", response, err)
}
mapping := savedColorMapping(t, service, f.shopee.ID)
if mapping == nil || mapping.PDDValue != "旧白色" || mapping.Status != shopeeproduct.MappingStatusConfirmed {
t.Fatalf("low-confidence result changed the saved mapping: %+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 != ProcessStageColorMapping {
t.Fatalf("pending mapping unexpectedly became 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 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)
}
}
+20
View File
@@ -142,6 +142,26 @@ func (h Handler) AdminBatchPreview(c *gin.Context) {
writeAdminData(c, response)
}
func (h Handler) AdminBatchSpecMatch(c *gin.Context) {
if !allowedOperator(c) {
return
}
var req BatchSpecMatchRequest
if !decode(c, &req) {
return
}
service, ok := h.service(c)
if !ok {
return
}
response, err := service.BatchSpecMatch(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeAdminData(c, response)
}
func (h Handler) AdminBatchCreate(c *gin.Context) {
if !allowedOperator(c) {
return
+1
View File
@@ -28,6 +28,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
admin.GET("", h.AdminList)
admin.POST("/batch-preview", h.AdminBatchPreview)
admin.POST("/batch-spec-match", h.AdminBatchSpecMatch)
admin.POST("/batch", h.AdminBatchCreate)
admin.POST("/batch-retry", h.AdminBatchRetry)
admin.POST("/stock", h.AdminCreateStock)
@@ -0,0 +1,75 @@
package shopeeproduct
import (
"context"
"strings"
)
// ResolvedMappingItem is one mapping produced by the narrowly scoped SYB
// batch-match entry point. Confirmed writes are intentionally restricted to
// auditable AI decisions; the general SetMapping path remains pending-first.
type ResolvedMappingItem struct {
Dimension string
ValueName string
PDDValue string
Source string
Status string
Confidence *float64
Reason string
}
// ApplyResolvedMappings atomically applies the color/size mappings needed by
// one SYB detail row. It is independent from SetMapping so #188's explicit
// high-confidence exception cannot change existing callers' pending semantics.
func (service *Service) ApplyResolvedMappings(ctx context.Context, id uint64, requestID string, minimumConfidence float64, items []ResolvedMappingItem) (SaveResponse, error) {
if len(items) == 0 || len(items) > 2 {
return SaveResponse{}, invalidRequest("必须包含 1 至 2 个待写入规格映射")
}
if minimumConfidence < 0 || minimumConfidence > 1 {
return SaveResponse{}, invalidRequest("自动确认阈值无效")
}
seen := make(map[string]bool, len(items))
for _, item := range items {
key := strings.TrimSpace(item.Dimension) + "\x00" + strings.TrimSpace(item.ValueName)
if seen[key] {
return SaveResponse{}, invalidRequest("待写入规格映射重复")
}
seen[key] = true
if item.Source != MappingSourceExactMatch && item.Source != MappingSourceAIMatch {
return SaveResponse{}, invalidRequest("批量匹配来源无效")
}
if item.Status != MappingStatusConfirmed || item.Source != MappingSourceAIMatch || item.Confidence == nil || *item.Confidence < minimumConfidence || strings.TrimSpace(item.Reason) == "" {
return SaveResponse{}, invalidRequest("自动确认仅允许带置信度和理由的 AI 匹配")
}
if err := service.validatePDDMappingTarget(ctx, id, item.Dimension, item.ValueName, strings.TrimSpace(item.PDDValue)); err != nil {
return SaveResponse{}, err
}
}
return service.mutateSpecs(ctx, id, requestID, func(specs []SpecDimension) ([]SpecDimension, error) {
for _, item := range items {
updated := false
for di, dimension := range specs {
if dimension.Name != item.Dimension {
continue
}
for vi, value := range dimension.Values {
if value.Name != item.ValueName {
continue
}
specs[di].Values[vi].Mapping = &Mapping{
PDDValue: strings.TrimSpace(item.PDDValue), Source: item.Source, Status: item.Status,
Confidence: item.Confidence, Reason: strings.TrimSpace(item.Reason),
}
updated = true
break
}
break
}
if !updated {
return nil, valueNotFound()
}
}
return specs, nil
})
}