feat: 增加顺运宝批量 AI 规格匹配 (#202)
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
const MaxAIMatchBatchItems = 100
|
||||
|
||||
type AIMatchBatchView struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
Model string `json:"model"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Total int `json:"total"`
|
||||
Processed int `json:"processed"`
|
||||
Success int `json:"success"`
|
||||
Reused int `json:"reused"`
|
||||
Manual int `json:"manual"`
|
||||
Failed int `json:"failed"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
Running bool `json:"running"`
|
||||
Items []AIMatchBatchItemView `json:"items"`
|
||||
}
|
||||
|
||||
type AIMatchBatchItemView struct {
|
||||
SybID string `json:"syb_id"`
|
||||
Status string `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
Outcome string `json:"outcome"`
|
||||
Message string `json:"message"`
|
||||
SourceText string `json:"source_text"`
|
||||
OptionKey string `json:"option_key"`
|
||||
ConfidenceText string `json:"confidence_text"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
}
|
||||
|
||||
func CreateAIMatchBatch(db *sql.DB, actor *model.User, sybIDs []string, snapshot AIMatchSnapshot, now time.Time) (*model.AIMatchBatch, error) {
|
||||
if actor == nil || !actor.IsActive() {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
ids := make([]string, 0, len(sybIDs))
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range sybIDs {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
if utf8.RuneCountInString(id) > 191 {
|
||||
return nil, invalidFieldInput("ids", "货运单明细编号过长")
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, invalidFieldInput("ids", "请先勾选要匹配的顺运宝商品")
|
||||
}
|
||||
if len(ids) > MaxAIMatchBatchItems {
|
||||
return nil, invalidFieldInput("ids", "一次最多匹配 %d 条商品", MaxAIMatchBatchItems)
|
||||
}
|
||||
if snapshot.Provider.ProviderID == "" || snapshot.Client == nil || snapshot.Secret == "" {
|
||||
return nil, fmt.Errorf("AI 服务商运行快照不可用")
|
||||
}
|
||||
batchID, err := randomID("AIM-", 16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createdAt := now.UTC().Format(model.TimeLayout)
|
||||
batch := &model.AIMatchBatch{BatchID: batchID, Status: "queued", CreatedByUserID: actor.UserID,
|
||||
TotalCount: len(ids), ProviderID: snapshot.Provider.ProviderID, ProviderName: snapshot.Provider.Name,
|
||||
ProviderBaseURL: snapshot.Provider.BaseURL, Model: snapshot.Provider.Model,
|
||||
TimeoutSeconds: snapshot.Provider.TimeoutSeconds, MaxConcurrency: snapshot.Provider.MaxConcurrency,
|
||||
ConfidenceThresholdBPS: snapshot.Provider.ConfidenceThresholdBPS,
|
||||
ConfigFingerprint: snapshot.ConfigFingerprint, RulesVersion: SpecMatchRulesVersion,
|
||||
PromptVersion: AISpecMatchPromptVersion, CreatedAt: createdAt}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
leaders := map[string]string{}
|
||||
items := make([]model.AIMatchBatchItem, 0, len(ids))
|
||||
for position, sybID := range ids {
|
||||
orderContext, err := repository.GetSybOrderContext(tx, sybID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orderContext == nil {
|
||||
return nil, invalidFieldInput("ids", "顺运宝明细 %s 已不存在,请刷新页面后重试", sybID)
|
||||
}
|
||||
identity := aiSpecMatchIdentity(*orderContext)
|
||||
leader := leaders[identity]
|
||||
if leader == "" {
|
||||
leader, leaders[identity] = sybID, sybID
|
||||
}
|
||||
itemID, err := randomID("AII-", 16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, model.AIMatchBatchItem{ItemID: itemID, BatchID: batchID, SybID: sybID,
|
||||
IdentityHash: identity, LeaderSybID: leader, ContextVersion: mappingContextVersion(*orderContext),
|
||||
Position: position + 1, Status: "queued"})
|
||||
}
|
||||
if err := repository.InsertAIMatchBatch(tx, *batch, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func aiSpecMatchIdentity(c repository.SybOrderContext) string {
|
||||
payload, _ := json.Marshal([]string{c.Order.ShopeeGoodsID, c.Order.SpecKey, c.Order.ProductSpec,
|
||||
c.PddGoodsID, c.PddUpdatedAt, c.PddSkusJSON, SpecMatchRulesVersion})
|
||||
return fmt.Sprintf("%x", sha256.Sum256(payload))
|
||||
}
|
||||
|
||||
func RunAIMatchBatch(ctx context.Context, db *sql.DB, actor model.User, batchID string, snapshot AIMatchSnapshot) error {
|
||||
if err := repository.MarkAIMatchBatchRunning(db, batchID, model.NowISO()); err != nil {
|
||||
return err
|
||||
}
|
||||
leaders, err := repository.ListAIMatchLeaderItems(db, batchID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
concurrency := snapshot.Provider.MaxConcurrency
|
||||
if concurrency < 1 {
|
||||
concurrency = 1
|
||||
}
|
||||
if concurrency > 16 {
|
||||
concurrency = 16
|
||||
}
|
||||
jobs := make(chan model.AIMatchBatchItem)
|
||||
var wg sync.WaitGroup
|
||||
var firstErr error
|
||||
var errMu sync.Mutex
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for leader := range jobs {
|
||||
if err := runAIMatchIdentity(ctx, db, actor, batchID, leader, snapshot); err != nil {
|
||||
errMu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
errMu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, leader := range leaders {
|
||||
jobs <- leader
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
if firstErr != nil {
|
||||
return firstErr
|
||||
}
|
||||
counts, err := repository.CountAIMatchBatchItems(db, batchID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
batch, err := repository.GetAIMatchBatch(db, batchID)
|
||||
if err != nil || batch == nil {
|
||||
return fmt.Errorf("完成 AI 匹配批次前无法读取批次: %w", err)
|
||||
}
|
||||
status := "succeeded"
|
||||
if counts.Failed == batch.TotalCount {
|
||||
status = "failed"
|
||||
} else if counts.Manual > 0 || counts.Failed > 0 || counts.Processed < batch.TotalCount {
|
||||
status = "partial"
|
||||
}
|
||||
return repository.FinishAIMatchBatch(db, batchID, status, "", model.NowISO(), counts)
|
||||
}
|
||||
|
||||
func runAIMatchIdentity(ctx context.Context, db *sql.DB, actor model.User, batchID string, leader model.AIMatchBatchItem, snapshot AIMatchSnapshot) error {
|
||||
startedAt := model.NowISO()
|
||||
if err := repository.MarkAIMatchItemRunning(db, leader.ItemID, startedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
result, matchErr := MatchSybSpecWithAI(ctx, db, &actor, snapshot, leader.SybID, leader.ContextVersion)
|
||||
leaderResult := batchItemFromMatch(leader, result, matchErr)
|
||||
leaderResult.StartedAt, leaderResult.FinishedAt = startedAt, model.NowISO()
|
||||
if err := repository.FinishAIMatchItem(db, leaderResult); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := repository.ListAIMatchIdentityItems(db, batchID, leader.IdentityHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.ItemID == leader.ItemID || item.Status != "queued" {
|
||||
continue
|
||||
}
|
||||
follower := reuseAIMatchLeaderResult(db, item, leaderResult)
|
||||
follower.FinishedAt = model.NowISO()
|
||||
if err := repository.FinishAIMatchItem(db, follower); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
counts, err := repository.CountAIMatchBatchItems(db, batchID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.UpdateAIMatchBatchCounts(db, batchID, counts)
|
||||
}
|
||||
|
||||
func reuseAIMatchLeaderResult(db *sql.DB, item, leader model.AIMatchBatchItem) model.AIMatchBatchItem {
|
||||
current, err := repository.GetSybOrderContext(db, item.SybID)
|
||||
if err != nil || current == nil || aiSpecMatchIdentity(*current) != item.IdentityHash {
|
||||
item.Status, item.Outcome, item.Message = "stale", "stale", "同规格处理期间数据已变化,请刷新后重试"
|
||||
return item
|
||||
}
|
||||
item.Status, item.Outcome = leader.Status, leader.Outcome
|
||||
item.Message = "同一商品规格复用批次结果:" + leader.Message
|
||||
item.MappingSource, item.OptionKey = leader.MappingSource, leader.OptionKey
|
||||
item.ConfidenceBPS, item.ConfidenceSet = leader.ConfidenceBPS, leader.ConfidenceSet
|
||||
if leader.Status == "succeeded" || leader.Status == "reused" {
|
||||
if !mappingIsValid(*current) {
|
||||
item.Status, item.Outcome, item.Message = "stale", "stale", "批次结果保存后当前映射已失效,请重试"
|
||||
item.MappingSource, item.OptionKey, item.ConfidenceSet = "", "", false
|
||||
return item
|
||||
}
|
||||
item.Status, item.Outcome = "reused", "reused"
|
||||
item.MappingSource, item.OptionKey = current.MappingSource, current.MappingOptionKey
|
||||
if item.MappingSource == "" {
|
||||
item.MappingSource = "manual"
|
||||
}
|
||||
item.ConfidenceBPS, item.ConfidenceSet = current.MappingConfidenceBPS, current.MappingConfidenceSet
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func batchItemFromMatch(item model.AIMatchBatchItem, result AISpecMatchResult, err error) model.AIMatchBatchItem {
|
||||
item.Outcome, item.Message = result.Outcome, truncateRunes(result.Message, 500)
|
||||
item.MappingSource, item.OptionKey = result.Source, result.OptionKey
|
||||
item.ConfidenceBPS, item.ConfidenceSet = result.ConfidenceBPS, result.ConfidenceSet
|
||||
item.ModelCalled = result.ModelCalled
|
||||
if err != nil {
|
||||
item.Status, item.Outcome, item.Message = "failed", "failed", truncateRunes(err.Error(), 500)
|
||||
return item
|
||||
}
|
||||
switch result.Outcome {
|
||||
case "ai_saved", "rule_saved":
|
||||
item.Status = "succeeded"
|
||||
case "reused", "manual_exists":
|
||||
item.Status = "reused"
|
||||
case "stale":
|
||||
item.Status = "stale"
|
||||
case "failed":
|
||||
item.Status = "failed"
|
||||
default:
|
||||
item.Status = "manual"
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func InterruptRunningAIMatchBatches(db *sql.DB, now time.Time) (int, error) {
|
||||
ids, err := repository.ListUnfinishedAIMatchBatchIDs(db)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := repository.InterruptAIMatchBatch(db, id, now.UTC().Format(model.TimeLayout)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return len(ids), nil
|
||||
}
|
||||
|
||||
func MarkAIMatchBatchInterrupted(db *sql.DB, batchID string) error {
|
||||
return repository.InterruptAIMatchBatch(db, batchID, model.NowISO())
|
||||
}
|
||||
|
||||
func GetAIMatchBatchView(db *sql.DB, actor *model.User, batchID string) (*AIMatchBatchView, error) {
|
||||
if actor == nil || !actor.IsActive() {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
batch, err := repository.GetAIMatchBatch(db, strings.TrimSpace(batchID))
|
||||
if err != nil || batch == nil {
|
||||
return nil, err
|
||||
}
|
||||
if !actor.IsAdmin() && batch.CreatedByUserID != actor.UserID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
items, err := repository.ListAIMatchBatchItems(db, batch.BatchID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := &AIMatchBatchView{BatchID: batch.BatchID, Status: batch.Status,
|
||||
StatusText: aiMatchBatchStatusText(batch.Status), ProviderName: batch.ProviderName, Model: batch.Model,
|
||||
ErrorMessage: batch.ErrorMessage, Total: batch.TotalCount, Processed: batch.ProcessedCount,
|
||||
Success: batch.SuccessCount, Reused: batch.ReusedCount, Manual: batch.ManualCount,
|
||||
Failed: batch.FailedCount, CreatedAt: batch.CreatedAt, StartedAt: batch.StartedAt,
|
||||
FinishedAt: batch.FinishedAt, Running: batch.Status == "queued" || batch.Status == "running",
|
||||
Items: make([]AIMatchBatchItemView, 0, len(items))}
|
||||
for _, item := range items {
|
||||
confidence := ""
|
||||
if item.ConfidenceSet {
|
||||
confidence = fmt.Sprintf("%.2f%%", float64(item.ConfidenceBPS)/100)
|
||||
}
|
||||
view.Items = append(view.Items, AIMatchBatchItemView{SybID: item.SybID, Status: item.Status,
|
||||
StatusText: aiMatchItemStatusText(item.Status), Outcome: item.Outcome,
|
||||
Message: item.Message, SourceText: mappingSourceText(item.MappingSource), OptionKey: item.OptionKey,
|
||||
ConfidenceText: confidence, FinishedAt: item.FinishedAt})
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func LatestAIMatchBatchID(db *sql.DB, actor *model.User) (string, error) {
|
||||
if actor == nil || !actor.IsActive() {
|
||||
return "", ErrUnauthenticated
|
||||
}
|
||||
return repository.GetLatestAIMatchBatchID(db, actor.UserID, actor.IsAdmin())
|
||||
}
|
||||
|
||||
func aiMatchBatchStatusText(status string) string {
|
||||
return map[string]string{"queued": "等待中", "running": "匹配中", "partial": "部分完成",
|
||||
"succeeded": "已完成", "failed": "失败", "interrupted": "已中断"}[status]
|
||||
}
|
||||
|
||||
func aiMatchItemStatusText(status string) string {
|
||||
return map[string]string{"queued": "等待中", "running": "匹配中", "succeeded": "已保存",
|
||||
"reused": "已复用", "manual": "需人工", "failed": "失败", "stale": "数据已变化",
|
||||
"interrupted": "已中断"}[status]
|
||||
}
|
||||
|
||||
func mappingSourceText(source string) string {
|
||||
return map[string]string{"manual": "人工匹配", "rule": "规则匹配", "ai": "AI匹配"}[source]
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type AISpecMatchResult struct {
|
||||
Source string
|
||||
OptionKey string
|
||||
ConfidenceBPS int
|
||||
ConfidenceSet bool
|
||||
ContextVersion string
|
||||
ModelCalled bool
|
||||
}
|
||||
@@ -98,7 +99,8 @@ func MatchSybSpecWithAI(ctx context.Context, db *sql.DB, actor *model.User, snap
|
||||
source = "manual"
|
||||
}
|
||||
return AISpecMatchResult{Outcome: "reused", Message: "已复用当前有效规格映射", Source: source,
|
||||
OptionKey: orderContext.MappingOptionKey, ConfidenceBPS: orderContext.MappingConfidenceBPS, ContextVersion: version}, nil
|
||||
OptionKey: orderContext.MappingOptionKey, ConfidenceBPS: orderContext.MappingConfidenceBPS,
|
||||
ConfidenceSet: orderContext.MappingConfidenceSet, ContextVersion: version}, nil
|
||||
}
|
||||
choices, keys, names, err := pddOptionChoices(orderContext.PddSkusJSON)
|
||||
if err != nil {
|
||||
@@ -289,7 +291,8 @@ func saveAutomaticMapping(db *sql.DB, actor model.User, original repository.SybO
|
||||
return AISpecMatchResult{}, err
|
||||
}
|
||||
return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, Source: actualSource,
|
||||
OptionKey: actual.PddOptionKey, ConfidenceBPS: actual.ConfidenceBPS, ContextVersion: decision.ContextVersion}, nil
|
||||
OptionKey: actual.PddOptionKey, ConfidenceBPS: actual.ConfidenceBPS,
|
||||
ConfidenceSet: actual.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil
|
||||
}
|
||||
decision.Outcome = source + "_saved"
|
||||
decision.ChosenOptionKey = latestChoice.Key
|
||||
@@ -301,7 +304,8 @@ func saveAutomaticMapping(db *sql.DB, actor model.User, original repository.SybO
|
||||
return AISpecMatchResult{}, err
|
||||
}
|
||||
return AISpecMatchResult{Outcome: decision.Outcome, Message: "规格映射已保存", Source: source,
|
||||
OptionKey: latestChoice.Key, ConfidenceBPS: decision.ConfidenceBPS, ContextVersion: decision.ContextVersion}, nil
|
||||
OptionKey: latestChoice.Key, ConfidenceBPS: decision.ConfidenceBPS,
|
||||
ConfidenceSet: decision.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil
|
||||
}
|
||||
|
||||
func recordAIMatchWithoutSave(db *sql.DB, decision model.AISpecMatchDecision, outcome, reason string) (AISpecMatchResult, error) {
|
||||
@@ -319,7 +323,7 @@ func recordAIMatchWithoutSave(db *sql.DB, decision model.AISpecMatchDecision, ou
|
||||
return AISpecMatchResult{}, err
|
||||
}
|
||||
return AISpecMatchResult{Outcome: outcome, Message: decision.Reason, ConfidenceBPS: decision.ConfidenceBPS,
|
||||
ContextVersion: decision.ContextVersion}, nil
|
||||
ConfidenceSet: decision.ConfidenceSet, ContextVersion: decision.ContextVersion}, nil
|
||||
}
|
||||
|
||||
func newAISpecDecision(c repository.SybOrderContext, actor model.User, snapshot AIMatchSnapshot, version string) model.AISpecMatchDecision {
|
||||
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("用户名或密码错误")
|
||||
ErrUnauthenticated = errors.New("未登录或登录已过期")
|
||||
ErrForbidden = errors.New("没有权限执行此操作")
|
||||
|
||||
// 用户不存在时也跑一次 bcrypt,避免响应时间直接泄露“这个用户名存在”。
|
||||
dummyPasswordHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), bcrypt.DefaultCost)
|
||||
|
||||
+66
-5
@@ -1064,7 +1064,13 @@ type SybOrderView struct {
|
||||
NeedsAttention bool
|
||||
CanCollect bool
|
||||
CanPurchase bool
|
||||
CanAIMatch bool
|
||||
SelectionActions string
|
||||
SelectionHint string
|
||||
MappingSource string
|
||||
MappingSourceText string
|
||||
MappingSourceClass string
|
||||
MappingSourceHelp string
|
||||
DefaultUnitPrice string
|
||||
DefaultTotalPrice string
|
||||
MappedPddChoice string
|
||||
@@ -1081,6 +1087,7 @@ const (
|
||||
SybStagePddCollectingStale = "pdd_collecting_stale"
|
||||
SybStagePddFailed = "pdd_failed"
|
||||
SybStageMappingPending = "mapping_pending"
|
||||
SybStageAIMatched = "ai_matched"
|
||||
SybStagePurchaseReady = "purchase_ready"
|
||||
SybStageTaskCreated = "task_created"
|
||||
SybStagePurchaseCompleted = "purchase_completed"
|
||||
@@ -1104,6 +1111,7 @@ func SybStageOptions() []SybStageOption {
|
||||
{Value: SybStagePddCollectingStale, Text: "PDD 采集中(超时)"},
|
||||
{Value: SybStagePddFailed, Text: "PDD 采集失败"},
|
||||
{Value: SybStageMappingPending, Text: "规格待匹配"},
|
||||
{Value: SybStageAIMatched, Text: "AI规格匹配"},
|
||||
{Value: SybStagePurchaseReady, Text: "可创建采购任务"},
|
||||
{Value: SybStageTaskCreated, Text: "已创建采购任务"},
|
||||
{Value: SybStagePurchaseCompleted, Text: "采购完成"},
|
||||
@@ -1253,6 +1261,7 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
|
||||
}
|
||||
if stage == SybStagePddCollecting || stage == SybStagePddCollectingStale ||
|
||||
stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
|
||||
stage == SybStageAIMatched ||
|
||||
stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted ||
|
||||
stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked {
|
||||
return listAdvancedSybStage(db, orderNos, shop, stage, page)
|
||||
@@ -1311,7 +1320,11 @@ func listAdvancedSybStage(db *sql.DB, orderNos []string, shop, stage string, pag
|
||||
filtered := make([]repository.SybOrderContext, 0)
|
||||
for _, context := range contexts {
|
||||
value, _, _, _ := sybStageFor(context)
|
||||
if value == stage {
|
||||
matches := value == stage
|
||||
if stage == SybStageAIMatched {
|
||||
matches = value == SybStagePurchaseReady && context.MappingSource == "ai" && mappingIsValid(context)
|
||||
}
|
||||
if matches {
|
||||
filtered = append(filtered, context)
|
||||
}
|
||||
}
|
||||
@@ -1378,14 +1391,46 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
|
||||
v.CanCollect = v.Stage == SybStagePddPending || v.Stage == SybStagePddFailed ||
|
||||
v.Stage == SybStagePddCollectingStale
|
||||
v.CanPurchase = v.Stage == SybStagePurchaseReady
|
||||
v.CanAIMatch = v.Stage == SybStageMappingPending || v.Stage == SybStagePurchaseReady
|
||||
actions := make([]string, 0, 3)
|
||||
if v.CanCollect {
|
||||
actions = append(actions, "collect")
|
||||
}
|
||||
if v.CanPurchase {
|
||||
actions = append(actions, "purchase")
|
||||
}
|
||||
if v.CanAIMatch {
|
||||
actions = append(actions, "ai")
|
||||
}
|
||||
v.SelectionActions = strings.Join(actions, " ")
|
||||
switch {
|
||||
case v.CanCollect:
|
||||
v.SelectionHint = "可批量创建 PDD 采集任务"
|
||||
case v.CanPurchase:
|
||||
v.SelectionHint = "可批量创建采购任务"
|
||||
case len(actions) > 0:
|
||||
labels := make([]string, 0, len(actions))
|
||||
if v.CanCollect {
|
||||
labels = append(labels, "创建采集")
|
||||
}
|
||||
if v.CanPurchase {
|
||||
labels = append(labels, "创建采购")
|
||||
}
|
||||
if v.CanAIMatch {
|
||||
labels = append(labels, "AI规格匹配")
|
||||
}
|
||||
v.SelectionHint = "可用于:" + strings.Join(labels, "、")
|
||||
default:
|
||||
v.SelectionHint = "当前阶段不能批量操作:" + v.StageHelp
|
||||
}
|
||||
if context.MappingOptionKey != "" {
|
||||
v.MappingSource = context.MappingSource
|
||||
if v.MappingSource == "" {
|
||||
v.MappingSource = "manual"
|
||||
}
|
||||
v.MappingSourceText = mappingSourceText(v.MappingSource)
|
||||
v.MappingSourceClass = "source-" + v.MappingSource
|
||||
v.MappingSourceHelp = context.MappingReason
|
||||
if !mappingIsValid(context) {
|
||||
v.MappingSourceText += "(已失效)"
|
||||
}
|
||||
}
|
||||
if v.CanPurchase {
|
||||
v.MappingOptionKey = context.MappingOptionKey
|
||||
v.ContextVersion = mappingContextVersion(context)
|
||||
@@ -1451,6 +1496,10 @@ type SybProcessingDetail struct {
|
||||
RecommendationNotice string
|
||||
MappingValid bool
|
||||
HasActiveTask bool
|
||||
MappingSource string
|
||||
MappingSourceText string
|
||||
MappingSourceClass string
|
||||
MappingSourceHelp string
|
||||
}
|
||||
|
||||
func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) {
|
||||
@@ -1472,6 +1521,18 @@ func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, err
|
||||
(d.Stage == SybStagePddPending || d.Stage == SybStagePddFailed ||
|
||||
d.Stage == SybStagePddCollectingStale)
|
||||
d.HasActiveTask = context.HasActiveTask
|
||||
if context.MappingOptionKey != "" {
|
||||
d.MappingSource = context.MappingSource
|
||||
if d.MappingSource == "" {
|
||||
d.MappingSource = "manual"
|
||||
}
|
||||
d.MappingSourceText = mappingSourceText(d.MappingSource)
|
||||
d.MappingSourceClass = "source-" + d.MappingSource
|
||||
d.MappingSourceHelp = context.MappingReason
|
||||
if !mappingIsValid(*context) {
|
||||
d.MappingSourceText += "(已失效)"
|
||||
}
|
||||
}
|
||||
if context.PddCollectStatus == string(model.CollectCollected) && context.PddSkusJSON != "" {
|
||||
choices, keys, names, parseErr := pddOptionChoices(context.PddSkusJSON)
|
||||
if parseErr == nil {
|
||||
|
||||
@@ -162,8 +162,12 @@ func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) {
|
||||
{SybStagePurchaseBlocked, "核对采购数量", copyWith(func(c *repository.SybOrderContext) { c.Order.Quantity = 0 })},
|
||||
{SybStagePurchaseReady, "创建采购任务", ready},
|
||||
}
|
||||
if got := len(SybStageOptions()) - 1; got != len(cases) {
|
||||
t.Fatalf("筛选项有 %d 个业务阶段,但测试只覆盖 %d 个", got, len(cases))
|
||||
// AI 规格匹配是映射来源筛选,不会取代订单当前的业务阶段。
|
||||
if got := len(SybStageOptions()) - 1; got != len(cases)+1 {
|
||||
t.Fatalf("筛选项有 %d 个业务阶段或来源筛选,但测试覆盖 %d 个业务阶段", got, len(cases))
|
||||
}
|
||||
if ParseSybStage(SybStageAIMatched) != SybStageAIMatched || SybStageLabel(SybStageAIMatched) != "AI规格匹配" {
|
||||
t.Fatal("AI 规格匹配筛选项未正确注册")
|
||||
}
|
||||
for _, tc := range cases {
|
||||
stage, text, _, action := sybStageFor(tc.context)
|
||||
|
||||
Reference in New Issue
Block a user