feat(#148): match purchase specs asynchronously

This commit is contained in:
QiuSW
2026-08-29 11:51:32 +08:00
parent c9e9539de0
commit 11c407d582
20 changed files with 888 additions and 199 deletions
+15
View File
@@ -1,3 +1,11 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 61a6effd4badcf94d9c4745521c4f3945a7be3ef
synchronized_at: 2026-08-29T03:46:32Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
@@ -248,6 +256,13 @@ Android Portal/Agent
- 历史采购执行身份继续以 `PDDGoodsIDSnapshot` / `PDDURLSnapshot` 为准;当前采购查询未发现按可变 `pdd_product_id` 聚合历史数据的实现,因此本工单无需改写统计 SQL。
## 创建采购任务异步规格匹配(#148)
- `purchase_spec_match_work_item` 是采购独立持久工作项;模型在 `server/app/goauto/models/purchase.go`,迁移由 `migrations.MigratedModels` 统一创建。
- `server/app/goauto/purchase/match_worker.go` 负责原子领取、租约、30 秒/2 分钟/10 分钟退避、启动恢复、严格自动确认和输入指纹重校验;只写采购任务快照,不写虾皮长期映射。
- `service.go` 在需要 provider 时把 `pending` 任务与工作项同事务创建并立即返回;`lifecycle.go` 在 `Next`、`Claim`、`Start` 三个入口检查工作项,防止未完成匹配的任务执行。
- `match_admin.go`、`admin_query.go` 和 `/purchase-tasks/{taskId}/matching*` 提供状态、重新入队和人工候选选择;Admin 页面在列表和详情展示匹配状态与恢复操作。
## PDD 颜色图片采集与存储(#133)
```text
+15
View File
@@ -1,3 +1,11 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 71d8707aba33d07f329e1cc86a087383e555738d
synchronized_at: 2026-08-29T03:47:02Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
@@ -324,6 +332,13 @@ synchronized_at: 2026-08-29T01:39:44Z
- 自动确认必须保存来源、实际置信度和脱敏限长原因;人工确认完成后同步对应分项及主表总体状态。worker 不得覆盖已由人工改变的映射。
- 纠错不能简单执行 B→C;必须把原 A→B 记录置为 `superseded`,建立 A→C,并只处理原记录冻结的虾皮商品影响集合。
## 创建采购任务时的异步规格匹配(#148)
- 创建任务继续同步使用已确认映射和唯一确定性 `exact_match`;只有需要外部 AI 时,任务与 `purchase_spec_match_work_item` 在同一事务创建,接口立即返回。活动工作项或 `manual_required` 未处理前,`Next`、`Claim`、`Start` 均拒绝任务。
- AI 结果只固化到采购任务快照,不自动写回虾皮商品长期规格映射。`ai_match` 必须达到设置阈值、严格属于当前候选、原因非空且写入前输入指纹未变化;否则转人工,Agent 不猜测。
- provider 网络、超时或 5xx 按 30 秒、2 分钟、10 分钟退避,最多尝试 3 次;预算内恢复会自动继续,耗尽后转 `manual_required`。Admin 可重新入队或从当前候选人工选择。
- 异步 provider 单次调用最长 60 秒,即 `min(配置超时, 60 秒)`;设置页测试连接仍使用完整配置超时。取消任务会同时终结活动工作项,输入变化会关闭旧工作项并要求重新入队。
## Agent 手动采集替代商品(#130)
- 只有同一设备上的失败采集或失败采购任务,且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT` 时,Agent 详情才显示“采集替代商品”;Android 不自行推断资格。
+10
View File
@@ -1,3 +1,11 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 9f04eb9bb7284e525a58309b9c47cdb593a1e09c
synchronized_at: 2026-08-29T03:49:41Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
@@ -510,6 +518,8 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
创建阶段需要外部 AI 的任务会先以 `pending` 与持久匹配工作项同事务创建并立即返回 Admin。工作项处于 `pending`、`running`、`retry_wait` 或 `manual_required` 时,`next` 不返回该任务,直接调用 `claim` 或 `start` 也会返回状态冲突。匹配完成后 Agent 仍只接收服务端固化的精确 PDD 原始标签;Android 接口和请求体不新增 AI、候选或人工决策字段。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整文字精确点击,连续没有新证据或达到上限即停止。若第二趟仍提交 `spec_probe_completed`,服务端将任务和当前 attempt 明确标记失败、释放租约并保留第一次规格决策,不再进入 `spec_probe_pending` 或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
`openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `RULE_NOT_MATCHED`。
+3
View File
@@ -89,6 +89,9 @@ var AdminAPIs = []APIPermission{
{"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true},
{"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true},
{"处理采购规格", "/api/admin/v1/purchase-tasks/:taskId/spec-decision", "POST", true},
{"查看采购规格匹配", "/api/admin/v1/purchase-tasks/:taskId/matching", "GET", true},
{"重新尝试采购规格匹配", "/api/admin/v1/purchase-tasks/:taskId/matching/requeue", "POST", true},
{"人工选择采购规格", "/api/admin/v1/purchase-tasks/:taskId/matching/manual", "POST", true},
{"授权重新采购", "/api/admin/v1/purchase-tasks/:taskId/authorize-repurchase", "POST", true},
{"复核支付状态", "/api/admin/v1/purchase-tasks/:taskId/payment-review", "POST", true},
{"选择回填候选", "/api/admin/v1/purchase-tasks/:taskId/writeback-candidate", "POST", true},
+1
View File
@@ -48,6 +48,7 @@ func MigratedModels() []any {
&models.PDDAccount{},
&models.PurchaseTask{},
&models.PurchaseTaskAttempt{},
&models.PurchaseSpecMatchWorkItem{},
&models.CollectionRule{},
&models.AgentManualCollectionSetting{},
&models.CollectionTask{},
+30
View File
@@ -47,6 +47,15 @@ const (
PurchaseWritebackStatusFailed = "failed"
)
const (
PurchaseMatchPending = "pending"
PurchaseMatchRunning = "running"
PurchaseMatchRetryWait = "retry_wait"
PurchaseMatchMatched = "matched"
PurchaseMatchManualRequired = "manual_required"
PurchaseMatchCancelled = "cancelled"
)
// PDDAccount is an optional operator-maintained reference used only for
// account-level scheduling. Android cannot reliably read the logged-in PDD
// account, so purchase tasks never require this relation and this table never
@@ -162,6 +171,27 @@ type PurchaseTask struct {
func (PurchaseTask) TableName() string { return "purchase_task" }
type PurchaseSpecMatchWorkItem struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
PurchaseTaskID uint64 `json:"purchaseTaskId" gorm:"not null;uniqueIndex:ux_purchase_match_task"`
PurchaseTask PurchaseTask `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
Status string `json:"status" gorm:"size:24;not null;index;check:ck_purchase_match_status,status IN ('pending','running','retry_wait','matched','manual_required','cancelled')"`
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
NextAttemptAt *time.Time `json:"nextAttemptAt" gorm:"index"`
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
LastErrorCode *string `json:"lastErrorCode,omitempty" gorm:"size:64"`
LastErrorAt *time.Time `json:"lastErrorAt,omitempty"`
Reason string `json:"reason" gorm:"size:500;not null;default:''"`
InputFingerprint string `json:"-" gorm:"size:64;not null"`
InputSnapshotJSON string `json:"-" gorm:"type:json;not null"`
CompletedAt *time.Time `json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (PurchaseSpecMatchWorkItem) TableName() string { return "purchase_spec_match_work_item" }
func (task *PurchaseTask) BeforeCreate(_ *gorm.DB) error {
if task.StatusVersion == 0 {
task.StatusVersion = 1
+138 -52
View File
@@ -2,6 +2,7 @@ package purchase
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
@@ -24,56 +25,57 @@ type AdminListRequest struct {
}
type AdminTaskItem struct {
ID uint64 `json:"id"`
TaskType string `json:"taskType"`
ExecutionMode string `json:"executionMode"`
Status string `json:"status"`
SYBProductID *uint64 `json:"sybProductId,omitempty"`
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
PDDProductID uint64 `json:"pddProductId"`
DeviceID *uint64 `json:"deviceId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
TargetColorSnapshot string `json:"targetColorSnapshot"`
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
MappedColorSnapshot string `json:"mappedColorSnapshot"`
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
SpecSource string `json:"specSource"`
Quantity int64 `json:"quantity"`
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
Currency string `json:"currency"`
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
AddressSuffix string `json:"addressSuffix"`
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
PaymentReviewStatus string `json:"paymentReviewStatus"`
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
TrackingNo *string `json:"trackingNo,omitempty"`
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
LogisticsStatus string `json:"logisticsStatus"`
WritebackStatus string `json:"writebackStatus"`
WritebackAt *time.Time `json:"writebackAt,omitempty"`
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CancelReason *string `json:"cancelReason,omitempty"`
ErrorCode *string `json:"errorCode,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Retryable bool `json:"retryable"`
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
StatusVersion uint64 `json:"statusVersion"`
StatusChangedAt time.Time `json:"statusChangedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID uint64 `json:"id"`
TaskType string `json:"taskType"`
ExecutionMode string `json:"executionMode"`
Status string `json:"status"`
SYBProductID *uint64 `json:"sybProductId,omitempty"`
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
PDDProductID uint64 `json:"pddProductId"`
DeviceID *uint64 `json:"deviceId,omitempty"`
DeviceName string `json:"deviceName,omitempty"`
ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"`
ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"`
ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"`
ShopeeShopNameSnapshot string `json:"shopeeShopNameSnapshot"`
PDDGoodsIDSnapshot string `json:"pddGoodsIdSnapshot"`
PDDTitleSnapshot string `json:"pddTitleSnapshot"`
TargetColorSnapshot string `json:"targetColorSnapshot"`
TargetSizeSnapshot string `json:"targetSizeSnapshot"`
MappedColorSnapshot string `json:"mappedColorSnapshot"`
MappedSizeSnapshot string `json:"mappedSizeSnapshot"`
SpecSource string `json:"specSource"`
Quantity int64 `json:"quantity"`
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent"`
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
Currency string `json:"currency"`
PDDAccountRefSnapshot string `json:"pddAccountRefSnapshot"`
AddressSuffix string `json:"addressSuffix"`
PDDOrderNo *string `json:"pddOrderNo,omitempty"`
OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"`
IrreversibleAt *time.Time `json:"irreversibleAt,omitempty"`
PaymentReviewStatus string `json:"paymentReviewStatus"`
PaymentReviewedAt *time.Time `json:"paymentReviewedAt,omitempty"`
TrackingNo *string `json:"trackingNo,omitempty"`
TrackingCollectedAt *time.Time `json:"trackingCollectedAt,omitempty"`
LogisticsStatus string `json:"logisticsStatus"`
WritebackStatus string `json:"writebackStatus"`
WritebackAt *time.Time `json:"writebackAt,omitempty"`
RePurchaseAuthorizedAt *time.Time `json:"rePurchaseAuthorizedAt,omitempty"`
RePurchaseConsumedAt *time.Time `json:"rePurchaseConsumedAt,omitempty"`
CancelledAt *time.Time `json:"cancelledAt,omitempty"`
CancelReason *string `json:"cancelReason,omitempty"`
ErrorCode *string `json:"errorCode,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Retryable bool `json:"retryable"`
RetryDisabledCode string `json:"retryDisabledCode,omitempty"`
RetryDisabledReason string `json:"retryDisabledReason,omitempty"`
StatusVersion uint64 `json:"statusVersion"`
StatusChangedAt time.Time `json:"statusChangedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Matching MatchingView `json:"matching"`
}
type AdminAttemptItem struct {
@@ -101,6 +103,7 @@ type AdminListResponse struct {
type AdminDetailResponse struct {
Task AdminTaskItem `json:"task"`
Attempts []AdminAttemptItem `json:"attempts"`
Matching MatchingView `json:"matching"`
}
func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminListResponse, error) {
@@ -156,9 +159,15 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis
if err != nil {
return AdminListResponse{}, err
}
matching, err := s.matchingViews(ctx, tasks)
if err != nil {
return AdminListResponse{}, err
}
items := make([]AdminTaskItem, 0, len(tasks))
for _, task := range tasks {
items = append(items, adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)))
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
item.Matching = matching[task.ID]
items = append(items, item)
}
return AdminListResponse{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil
}
@@ -188,7 +197,13 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe
StartedAt: attempt.StartedAt, FinishedAt: attempt.FinishedAt, CreatedAt: attempt.CreatedAt,
})
}
return AdminDetailResponse{Task: adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)), Attempts: items}, nil
matching, matchErr := s.MatchingDetail(ctx, task.ID)
if matchErr != nil {
return AdminDetailResponse{}, matchErr
}
item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true))
item.Matching = matching
return AdminDetailResponse{Task: item, Attempts: items, Matching: matching}, nil
}
func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) {
@@ -255,3 +270,74 @@ func validPurchaseStatus(status string) bool {
return false
}
}
func (s *Service) matchingViews(ctx context.Context, tasks []models.PurchaseTask) (map[uint64]MatchingView, error) {
result := make(map[uint64]MatchingView, len(tasks))
ids := make([]uint64, 0, len(tasks))
for _, task := range tasks {
ids = append(ids, task.ID)
result[task.ID] = resolvedMatchingView(task)
}
if len(ids) == 0 {
return result, nil
}
var works []models.PurchaseSpecMatchWorkItem
if err := s.DB.WithContext(ctx).Where("purchase_task_id IN ?", ids).Find(&works).Error; err != nil {
return nil, internal(err)
}
for _, work := range works {
view, err := matchingViewFromWork(work)
if err != nil {
return nil, internal(err)
}
result[work.PurchaseTaskID] = view
}
return result, nil
}
func (s *Service) MatchingDetail(ctx context.Context, taskID uint64) (MatchingView, error) {
var task models.PurchaseTask
if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil {
return MatchingView{}, purchaseNotFound(err)
}
var work models.PurchaseSpecMatchWorkItem
if err := s.DB.WithContext(ctx).Where("purchase_task_id = ?", taskID).First(&work).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return resolvedMatchingView(task), nil
} else if err != nil {
return MatchingView{}, internal(err)
}
view, err := matchingViewFromWork(work)
if err != nil {
return MatchingView{}, internal(err)
}
return view, nil
}
func resolvedMatchingView(task models.PurchaseTask) MatchingView {
if task.SpecSource == "unresolved" {
return MatchingView{Status: "unresolved", Executable: true, Reason: "任务执行时需要规格探测"}
}
return MatchingView{Status: models.PurchaseMatchMatched, Executable: true, Reason: "规格已匹配"}
}
func matchingViewFromWork(work models.PurchaseSpecMatchWorkItem) (MatchingView, error) {
var snapshot matchFingerprint
if err := json.Unmarshal([]byte(work.InputSnapshotJSON), &snapshot); err != nil {
return MatchingView{}, err
}
view := MatchingView{Status: work.Status, Executable: work.Status == models.PurchaseMatchMatched, Reason: work.Reason, NextAttemptAt: work.NextAttemptAt, TargetColor: snapshot.TargetColor, TargetSize: snapshot.TargetSize, Colors: snapshot.Colors, Sizes: snapshot.Sizes}
if work.LastErrorCode != nil {
view.ReasonCode = *work.LastErrorCode
}
switch work.Status {
case models.PurchaseMatchPending, models.PurchaseMatchRunning:
view.NextAction = "wait"
case models.PurchaseMatchRetryWait:
view.NextAction = "wait_retry"
case models.PurchaseMatchManualRequired:
view.NextAction = "manual_or_retry"
case models.PurchaseMatchCancelled:
view.NextAction = "requeue"
}
return view, nil
}
+52
View File
@@ -312,6 +312,58 @@ func (h Handler) SpecDecision(c *gin.Context) {
}
writeAdminReplay(c, p, replayed)
}
func (h Handler) MatchingDetail(c *gin.Context) {
if !allowedOperator(c) {
return
}
id, ok := pathID(c)
if !ok {
return
}
service, serviceOK := h.service(c)
if !serviceOK {
return
}
view, err := service.MatchingDetail(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
writeAdminData(c, view)
}
func (h Handler) RequeueMatching(c *gin.Context) { h.matchingAction(c, (*Service).RequeueMatching) }
func (h Handler) ManualMatching(c *gin.Context) { h.matchingAction(c, (*Service).ManualMatching) }
func (h Handler) matchingAction(c *gin.Context, fn func(*Service, context.Context, uint64, MatchingActionRequest) (MatchingView, error)) {
if !allowedOperator(c) {
return
}
id, ok := pathID(c)
if !ok {
return
}
var req MatchingActionRequest
if !decode(c, &req) {
return
}
req.OperatorID = operatorID(c)
if req.OperatorID == 0 {
writeError(c, fail(CodeInvalidRequest, "无法识别当前操作人"))
return
}
service, serviceOK := h.service(c)
if !serviceOK {
return
}
view, err := fn(service, c.Request.Context(), id, req)
if err != nil {
writeError(c, err)
return
}
writeAdminData(c, view)
}
func (h Handler) AuthorizeRePurchase(c *gin.Context) { h.manual(c, (*Service).AuthorizeRePurchase) }
func (h Handler) ReviewPayment(c *gin.Context) { h.manual(c, (*Service).ReviewPayment) }
func (h Handler) SelectWriteback(c *gin.Context) { h.manual(c, (*Service).SelectWriteback) }
+23
View File
@@ -41,6 +41,11 @@ func (s *Service) Next(ctx context.Context, token string) (*TaskPayload, error)
if t.Status == models.PurchaseTaskStatusSpecProbePending && t.MappedColorSnapshot == "" && t.MappedSizeSnapshot == "" {
continue
}
if active, er := activePurchaseMatch(s.DB.WithContext(ctx), t.ID); er != nil {
return nil, er
} else if active {
continue
}
required, er := decodeStrings(t.RequiredCapabilitiesJSON)
if er != nil {
return nil, internal(er)
@@ -77,6 +82,11 @@ func (s *Service) Claim(ctx context.Context, taskID uint64, req ActionRequest, t
if t.Status != models.PurchaseTaskStatusPending && t.Status != models.PurchaseTaskStatusSpecProbePending {
return fail(CodeStateConflict, "任务当前状态不能领取")
}
if active, e := activePurchaseMatch(tx, t.ID); e != nil {
return e
} else if active {
return fail(CodeStateConflict, "任务规格匹配尚未完成")
}
if t.DeviceID != nil && *t.DeviceID != d.ID {
return fail(CodeStateConflict, "任务已指定给其他设备")
}
@@ -150,6 +160,11 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t
if t.Status != models.PurchaseTaskStatusPending && t.Status != models.PurchaseTaskStatusSpecProbePending {
return fail(CodeStateConflict, "任务当前状态不能开始")
}
if active, e := activePurchaseMatch(tx, t.ID); e != nil {
return e
} else if active {
return fail(CodeStateConflict, "任务规格匹配尚未完成")
}
if t.DeviceID == nil || *t.DeviceID != d.ID {
return fail(CodeStateConflict, "任务不属于当前设备")
}
@@ -612,3 +627,11 @@ func (s *Service) lease() time.Duration {
}
return s.LeaseDuration
}
func activePurchaseMatch(db *gorm.DB, taskID uint64) (bool, error) {
var count int64
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ? AND status IN ?", taskID, []string{models.PurchaseMatchPending, models.PurchaseMatchRunning, models.PurchaseMatchRetryWait, models.PurchaseMatchManualRequired}).Count(&count).Error; err != nil {
return false, internal(err)
}
return count > 0, nil
}
+6
View File
@@ -160,6 +160,12 @@ func (s *Service) manual(ctx context.Context, id uint64, req ManualRequest, appl
if e := apply(&out); e != nil {
return e
}
if out.Status == models.PurchaseTaskStatusCancelled {
now := s.Now()
if e := tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ? AND status IN ?", out.ID, []string{models.PurchaseMatchPending, models.PurchaseMatchRunning, models.PurchaseMatchRetryWait, models.PurchaseMatchManualRequired}).Updates(map[string]any{"status": models.PurchaseMatchCancelled, "completed_at": now, "lease_owner": "", "lease_expires_at": nil, "reason": "采购任务已取消"}).Error; e != nil {
return internal(e)
}
}
return tx.Save(&out).Error
})
return out, replayed, err
+122
View File
@@ -0,0 +1,122 @@
package purchase
import (
"context"
"encoding/json"
"errors"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *Service) RequeueMatching(ctx context.Context, taskID uint64, req MatchingActionRequest) (MatchingView, error) {
if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 {
return MatchingView{}, fail(CodeInvalidRequest, "requestId 或操作人无效")
}
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var task models.PurchaseTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil {
return purchaseNotFound(err)
}
if task.Status != models.PurchaseTaskStatusPending || task.SpecSource != "unresolved" || task.LeaseExpiresAt != nil {
return fail(CodeStateConflict, "任务当前不能重新匹配")
}
var work models.PurchaseSpecMatchWorkItem
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("purchase_task_id = ?", taskID).First(&work).Error; err != nil {
return internal(err)
}
if work.Status != models.PurchaseMatchManualRequired && work.Status != models.PurchaseMatchCancelled {
return fail(CodeStateConflict, "当前匹配状态不能重新入队")
}
fingerprint, snapshot, err := currentPurchaseMatchInput(tx, task)
if err != nil {
return err
}
return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ?", work.ID).Updates(map[string]any{"status": models.PurchaseMatchPending, "attempt_count": 0, "next_attempt_at": nil, "lease_owner": "", "lease_expires_at": nil, "input_fingerprint": fingerprint, "input_snapshot_json": snapshot, "completed_at": nil}).Error
})
if err != nil {
return MatchingView{}, err
}
if s.StartMatching != nil {
s.StartMatching(s.DB)
}
return s.MatchingDetail(ctx, taskID)
}
func (s *Service) ManualMatching(ctx context.Context, taskID uint64, req MatchingActionRequest) (MatchingView, error) {
if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 {
return MatchingView{}, fail(CodeInvalidRequest, "requestId 或操作人无效")
}
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var task models.PurchaseTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil {
return purchaseNotFound(err)
}
var work models.PurchaseSpecMatchWorkItem
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("purchase_task_id = ?", taskID).First(&work).Error; err != nil {
return internal(err)
}
if task.Status != models.PurchaseTaskStatusPending || task.SpecSource != "unresolved" || task.LeaseExpiresAt != nil || work.Status != models.PurchaseMatchManualRequired {
return fail(CodeStateConflict, "任务当前不能人工选择规格")
}
fingerprint, _, err := currentPurchaseMatchInput(tx, task)
if err != nil || fingerprint != work.InputFingerprint {
return fail(CodeMappingRequired, "候选规格已变化,请重新入队")
}
var snapshot matchFingerprint
if err := json.Unmarshal([]byte(work.InputSnapshotJSON), &snapshot); err != nil {
return internal(err)
}
request := aimatching.MatchRequest{TargetColor: snapshot.TargetColor, TargetSize: snapshot.TargetSize, Colors: snapshot.Colors, Sizes: snapshot.Sizes}
if !matchCandidateValid(request.TargetColor, req.MappedColor, request.Colors) || !matchCandidateValid(request.TargetSize, req.MappedSize, request.Sizes) {
return fail(CodeMappingRequired, "人工选择必须属于当前候选规格")
}
decision := aimatching.RecordedMatch(request, "manual_mapping", strings.TrimSpace(req.MappedColor), strings.TrimSpace(req.MappedSize), "管理员人工选择规格")
raw, err := json.Marshal(decision.Decision)
if err != nil {
return internal(err)
}
now := s.Now()
if err := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND status = ? AND spec_source = ?", task.ID, models.PurchaseTaskStatusPending, "unresolved").Updates(map[string]any{"mapped_color_snapshot": req.MappedColor, "mapped_size_snapshot": req.MappedSize, "spec_source": "manual_mapping", "spec_decision_snapshot": string(raw), "spec_decision_request_id": req.RequestID, "spec_decision_by": req.OperatorID, "status_version": gorm.Expr("status_version + 1"), "status_changed_at": now}).Error; err != nil {
return internal(err)
}
return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ? AND status = ?", work.ID, models.PurchaseMatchManualRequired).Updates(map[string]any{"status": models.PurchaseMatchMatched, "completed_at": now, "lease_owner": "", "lease_expires_at": nil, "reason": "管理员人工选择规格"}).Error
})
if err != nil {
return MatchingView{}, err
}
return s.MatchingDetail(ctx, taskID)
}
func currentPurchaseMatchInput(tx *gorm.DB, task models.PurchaseTask) (string, string, error) {
if task.SYBProductID == nil || task.ShopeeProductID == nil {
return "", "", fail(CodeMappingRequired, "规格匹配输入已失效")
}
var syb models.SYBProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *task.SYBProductID).Error; err != nil {
return "", "", err
}
var shopee models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, *task.ShopeeProductID).Error; err != nil {
return "", "", err
}
var pdd models.PDDProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, task.PDDProductID).Error; err != nil {
return "", "", err
}
if shopee.PDDProductID == nil || *shopee.PDDProductID != pdd.ID || pdd.Status != "active" {
return "", "", fail(CodeMappingRequired, "商品关联或状态已变化")
}
candidates, usable := archiveCandidates(pdd.SpecsJSON, task.TargetColorSnapshot, task.TargetSizeSnapshot)
if !usable {
return "", "", fail(CodeMappingRequired, "候选规格已失效")
}
request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes}
return creationMatchInput(syb, shopee, pdd, request)
}
var _ = errors.Is
+240
View File
@@ -0,0 +1,240 @@
package purchase
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
purchaseMatchAttempts = 3
purchaseMatchLease = 90 * time.Second
purchaseMatchTimeout = 60 * time.Second
)
var purchaseMatchBackoff = [...]time.Duration{30 * time.Second, 2 * time.Minute, 10 * time.Minute}
type PurchaseMatchWorker struct {
DB *gorm.DB
Matcher SpecMatcher
OwnerID string
Now func() time.Time
}
func NewPurchaseMatchWorker(db *gorm.DB) *PurchaseMatchWorker {
return &PurchaseMatchWorker{DB: db, Matcher: aimatching.NewService(db), OwnerID: uuid.NewString(), Now: func() time.Time { return time.Now().UTC() }}
}
func StartPurchaseMatching(db *gorm.DB) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
_ = NewPurchaseMatchWorker(db).RunPending(ctx)
}()
}
func RecoverPurchaseMatching(db *gorm.DB) {
go func() {
worker := NewPurchaseMatchWorker(db)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
_ = worker.RunPending(context.Background())
<-ticker.C
}
}()
}
func (worker *PurchaseMatchWorker) RunPending(ctx context.Context) error {
for {
processed, err := worker.RunOnce(ctx)
if err != nil || !processed {
return err
}
}
}
func (worker *PurchaseMatchWorker) RunOnce(ctx context.Context) (bool, error) {
if worker.Matcher == nil {
worker.Matcher = aimatching.NewService(worker.DB)
}
if worker.OwnerID == "" {
worker.OwnerID = uuid.NewString()
}
if worker.Now == nil {
worker.Now = func() time.Time { return time.Now().UTC() }
}
now := worker.Now()
var item models.PurchaseSpecMatchWorkItem
err := worker.DB.WithContext(ctx).
Where("status IN ? AND (next_attempt_at IS NULL OR next_attempt_at <= ?) AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", []string{models.PurchaseMatchPending, models.PurchaseMatchRetryWait, models.PurchaseMatchRunning}, now, now).
Order("created_at, id").First(&item).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, internal(err)
}
lease := now.Add(purchaseMatchLease)
claimed := worker.DB.WithContext(ctx).Model(&models.PurchaseSpecMatchWorkItem{}).
Where("id = ? AND status IN ? AND attempt_count = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", item.ID, []string{models.PurchaseMatchPending, models.PurchaseMatchRetryWait, models.PurchaseMatchRunning}, item.AttemptCount, now).
Updates(map[string]any{"status": models.PurchaseMatchRunning, "attempt_count": gorm.Expr("attempt_count + 1"), "lease_owner": worker.OwnerID, "lease_expires_at": lease, "next_attempt_at": nil})
if claimed.Error != nil {
return false, internal(claimed.Error)
}
if claimed.RowsAffected != 1 {
return true, nil
}
item.AttemptCount++
item.Status, item.LeaseOwner, item.LeaseExpiresAt = models.PurchaseMatchRunning, worker.OwnerID, &lease
return true, worker.process(ctx, item)
}
func (worker *PurchaseMatchWorker) process(ctx context.Context, item models.PurchaseSpecMatchWorkItem) error {
var snapshot matchFingerprint
if err := json.Unmarshal([]byte(item.InputSnapshotJSON), &snapshot); err != nil {
return worker.finishFailure(ctx, item, "MATCH_INPUT_INVALID", "匹配输入快照无效", false)
}
request := aimatching.MatchRequest{TargetColor: snapshot.TargetColor, TargetSize: snapshot.TargetSize, Colors: snapshot.Colors, Sizes: snapshot.Sizes}
callCtx, cancel := context.WithTimeout(ctx, purchaseMatchTimeout)
defer cancel()
matched, err := worker.Matcher.Resolve(callCtx, request)
if err != nil {
return worker.finishFailure(ctx, item, matchProviderCode(err), compactMatchReason(err.Error()), retryablePurchaseMatch(err))
}
settings, err := aimatching.NewService(worker.DB).Settings(ctx)
if err != nil {
return worker.finishFailure(ctx, item, "AI_SETTING_READ_FAILED", "读取 AI 匹配设置失败", true)
}
if matched.Source != aimatching.SourceExact && matched.Source != aimatching.SourceAI {
return worker.finishFailure(ctx, item, "MATCH_SOURCE_INVALID", "匹配来源无效", false)
}
if !matchCandidateValid(request.TargetColor, matched.MappedColor, request.Colors) || !matchCandidateValid(request.TargetSize, matched.MappedSize, request.Sizes) {
return worker.finishFailure(ctx, item, "MATCH_OUTSIDE_CANDIDATES", "匹配结果不在当前候选集中", false)
}
if matched.Source == aimatching.SourceAI {
confidence := matched.Decision.Confidence
if confidence == nil || *confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(matched.Decision.Reason) == "" {
return worker.finishFailure(ctx, item, "AI_AUTO_CONFIRM_REJECTED", "AI 结果未达到自动确认条件", false)
}
}
return worker.finishMatched(ctx, item, matched)
}
func (worker *PurchaseMatchWorker) finishMatched(ctx context.Context, item models.PurchaseSpecMatchWorkItem, matched aimatching.MatchResult) error {
return worker.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var task models.PurchaseTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, item.PurchaseTaskID).Error; err != nil {
return internal(err)
}
var current models.PurchaseSpecMatchWorkItem
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, item.ID).Error; err != nil {
return internal(err)
}
if current.Status != models.PurchaseMatchRunning || current.LeaseOwner != worker.OwnerID || task.Status != models.PurchaseTaskStatusPending || task.SpecSource != "unresolved" || task.LeaseExpiresAt != nil {
return nil
}
fingerprint, err := currentPurchaseMatchFingerprint(tx, task)
if err != nil || fingerprint != current.InputFingerprint {
return worker.cancelInputChanged(tx, current)
}
decision, err := json.Marshal(matched.Decision)
if err != nil {
return internal(err)
}
now := worker.Now()
if err := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ? AND status = ? AND spec_source = ?", task.ID, models.PurchaseTaskStatusPending, "unresolved").Updates(map[string]any{"mapped_color_snapshot": matched.MappedColor, "mapped_size_snapshot": matched.MappedSize, "spec_source": matched.Source, "spec_decision_snapshot": string(decision), "status_version": gorm.Expr("status_version + 1"), "status_changed_at": now}).Error; err != nil {
return internal(err)
}
return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ? AND status = ? AND lease_owner = ?", current.ID, models.PurchaseMatchRunning, worker.OwnerID).Updates(map[string]any{"status": models.PurchaseMatchMatched, "completed_at": now, "lease_owner": "", "lease_expires_at": nil, "last_error_code": nil, "reason": compactMatchReason(matched.Decision.Reason)}).Error
})
}
func (worker *PurchaseMatchWorker) finishFailure(ctx context.Context, item models.PurchaseSpecMatchWorkItem, code, reason string, retryable bool) error {
return worker.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current models.PurchaseSpecMatchWorkItem
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, item.ID).Error; err != nil {
return internal(err)
}
if current.Status != models.PurchaseMatchRunning || current.LeaseOwner != worker.OwnerID {
return nil
}
now := worker.Now()
status := models.PurchaseMatchManualRequired
var next *time.Time
if retryable && current.AttemptCount < purchaseMatchAttempts {
status = models.PurchaseMatchRetryWait
value := now.Add(purchaseMatchBackoff[current.AttemptCount-1])
next = &value
}
return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ? AND status = ? AND lease_owner = ?", current.ID, models.PurchaseMatchRunning, worker.OwnerID).Updates(map[string]any{"status": status, "next_attempt_at": next, "lease_owner": "", "lease_expires_at": nil, "last_error_code": code, "last_error_at": now, "reason": compactMatchReason(reason)}).Error
})
}
func (worker *PurchaseMatchWorker) cancelInputChanged(tx *gorm.DB, item models.PurchaseSpecMatchWorkItem) error {
now := worker.Now()
return tx.Model(&models.PurchaseSpecMatchWorkItem{}).Where("id = ?", item.ID).Updates(map[string]any{"status": models.PurchaseMatchCancelled, "completed_at": now, "lease_owner": "", "lease_expires_at": nil, "last_error_code": "INPUT_CHANGED", "last_error_at": now, "reason": "商品关联或候选规格已变化"}).Error
}
func currentPurchaseMatchFingerprint(tx *gorm.DB, task models.PurchaseTask) (string, error) {
if task.SYBProductID == nil || task.ShopeeProductID == nil {
return "", fail(CodeMappingRequired, "规格匹配输入已失效")
}
var syb models.SYBProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, *task.SYBProductID).Error; err != nil {
return "", err
}
var shopee models.ShopeeProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, *task.ShopeeProductID).Error; err != nil {
return "", err
}
var pdd models.PDDProduct
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, task.PDDProductID).Error; err != nil {
return "", err
}
candidates, _ := archiveCandidates(pdd.SpecsJSON, task.TargetColorSnapshot, task.TargetSizeSnapshot)
request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes}
return creationMatchFingerprint(syb, shopee, pdd, request)
}
func matchCandidateValid(target, value string, candidates []string) bool {
if strings.TrimSpace(target) == "" {
return strings.TrimSpace(value) == ""
}
for _, candidate := range candidates {
if candidate == value {
return true
}
}
return false
}
func retryablePurchaseMatch(err error) bool {
var matchErr *aimatching.Error
return errors.As(err, &matchErr) && matchErr.Code == aimatching.CodeProviderUnavailable
}
func matchProviderCode(err error) string {
var matchErr *aimatching.Error
if errors.As(err, &matchErr) {
return matchErr.Code
}
return aimatching.CodeProviderUnavailable
}
func compactMatchReason(value string) string {
value = strings.TrimSpace(value)
if len([]rune(value)) > 500 {
return string([]rune(value)[:500])
}
return value
}
@@ -0,0 +1,109 @@
package purchase
import (
"context"
"errors"
"testing"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
type workerMatcher struct {
result aimatching.MatchResult
err error
calls int
}
func (matcher *workerMatcher) Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) {
matcher.calls++
return matcher.result, matcher.err
}
func queuedMatchTask(t *testing.T) (*gorm.DB, fixture, models.PurchaseTask) {
t.Helper()
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).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)
}
service := testService(db)
service.StartMatching = func(*gorm.DB) {}
task, _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
if err != nil {
t.Fatal(err)
}
return db, f, task
}
func TestPurchaseMatchWorkerConfirmsStrictAIResult(t *testing.T) {
db, _, task := queuedMatchTask(t)
confidence := 0.95
request := aimatching.MatchRequest{TargetColor: "象牙白", TargetSize: "XL", Colors: []string{"白色", "米白色"}, Sizes: []string{"XL"}}
result := aimatching.RecordedMatch(request, aimatching.SourceAI, "白色", "XL", "唯一可靠候选")
result.Decision.Confidence = &confidence
matcher := &workerMatcher{result: result}
worker := NewPurchaseMatchWorker(db)
worker.Matcher = matcher
if processed, err := worker.RunOnce(context.Background()); err != nil || !processed {
t.Fatalf("processed=%v err=%v", processed, err)
}
var saved models.PurchaseTask
var work models.PurchaseSpecMatchWorkItem
_ = db.First(&saved, task.ID).Error
_ = db.Where("purchase_task_id = ?", task.ID).First(&work).Error
if saved.SpecSource != aimatching.SourceAI || saved.MappedColorSnapshot != "白色" || work.Status != models.PurchaseMatchMatched || matcher.calls != 1 {
t.Fatalf("task=%+v work=%+v calls=%d", saved, work, matcher.calls)
}
}
func TestPurchaseMatchWorkerRetriesThenRequiresManual(t *testing.T) {
db, _, task := queuedMatchTask(t)
matcher := &workerMatcher{err: &aimatching.Error{Code: aimatching.CodeProviderUnavailable, Message: "down", Cause: errors.New("temporary")}}
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
worker := NewPurchaseMatchWorker(db)
worker.Matcher = matcher
worker.Now = func() time.Time { return now }
for attempt := 1; attempt <= 3; attempt++ {
if processed, err := worker.RunOnce(context.Background()); err != nil || !processed {
t.Fatalf("attempt=%d processed=%v err=%v", attempt, processed, err)
}
var work models.PurchaseSpecMatchWorkItem
_ = db.Where("purchase_task_id = ?", task.ID).First(&work).Error
if attempt < 3 {
if work.Status != models.PurchaseMatchRetryWait || work.NextAttemptAt == nil {
t.Fatalf("attempt=%d work=%+v", attempt, work)
}
now = work.NextAttemptAt.Add(time.Second)
} else if work.Status != models.PurchaseMatchManualRequired {
t.Fatalf("terminal work=%+v", work)
}
}
if matcher.calls != 3 {
t.Fatalf("calls=%d", matcher.calls)
}
}
func TestManualMatchingUsesCurrentCandidatesAndUnblocksTask(t *testing.T) {
db, f, task := queuedMatchTask(t)
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Update("status", models.PurchaseMatchManualRequired).Error; err != nil {
t.Fatal(err)
}
service := testService(db)
view, err := service.ManualMatching(context.Background(), task.ID, MatchingActionRequest{RequestID: uuid.NewString(), OperatorID: 1, MappedColor: "白色", MappedSize: "XL"})
if err != nil || !view.Executable || view.Status != models.PurchaseMatchMatched {
t.Fatalf("view=%+v err=%v", view, err)
}
if _, err := service.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatalf("matched task remained blocked: %v", err)
}
}
func queuedFixtureToken(*testing.T, *gorm.DB, models.PurchaseTask) string { return "invalid" }
+3
View File
@@ -33,6 +33,9 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
admin.GET("/:taskId", h.AdminDetail)
admin.POST("", h.AdminCreate)
admin.POST("/:taskId/spec-decision", h.SpecDecision)
admin.GET("/:taskId/matching", h.MatchingDetail)
admin.POST("/:taskId/matching/requeue", h.RequeueMatching)
admin.POST("/:taskId/matching/manual", h.ManualMatching)
admin.POST("/:taskId/authorize-repurchase", h.AuthorizeRePurchase)
admin.POST("/:taskId/payment-review", h.ReviewPayment)
admin.POST("/:taskId/writeback-candidate", h.SelectWriteback)
+35 -32
View File
@@ -41,10 +41,11 @@ type Service struct {
Now func() time.Time
LeaseDuration time.Duration
Matcher SpecMatcher
StartMatching func(*gorm.DB)
}
func NewService(db *gorm.DB) *Service {
return &Service{DB: db, Now: func() time.Time { return time.Now().UTC() }, LeaseDuration: DefaultLeaseDuration, Matcher: aimatching.NewService(db)}
return &Service{DB: db, Now: func() time.Time { return time.Now().UTC() }, LeaseDuration: DefaultLeaseDuration, Matcher: aimatching.NewService(db), StartMatching: StartPurchaseMatching}
}
// SpecMatcher is server-only. Android receives only the final exact PDD labels
@@ -55,7 +56,7 @@ type SpecMatcher interface {
type externalMatchPlan struct {
fingerprint string
result aimatching.MatchResult
snapshot string
}
type matchFingerprint struct {
@@ -133,18 +134,14 @@ func (s *Service) prepareExternalMatch(ctx context.Context, req CreateRequest) (
if _, deterministic := aimatching.DeterministicMatch(request); deterministic {
return nil, nil
}
fingerprint, err := creationMatchFingerprint(syb, shopee, pdd, request)
fingerprint, snapshot, err := creationMatchInput(syb, shopee, pdd, request)
if err != nil {
return nil, internal(err)
}
match, err := s.matcher().Resolve(ctx, request)
if err != nil {
return nil, purchaseMatchError(err)
}
return &externalMatchPlan{fingerprint: fingerprint, result: match}, nil
return &externalMatchPlan{fingerprint: fingerprint, snapshot: snapshot}, nil
}
func creationMatchFingerprint(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, error) {
func creationMatchInput(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, string, error) {
canonical := func(raw string) (json.RawMessage, error) {
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
@@ -154,18 +151,23 @@ func creationMatchFingerprint(syb models.SYBProduct, shopee models.ShopeeProduct
}
shopeeSpecs, err := canonical(shopee.SpecsJSON)
if err != nil {
return "", err
return "", "", err
}
pddSpecs, err := canonical(pdd.SpecsJSON)
if err != nil {
return "", err
return "", "", err
}
payload, err := json.Marshal(matchFingerprint{SYBProductID: syb.ID, TargetColor: request.TargetColor, TargetSize: request.TargetSize, Quantity: syb.Quantity, ShopeeProductID: shopee.ID, ShopeeSpecs: shopeeSpecs, PDDProductID: pdd.ID, PDDStatus: pdd.Status, PDDSpecs: pddSpecs, Colors: request.Colors, Sizes: request.Sizes})
if err != nil {
return "", err
return "", "", err
}
digest := sha256.Sum256(payload)
return hex.EncodeToString(digest[:]), nil
return hex.EncodeToString(digest[:]), string(payload), nil
}
func creationMatchFingerprint(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, error) {
fingerprint, _, err := creationMatchInput(syb, shopee, pdd, request)
return fingerprint, err
}
func (s *Service) create(ctx context.Context, req CreateRequest) (models.PurchaseTask, bool, error) {
@@ -361,27 +363,19 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
decisionSnapshot = string(decision)
} else if !archiveUsable {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if externalPlan != nil {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else {
if externalPlan != nil {
// The complete input was revalidated above before selecting a branch.
mappedColor, mappedSize, specSource = externalPlan.result.MappedColor, externalPlan.result.MappedSize, externalPlan.result.Source
decision, marshalErr := json.Marshal(externalPlan.result.Decision)
if marshalErr != nil {
return internal(marshalErr)
}
decisionSnapshot = string(decision)
} else {
match, matchErr := s.matcher().Resolve(ctx, matchRequest)
if matchErr != nil {
return purchaseMatchError(matchErr)
}
mappedColor, mappedSize, specSource = match.MappedColor, match.MappedSize, match.Source
decision, marshalErr := json.Marshal(match.Decision)
if marshalErr != nil {
return internal(marshalErr)
}
decisionSnapshot = string(decision)
match, matched := aimatching.DeterministicMatch(matchRequest)
if !matched {
return fail(CodeMappingRequired, "规格映射不完整,需要异步规格匹配")
}
mappedColor, mappedSize, specSource = match.MappedColor, match.MappedSize, match.Source
decision, marshalErr := json.Marshal(match.Decision)
if marshalErr != nil {
return internal(marshalErr)
}
decisionSnapshot = string(decision)
}
if specSource == "unresolved" && !containsString(rule.RequiredCapabilities, purchasecontract.CapabilitySpecProbeV1) {
return fail(CodeMappingRequired, "规格映射不完整,所选规则不支持规格探测")
@@ -409,8 +403,17 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
if err := tx.Model(&out).Update("address_suffix", out.AddressSuffix).Error; err != nil {
return internal(err)
}
if externalPlan != nil {
work := models.PurchaseSpecMatchWorkItem{PurchaseTaskID: out.ID, Status: models.PurchaseMatchPending, InputFingerprint: externalPlan.fingerprint, InputSnapshotJSON: externalPlan.snapshot}
if err := tx.Create(&work).Error; err != nil {
return internal(err)
}
}
return nil
})
if err == nil && !replayed && externalPlan != nil && s.StartMatching != nil {
s.StartMatching(s.DB)
}
return out, replayed, err
}
+31 -105
View File
@@ -6,11 +6,9 @@ import (
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/device"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
@@ -23,34 +21,6 @@ import (
"gorm.io/gorm/logger"
)
type blockingMatch struct {
started chan struct{}
release chan struct{}
calls int
mu sync.Mutex
}
func (matcher *blockingMatch) Resolve(ctx context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
matcher.mu.Lock()
matcher.calls++
if matcher.calls == 1 {
close(matcher.started)
}
matcher.mu.Unlock()
select {
case <-ctx.Done():
return aimatching.MatchResult{}, ctx.Err()
case <-matcher.release:
return aimatching.RecordedMatch(request, aimatching.SourceAI, "白色", "XL", "唯一候选"), nil
}
}
func (matcher *blockingMatch) count() int {
matcher.mu.Lock()
defer matcher.mu.Unlock()
return matcher.calls
}
type fixture struct {
syb models.SYBProduct
shopee models.ShopeeProduct
@@ -409,89 +379,45 @@ func TestCreateDoesNotTrustMappedLabelsFromRequest(t *testing.T) {
}
}
func TestCreateRejectsUsableArchiveWhenNeitherMatcherFindsSpec(t *testing.T) {
func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil {
t.Fatal(err)
}
_, err := createLive(t, testService(db), f)
if code(err) != CodeMappingRequired || !strings.Contains(err.Error(), "AI") {
t.Fatalf("unmatched active archive should be rejected before dispatch: %v", err)
}
}
func TestCreateWaitsForMatcherBeforeOpeningWriteTransactionAndDeduplicatesConcurrentRequest(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).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)
}
matcher := &blockingMatch{started: make(chan struct{}), release: make(chan struct{})}
service := testService(db)
service.Matcher = matcher
requestID := uuid.NewString()
request := CreateRequest{RequestID: requestID, ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)}
type result struct {
task models.PurchaseTask
replayed bool
err error
}
results := make(chan result, 2)
go func() {
task, replayed, err := service.Create(context.Background(), request)
results <- result{task, replayed, err}
}()
<-matcher.started
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("product_title", "等待期间可更新").Error; err != nil {
t.Fatalf("matcher wait held database transaction: %v", err)
}
go func() {
task, replayed, err := service.Create(context.Background(), request)
results <- result{task, replayed, err}
}()
close(matcher.release)
first, second := <-results, <-results
if first.err != nil || second.err != nil || first.task.ID == 0 || first.task.ID != second.task.ID {
t.Fatalf("concurrent result mismatch: first=%+v second=%+v", first, second)
}
if matcher.count() != 1 || (!first.replayed && !second.replayed) {
t.Fatalf("calls=%d replayed=%v/%v", matcher.count(), first.replayed, second.replayed)
}
}
func TestCreateRejectsExternalMatchWhenInputChanges(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).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)
}
matcher := &blockingMatch{started: make(chan struct{}), release: make(chan struct{})}
service := testService(db)
service.Matcher = matcher
done := make(chan error, 1)
go func() {
_, _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
done <- err
}()
<-matcher.started
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("status", "disabled").Error; err != nil {
t.Fatal(err)
}
close(matcher.release)
if err := <-done; code(err) != CodeMappingRequired {
t.Fatalf("changed input code=%s err=%v", code(err), err)
service.StartMatching = func(*gorm.DB) {}
task, err := createLive(t, service, f)
if err != nil || task.SpecSource != "unresolved" {
t.Fatalf("task=%+v err=%v", task, err)
}
var count int64
db.Model(&models.PurchaseTask{}).Count(&count)
if count != 0 {
t.Fatalf("stale match created %d tasks", count)
if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("work count=%d err=%v", count, err)
}
}
func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).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)
}
service := testService(db)
service.StartMatching = func(*gorm.DB) {}
task, _, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)})
if err != nil || task.ID == 0 || task.SpecSource != "unresolved" {
t.Fatalf("queued task=%+v err=%v", task, err)
}
var work models.PurchaseSpecMatchWorkItem
if err := db.Where("purchase_task_id = ?", task.ID).First(&work).Error; err != nil || work.Status != models.PurchaseMatchPending || work.InputFingerprint == "" {
t.Fatalf("work=%+v err=%v", work, err)
}
if next, err := service.Next(context.Background(), f.token); err != nil || next != nil {
t.Fatalf("matching task dispatched: %+v %v", next, err)
}
}
+20
View File
@@ -82,6 +82,26 @@ type StockCreateRequest struct {
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
}
type MatchingView struct {
Status string `json:"status"`
Executable bool `json:"executable"`
ReasonCode string `json:"reasonCode,omitempty"`
Reason string `json:"reason,omitempty"`
NextAction string `json:"nextAction,omitempty"`
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
TargetColor string `json:"targetColor,omitempty"`
TargetSize string `json:"targetSize,omitempty"`
Colors []string `json:"colors,omitempty"`
Sizes []string `json:"sizes,omitempty"`
}
type MatchingActionRequest struct {
RequestID string `json:"requestId"`
MappedColor string `json:"mappedColor,omitempty"`
MappedSize string `json:"mappedSize,omitempty"`
OperatorID uint64 `json:"-"`
}
type ActionRequest struct {
RequestID string `json:"requestId"`
}
+2
View File
@@ -21,6 +21,7 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/router"
goautodevice "go-admin/app/goauto/device"
goautopurchase "go-admin/app/goauto/purchase"
goautoreplacement "go-admin/app/goauto/replacement"
goautosybimport "go-admin/app/goauto/sybimport"
goautosybinnercode "go-admin/app/goauto/sybinnercode"
@@ -101,6 +102,7 @@ func run() error {
return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err)
}
goautoreplacement.RecoverMatching(db)
goautopurchase.RecoverPurchaseMatching(db)
goautotask.RecoverReplacementActivations(db)
}
offlineMonitorContext, stopOfflineMonitors := context.WithCancel(context.Background())
+3
View File
@@ -7,6 +7,9 @@ export function createPurchaseTasksBatch(data) { return request({ url: '/api/adm
export function retryPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-retry', method: 'post', data, timeout: 60000 }) }
export function createStockPurchaseTask(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/stock', method: 'post', data, ...options }) }
export function createPurchaseTask(data) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'post', data, timeout: 60000 }) }
export function getPurchaseMatching(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/matching`, method: 'get' }) }
export function requeuePurchaseMatching(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/matching/requeue`, method: 'post', data }) }
export function choosePurchaseMatching(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/matching/manual`, method: 'post', data }) }
export function authorizeRepurchase(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/authorize-repurchase`, method: 'post', data }) }
export function reviewPurchasePayment(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/payment-review`, method: 'post', data }) }
export function selectPurchaseWriteback(taskId, data) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}/writeback-candidate`, method: 'post', data }) }
+30 -10
View File
@@ -24,6 +24,7 @@
<el-table-column label="采购规格 / 数量" min-width="190"><template #default="{ row }"><div>{{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}</div><div class="muted">目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件</div></template></el-table-column>
<el-table-column label="设备" min-width="140"><template #default="{ row }"><div>{{ row.deviceName || '尚未分派' }}</div><div class="muted">账号:{{ row.pddAccountRefSnapshot || '不强制记录' }}</div></template></el-table-column>
<el-table-column label="状态" width="150" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag><el-tooltip v-if="row.status === 'failed' && !row.retryable" :content="row.retryDisabledReason || '当前任务不能重试'" placement="top"><span class="retry-disabled" tabindex="0">不可重试</span></el-tooltip></template></el-table-column>
<el-table-column label="规格匹配" min-width="150"><template #default="{ row }"><el-tag :type="matchingType(row.matching?.status)" effect="plain">{{ matchingLabel(row.matching?.status) }}</el-tag><div v-if="row.matching && !row.matching.executable" class="muted">{{ row.matching.reason || '完成前不可执行' }}</div></template></el-table-column>
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
<el-table-column label="创建时间" width="170"><template #default="{ row }">{{ formatTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row)">详情</el-button></template></el-table-column>
@@ -49,6 +50,11 @@
<el-descriptions-item label="设备">{{ detail.task.deviceName || '尚未分派' }}</el-descriptions-item><el-descriptions-item label="地址后缀">{{ detail.task.addressSuffix || '未生成' }}</el-descriptions-item>
</el-descriptions></section>
<section v-if="detail.matching"><h3>规格匹配</h3>
<el-alert :title="matchingLabel(detail.matching.status)" :description="detail.matching.reason || matchingDescription(detail.matching)" :type="matchingType(detail.matching.status)" :closable="false" show-icon class="notice" />
<el-descriptions :column="2" border><el-descriptions-item label="目标规格">{{ specText(detail.matching.targetColor, detail.matching.targetSize) }}</el-descriptions-item><el-descriptions-item label="下次重试">{{ formatTime(detail.matching.nextAttemptAt) }}</el-descriptions-item><el-descriptions-item label="颜色候选">{{ (detail.matching.colors || []).join('、') || '—' }}</el-descriptions-item><el-descriptions-item label="尺码候选">{{ (detail.matching.sizes || []).join('、') || '—' }}</el-descriptions-item></el-descriptions>
<div v-if="['manual_required','cancelled'].includes(detail.matching.status)" class="matching-actions"><el-button :loading="matchingAction.saving" @click="requeueMatching">重新尝试 AI</el-button><el-button v-if="detail.matching.status === 'manual_required'" type="primary" @click="openMatchingDialog">人工选择规格</el-button></div>
</section>
<section><h3>订单与人工处理</h3><div class="state-grid">
<div class="state-card"><span>订单</span><strong>{{ detail.task.pddOrderNo || '尚未取得订单号' }}</strong><small>{{ detail.task.orderSubmittedAt ? `下单时间 ${formatTime(detail.task.orderSubmittedAt)}` : '获取订单号失败即采购失败' }}</small></div>
<div class="state-card"><span>支付复核</span><strong>{{ paymentLabel(detail.task.paymentReviewStatus) }}</strong><small>{{ detail.task.paymentReviewedAt ? formatTime(detail.task.paymentReviewedAt) : '仅人工记录,系统不支付' }}</small></div>
@@ -73,6 +79,12 @@
</div>
</el-drawer>
<el-dialog v-model="matchingDialog.open" title="人工选择 PDD 规格" width="620px" :close-on-click-modal="false">
<el-alert title="只能从当前候选中选择;提交前服务端会重新校验任务和商品规格。" type="info" :closable="false" show-icon class="notice" />
<el-form label-position="top"><el-form-item label="目标规格">{{ specText(detail.matching?.targetColor, detail.matching?.targetSize) }}</el-form-item><el-form-item v-if="detail.matching?.targetColor" label="PDD 颜色(必选)"><el-select v-model="matchingDialog.color" style="width:100%"><el-option v-for="value in detail.matching.colors || []" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item v-if="detail.matching?.targetSize" label="PDD 尺码(必选)"><el-select v-model="matchingDialog.size" style="width:100%"><el-option v-for="value in detail.matching.sizes || []" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-form>
<template #footer><el-button :disabled="matchingDialog.saving" @click="matchingDialog.open=false">取消</el-button><el-button type="primary" :disabled="!canSubmitMatching" :loading="matchingDialog.saving" @click="submitMatching">确认选择</el-button></template>
</el-dialog>
<el-dialog v-model="repurchase.open" title="允许重新采购" width="560px" :close-on-click-modal="false">
<el-alert title="这是一次性授权。后续从 SYB 商品重新创建采购任务时会自动消耗;本操作不会立即创建订单。" type="warning" :closable="false" show-icon class="notice" />
<el-form label-position="top"><el-form-item label="授权原因(必填)"><el-input v-model="repurchase.reason" type="textarea" :rows="3" maxlength="300" show-word-limit placeholder="说明为什么需要再次采购" /></el-form-item><el-form-item label="输入“允许重新采购”确认"><el-input v-model="repurchase.confirmText" autocomplete="off" /></el-form-item></el-form>
@@ -121,7 +133,7 @@
<script>
import { ElMessage, ElMessageBox } from 'element-plus'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
import { authorizeRepurchase, cancelPurchaseTask, getPurchaseTask, listPurchaseTasks, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback } from '@/api/goauto/purchase-tasks'
import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback } from '@/api/goauto/purchase-tasks'
const statusOptions = [
['pending', '待执行'], ['spec_probe_pending', '待探测规格'], ['running', '执行中'], ['rehearsal_completed', '演练完成'],
@@ -135,13 +147,14 @@ export default {
return {
loading: false, loadError: '', tasks: [], total: 0, statuses: statusOptions,
query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' },
detail: { open: false, loading: false, task: null, attempts: [] },
retrySelection: [],
retryDialog: { open: false, saving: false, items: [] },
retryResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
repurchase: { open: false, saving: false, reason: '', confirmText: '' },
cancelDialog: { open: false, saving: false, reason: '' },
unknown: { open: false, saving: false, status: 'order_created', pddOrderNo: '', orderSubmittedAt: '', reason: '' }
detail: { open: false, loading: false, task: null, attempts: [], matching: null },
matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' },
retrySelection: [],
retryDialog: { open: false, saving: false, items: [] },
retryResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
repurchase: { open: false, saving: false, reason: '', confirmText: '' },
cancelDialog: { open: false, saving: false, reason: '' },
unknown: { open: false, saving: false, status: 'order_created', pddOrderNo: '', orderSubmittedAt: '', reason: '' }
}
},
computed: {
@@ -158,6 +171,7 @@ export default {
canCancel() { const t = this.detail.task; return t && !['running', 'order_submit_started', 'cancelled'].includes(t.status) },
canSubmitRepurchase() { return this.repurchase.reason.trim() && this.repurchase.confirmText === '允许重新采购' },
canSubmitUnknown() { return this.unknown.status === 'order_created' ? this.unknown.pddOrderNo.trim() && this.unknown.orderSubmittedAt : this.unknown.reason.trim() },
canSubmitMatching() { const m = this.detail.matching || {}; return (!m.targetColor || this.matchingDialog.color) && (!m.targetSize || this.matchingDialog.size) },
firstRetriedTask() { return this.retryResult.items.find(item => item.created && item.taskId) || null }
},
created() {
@@ -177,10 +191,16 @@ export default {
cleanQuery() { return Object.fromEntries(Object.entries(this.query).filter(([, value]) => value !== '')) },
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '' }; this.load() },
async openDetail(row) { this.detail = { open: true, loading: true, task: null, attempts: [] }; try { await this.refreshDetail(row.id) } finally { this.detail.loading = false } },
async refreshDetail(id = this.detail.task?.id) { const r = await getPurchaseTask(id); this.detail.task = r.data.task; this.detail.attempts = r.data.attempts || [] },
async openDetail(row) { this.detail = { open: true, loading: true, task: null, attempts: [], matching: null }; try { await this.refreshDetail(row.id) } finally { this.detail.loading = false } },
async refreshDetail(id = this.detail.task?.id) { const r = await getPurchaseTask(id); this.detail.task = r.data.task; this.detail.attempts = r.data.attempts || []; this.detail.matching = r.data.matching || r.data.task?.matching || null },
statusLabel(value) { return statusOptions.find(item => item.value === value)?.label || value || '—' },
statusType(value) { return { pending: 'info', spec_probe_pending: 'warning', running: 'warning', rehearsal_completed: 'success', order_submit_started: 'warning', order_created: 'success', order_result_unknown: 'danger', failed: 'danger', cancelled: 'info' }[value] || 'info' },
matchingLabel(value) { return { pending: '等待匹配', running: '匹配中', retry_wait: '等待重试', matched: '已匹配', manual_required: '需要人工处理', cancelled: '输入已失效', unresolved: '执行时探测' }[value] || '未记录' },
matchingType(value) { return { pending: 'info', running: 'warning', retry_wait: 'warning', matched: 'success', manual_required: 'warning', cancelled: 'danger', unresolved: 'info' }[value] || 'info' },
matchingDescription(matching) { return matching.executable ? '规格已就绪。' : '规格匹配完成前任务不可被设备领取或执行。' },
async requeueMatching() { this.matchingAction.saving = true; try { await requeuePurchaseMatching(this.detail.task.id, { requestId: this.requestId() }); ElMessage.success('已重新加入匹配队列'); await this.refreshDetail(); await this.load() } finally { this.matchingAction.saving = false } },
openMatchingDialog() { this.matchingDialog = { open: true, saving: false, color: '', size: '' } },
async submitMatching() { this.matchingDialog.saving = true; try { await choosePurchaseMatching(this.detail.task.id, { requestId: this.requestId(), mappedColor: this.matchingDialog.color, mappedSize: this.matchingDialog.size }); ElMessage.success('规格选择已保存'); this.matchingDialog.open = false; await this.refreshDetail(); await this.load() } finally { this.matchingDialog.saving = false } },
taskTypeLabel(value) { return value === 'stock' ? '备货采购' : '订单采购' },
taskTypeTag(value) { return value === 'stock' ? 'warning' : 'primary' },
modeLabel(value) { return value === 'live' ? '正式' : '演练' },