feat(#44): create purchase tasks from SYB products

This commit is contained in:
QiuSW
2026-08-21 10:17:31 +08:00
parent 09083c8e18
commit 7e4f404265
16 changed files with 702 additions and 22 deletions
+4 -4
View File
@@ -2,8 +2,8 @@
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: 75a180298a15a2dbe73c4230171d8cdfd25364fb
synchronized_at: 2026-08-20T15:49:48Z
wiki_revision: bea774951bc16ac3adba62ff4fdc2c4e750a0884
synchronized_at: 2026-08-21T02:11:49Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -118,13 +118,13 @@ Android Portal/Agent
| SYB 店铺准入、发现与过滤 | `server/app/goauto/sybshop/`、`server/app/goauto/sybimport/`;迁移 `server/cmd/migrate/migration/version-local/1786700900000_syb_shop.go` |
| SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` |
| 采购任务数据与类型化规则契约 | `server/app/goauto/models/purchase.go`、`server/app/goauto/purchasecontract/`;迁移 `server/cmd/migrate/migration/version-local/1786701100000_purchase_contract.go` |
| 采购任务创建、租约、attempt 幂等、Admin 只读查询和人工处置状态机 | `server/app/goauto/purchase/`;追加迁移 `server/cmd/migrate/migration/version-local/1786701200000_purchase_state_machine.go` |
| 采购任务单条/批量预检与创建、租约、attempt 幂等、Admin 只读查询和人工处置状态机 | `server/app/goauto/purchase/`;批量编排复用单任务事务且不新增迁移;既有追加迁移为 `server/cmd/migrate/migration/version-local/1786701200000_purchase_state_machine.go` |
| 任务领取、结果、重置与删除 | `server/app/goauto/task/` |
| 管理端基线 | `web/`(go-admin-ui v3.0.0) |
| 管理端闭环页面 | `web/src/views/goauto/` |
| Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 |
| SYB 店铺管理页面与接口封装 | `web/src/views/goauto/syb-shops/`、`web/src/api/goauto/syb-shops.js`;确认原型快照 `prototypes/49/v1/index.html` |
| SYB 异步导入入口与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`;确认原型快照 `prototypes/50/v2/index.html` |
| SYB 异步导入、当前页采购选择/确认/逐条结果与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`、`web/src/api/goauto/purchase-tasks.js`;确认原型见 #44 设计证据,导入原型快照为 `prototypes/50/v2/index.html` |
| Android Agent 基线 | `android/app/src/main/java/cn/ilapage/goauto/agent/` |
| Android 无障碍规则执行 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/` |
| Android 采购演练规则解释器 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt`、`PurchaseRehearsalExecutor.kt` |
+8 -5
View File
@@ -2,8 +2,8 @@
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: 5476b34a8620bcd2e77c40b42938c42c6cb43675
synchronized_at: 2026-08-20T15:54:44Z
wiki_revision: 6f0a0c4d36c7101b5e81660363865f2e75759584
synchronized_at: 2026-08-21T02:11:53Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -43,7 +43,7 @@ synchronized_at: 2026-08-20T15:54:44Z
- 关联虾皮商品档案:命中存活记录直接关联;命中软删除记录则复活并保留原有人工映射;均不存在时创建最小档案。命中既有档案时只补写为空的参考图与售价,不覆盖人工修改过的标题、店铺和映射。
- 重新解析只读取已保存的原始 JSON,不请求 SYB 接口,永不改写 `RawJSON`;标记 `manuallyConfirmed` 的明细默认跳过重新解析,除非显式指定强制覆盖。
- 人工修正的颜色/尺码视为与解析成功同等可信,同样会合并进虾皮商品档案;标记为已人工确认。
- 不创建采购任务,不保存 PDD 订单号、快递单号、支付或有效订单状态;采购发起入口不在本域实现。
- SYB 商品数据本身不保存 PDD 订单号、快递单号、支付或有效订单状态;Admin 的采购任务创建入口位于 SYB 商品列表,任务事实仍写入独立采购域。
## SYB 店铺过滤
@@ -113,6 +113,9 @@ synchronized_at: 2026-08-20T15:54:44Z
- `execution_mode` 创建后不可变:`rehearsal` 只能完成商品、规格、数量和价格复核;`live` 才可能获得改地址和创建订单能力。任何模式永远禁止支付。
- 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。
- 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。
- Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。
- 批量创建使用服务端内置并经契约校验的正式采购规则,每条任务保存不可变快照;设备默认人工指定,也可以留空由符合能力的空闲设备领取。
- 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:已确认颜色映射时按该颜色人民币价格计算;待规格探测时用可用颜色最低价的 0.2 倍到最高价的 1.5 倍,参考价取最高价。没有可用颜色价格时不能创建。
- 地址后缀为 `_cg{purchase_task.id}`。修改失败时禁止创建订单;任务与订单正式关联仍以完整 PDD 订单号为准。
- 点击创建订单前必须先保存 `order_submit_started` 和不可逆时间;结果不明时进入 `order_result_unknown`,禁止自动再次点击。
- Agent 本地 Room/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。
@@ -120,9 +123,9 @@ synchronized_at: 2026-08-20T15:54:44Z
- 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。
- 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。无匹配结果明确失败。
- Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。
- 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。
- Admin 采购管理只查看和处理已有任务,不提供创建入口或支付按钮;采购任务创建入口属于 SYB 商品模块的独立工单。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。
- Admin 采购管理只查看和处理已有任务,不提供创建入口或支付按钮;单条和批量采购任务都从 SYB 商品列表发起。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。
## 自动化边界
+8 -6
View File
@@ -2,8 +2,8 @@
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: 5952395a43ce26d7ee6cdc8076f2367769de9310
synchronized_at: 2026-08-20T15:29:46Z
wiki_revision: 8fa791cf3f249096ddad2ede02bf13251f963407
synchronized_at: 2026-08-21T02:15:58Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -369,9 +369,9 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `DEVICE_BUSY` | 设备已有活动任务 | 否 |
| `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 |
## 采购任务共享契约(#33、#34、#42、#53)
## 采购任务共享契约(#33、#34、#42、#44、#53)
本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机及 Android 演练执行已经落地。Admin 页面和正式地址/订单动作仍由后续工单实现。任何实现都不得扩展为自动支付。
本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口及 Android 演练执行已经落地。正式地址/订单动作仍由后续高风险工单实现。任何实现都不得扩展为自动支付。
### 任务与快照
@@ -463,6 +463,8 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `GET` | `/api/admin/v1/purchase-tasks` | 分页列表;可按任务 ID、状态、模式、SYB 商品 ID 和 PDD 订单号筛选 |
| `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 |
| `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 |
| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回是否可创建、价格区间、原因和下一步,不创建任务 |
| `POST` | `/api/admin/v1/purchase-tasks/batch` | 批次 `requestId`、`sybProductIds`(1~100)和可选 `deviceId`;每条派生稳定幂等键并独立创建,部分失败不回滚成功项 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/payment-review` | `paid` 或 `unpaid`,管理员和采购员可操作 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/writeback-candidate` | 人工选择回填候选;后选的已支付订单覆盖同一 SYB 明细的旧选择 |
@@ -470,9 +472,9 @@ POST /api/agent/v1/tasks/{taskId}/fail
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/resolve-unknown` | 人工把结果未知解除为已创建订单或已取消 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/spec-decision` | 固化第一趟规格探测决策;同一 attempt 不可改写 |
本工单只提供状态机写接口;列表、详情、批量创建等 Admin 查询与交互接口由 #35 按已确认原型补齐。
Admin 列表与详情由 #35 实现;#44 在 SYB 商品列表提供单条/当前页批量创建入口。页面预检只负责提前解释,`batch` 提交时仍逐条执行现有单任务事务和活动任务、订单、支付、设备能力门禁;返回项包含任务号,或失败原因与下一步建议。只有管理员和采购员可以调用。
创建请求的价格保护使用整数分:`referenceUnitPriceCent`、`minUnitPriceCent`、`maxUnitPriceCent` 和 `currency`。执行时以 PDD App 实际单价校验;低于最小值或高于最大值均返回普通人可理解的价格越界错误,不考虑优惠券,不以订单总价替代单价判断。
创建请求的价格保护使用整数分:`referenceUnitPriceCent`、`minUnitPriceCent`、`maxUnitPriceCent` 和 `currency`。执行时以 PDD App 实际单价校验;低于最小值或高于最大值均返回普通人可理解的价格越界错误,不考虑优惠券,不以订单总价替代单价判断。 #44 批量创建不接受浏览器提交价格:服务端从 PDD 颜色规格价格生成 CNY 快照,已映射颜色按该颜色的 0.2~1.5 倍,待规格探测时按全部可用颜色最低价的 0.2 倍到最高价的 1.5 倍;没有可用颜色价格时逐条拒绝。SYB/Shopee 的 TWD 售价不参与 PDD 采购价格保护。
### Android 接口(由 #34 实现)
@@ -102,6 +102,19 @@ func TestAdminQueryHandlersRequireOperatorRole(t *testing.T) {
}
}
func TestAdminBatchHandlersRequireOperatorRole(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, handler := range []func(*gin.Context){(Handler{}).AdminBatchPreview, (Handler{}).AdminBatchCreate} {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodPost, "/api/admin/v1/purchase-tasks/batch", strings.NewReader(`{}`))
handler(context)
if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "FORBIDDEN") {
t.Fatalf("unauthorized batch handler returned %d %s", recorder.Code, recorder.Body.String())
}
}
}
func TestAdminSuccessEnvelopeIsCompatibleWithGoAdminUI(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
+318
View File
@@ -0,0 +1,318 @@
package purchase
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"go-admin/app/goauto/models"
"go-admin/app/goauto/product"
"go-admin/app/goauto/purchasecontract"
"github.com/google/uuid"
"gorm.io/gorm"
)
const maxBatchPurchaseItems = 100
type BatchPreviewRequest struct {
SYBProductIDs []uint64 `json:"sybProductIds"`
DeviceID *uint64 `json:"deviceId,omitempty"`
}
type BatchCreateRequest struct {
RequestID string `json:"requestId"`
SYBProductIDs []uint64 `json:"sybProductIds"`
DeviceID *uint64 `json:"deviceId,omitempty"`
}
type BatchPreviewItem struct {
SYBProductID uint64 `json:"sybProductId"`
OrderCode string `json:"orderCode,omitempty"`
ShopeeProductID *uint64 `json:"shopeeProductId,omitempty"`
ShopeeItemID string `json:"shopeeItemId,omitempty"`
ProductTitle string `json:"productTitle,omitempty"`
PDDProductID *uint64 `json:"pddProductId,omitempty"`
PDDGoodsID string `json:"pddGoodsId,omitempty"`
PDDTitle string `json:"pddTitle,omitempty"`
TargetColor string `json:"targetColor,omitempty"`
TargetSize string `json:"targetSize,omitempty"`
MappedColor string `json:"mappedColor,omitempty"`
MappedSize string `json:"mappedSize,omitempty"`
Quantity int64 `json:"quantity,omitempty"`
ReferenceUnitPriceCent int64 `json:"referenceUnitPriceCent,omitempty"`
MinUnitPriceCent int64 `json:"minUnitPriceCent,omitempty"`
MaxUnitPriceCent int64 `json:"maxUnitPriceCent,omitempty"`
Currency string `json:"currency,omitempty"`
Eligible bool `json:"eligible"`
ReasonCode string `json:"reasonCode,omitempty"`
Reason string `json:"reason,omitempty"`
NextAction string `json:"nextAction,omitempty"`
ActiveTaskID *uint64 `json:"activeTaskId,omitempty"`
}
type BatchPreviewResponse struct {
Items []BatchPreviewItem `json:"items"`
EligibleCount int `json:"eligibleCount"`
SkippedCount int `json:"skippedCount"`
}
type BatchCreateItem struct {
BatchPreviewItem
Created bool `json:"created"`
TaskID *uint64 `json:"taskId,omitempty"`
TaskNo string `json:"taskNo,omitempty"`
Replayed bool `json:"replayed,omitempty"`
}
type BatchCreateResponse struct {
Items []BatchCreateItem `json:"items"`
CreatedCount int `json:"createdCount"`
FailedCount int `json:"failedCount"`
}
func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (BatchPreviewResponse, error) {
ids, err := validateBatchIDs(req.SYBProductIDs)
if err != nil {
return BatchPreviewResponse{}, err
}
deviceErr := s.validateBatchDevice(ctx, req.DeviceID)
items := make([]BatchPreviewItem, 0, len(ids))
eligible := 0
for _, id := range ids {
item := s.previewOne(ctx, id)
if item.Eligible && deviceErr != nil {
item.Eligible = false
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(deviceErr)
}
if item.Eligible {
eligible++
}
items = append(items, item)
}
return BatchPreviewResponse{Items: items, EligibleCount: eligible, SkippedCount: len(items) - eligible}, nil
}
func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (BatchCreateResponse, error) {
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
return BatchCreateResponse{}, fail(CodeInvalidRequest, "requestId 无效")
}
ids, err := validateBatchIDs(req.SYBProductIDs)
if err != nil {
return BatchCreateResponse{}, err
}
deviceErr := s.validateBatchDevice(ctx, req.DeviceID)
result := BatchCreateResponse{Items: make([]BatchCreateItem, 0, len(ids))}
for _, id := range ids {
itemRequestID := batchItemRequestID(req.RequestID, id)
var replay models.PurchaseTask
if err := s.DB.WithContext(ctx).Where("create_request_id = ?", itemRequestID).First(&replay).Error; err == nil {
taskID := replay.ID
preview := s.previewOne(ctx, id)
preview.Eligible, preview.ReasonCode, preview.Reason, preview.NextAction, preview.ActiveTaskID = true, "", "", "", nil
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview, Created: true, TaskID: &taskID, TaskNo: taskNumber(taskID), Replayed: true})
result.CreatedCount++
continue
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return BatchCreateResponse{}, internal(err)
}
preview := s.previewOne(ctx, id)
if preview.Eligible && deviceErr != nil {
preview.Eligible = false
preview.ReasonCode, preview.Reason, preview.NextAction = serviceErrorFields(deviceErr)
}
if !preview.Eligible {
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview})
result.FailedCount++
continue
}
sybID := id
task, replayed, createErr := s.Create(ctx, CreateRequest{
RequestID: itemRequestID, ExecutionMode: models.PurchaseExecutionModeLive,
SYBProductID: &sybID, DeviceID: req.DeviceID,
ReferenceUnitPriceCent: preview.ReferenceUnitPriceCent,
MinUnitPriceCent: preview.MinUnitPriceCent, MaxUnitPriceCent: preview.MaxUnitPriceCent,
Currency: preview.Currency, RuleSnapshot: purchasecontract.DefaultLiveRule(),
})
if createErr != nil {
preview.Eligible = false
preview.ReasonCode, preview.Reason, preview.NextAction = serviceErrorFields(createErr)
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview})
result.FailedCount++
continue
}
taskID := task.ID
result.Items = append(result.Items, BatchCreateItem{BatchPreviewItem: preview, Created: true, TaskID: &taskID, TaskNo: taskNumber(taskID), Replayed: replayed})
result.CreatedCount++
}
return result, nil
}
func validateBatchIDs(raw []uint64) ([]uint64, error) {
if len(raw) == 0 || len(raw) > maxBatchPurchaseItems {
return nil, fail(CodeInvalidRequest, "sybProductIds 必须包含 1 至 100 条当前页商品")
}
seen := make(map[uint64]bool, len(raw))
ids := make([]uint64, 0, len(raw))
for _, id := range raw {
if id == 0 || seen[id] {
return nil, fail(CodeInvalidRequest, "sybProductIds 包含无效或重复的商品")
}
seen[id] = true
ids = append(ids, id)
}
return ids, nil
}
func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64) error {
if deviceID == nil {
return nil
}
var record models.AgentDevice
if err := s.DB.WithContext(ctx).First(&record, *deviceID).Error; err != nil {
return notFound(err, "设备不存在")
}
if record.Status != models.DeviceStatusOnline || record.TokenRevokedAt != nil {
return fail(CodeInvalidRequest, "所选设备当前不可用,请选择在线设备或留空")
}
rule, err := purchasecontract.Validate(purchasecontract.DefaultLiveRule(), models.PurchaseExecutionModeLive)
if err != nil {
return internal(err)
}
return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule))
}
func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
item := BatchPreviewItem{SYBProductID: id}
var syb models.SYBProduct
if err := s.DB.WithContext(ctx).First(&syb, id).Error; err != nil {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PRODUCT_NOT_FOUND", "SYB 商品不存在或已被删除", "refresh"
return item
}
item.OrderCode, item.ShopeeProductID, item.ShopeeItemID = syb.OrderCode, syb.ShopeeProductID, syb.ShopeeItemID
item.ProductTitle, item.TargetColor, item.TargetSize, item.Quantity = syb.ProductTitle, syb.TargetColor, syb.TargetSize, syb.Quantity
if syb.ParseStatus == models.SYBParseStatusFailed {
item.ReasonCode, item.Reason, item.NextAction = "SYB_PARSE_FAILED", "解析失败,请先重新解析或人工修正", "reparse"
return item
}
if syb.ShopeeProductID == nil {
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_LINKED", "尚未关联蝦皮商品,请先重新导入或处理商品档案", "refresh"
return item
}
var shopee models.ShopeeProduct
if err := s.DB.WithContext(ctx).First(&shopee, *syb.ShopeeProductID).Error; err != nil {
item.ReasonCode, item.Reason, item.NextAction = "SHOPEE_NOT_FOUND", "关联的蝦皮商品不存在,请先处理商品档案", "open_shopee"
return item
}
if shopee.PDDProductID == nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_LINKED", "尚未关联拼多多商品,请先去匹配", "open_mapping"
return item
}
item.PDDProductID = shopee.PDDProductID
var pdd models.PDDProduct
if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_FOUND", "关联的拼多多商品不存在,请先重新匹配", "open_mapping"
return item
}
item.PDDGoodsID, item.PDDTitle = pdd.GoodsID, pdd.Title
if pdd.Status != "active" {
item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_READY", "拼多多商品尚未采集完成或已停用", "open_pdd"
return item
}
item.MappedColor, item.MappedSize, _ = confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor)
if err != nil {
item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd"
return item
}
item.ReferenceUnitPriceCent, item.MinUnitPriceCent, item.MaxUnitPriceCent, item.Currency = reference, minPrice, maxPrice, "CNY"
var previous models.PurchaseTask
err = s.DB.WithContext(ctx).Where("syb_product_id = ?", syb.ID).Order("id DESC").First(&previous).Error
if err == nil {
item.ActiveTaskID = &previous.ID
switch previous.Status {
case models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted:
case models.PurchaseTaskStatusOrderCreated:
if previous.PaymentReviewStatus == models.PurchasePaymentReviewPaid {
item.ReasonCode, item.Reason, item.NextAction = "PAID_ORDER_EXISTS", "该商品已有已支付订单,不能再次创建", "open_task"
return item
}
if previous.RePurchaseAuthorizedAt == nil || previous.RePurchaseConsumedAt != nil {
item.ReasonCode, item.Reason, item.NextAction = CodeRePurchaseRequired, "该商品已经采购成功,请先在采购任务中允许重新采购", "open_task"
return item
}
default:
item.ReasonCode, item.Reason, item.NextAction = CodeStateConflict, "该商品已有未结束的采购任务", "open_task"
return item
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
return item
}
item.ActiveTaskID = nil
item.Eligible = true
return item
}
func purchasePriceRange(raw, mappedColor string) (reference, minPrice, maxPrice int64, err error) {
var dimensions []product.SpecDimension
if json.Unmarshal([]byte(raw), &dimensions) != nil {
return 0, 0, 0, errors.New("拼多多商品规格数据无效,请先重新采集")
}
prices := make([]int64, 0)
for _, dimension := range dimensions {
if dimension.Role != "color" {
continue
}
for _, value := range dimension.Values {
if !value.Selectable || value.PriceCent == nil || *value.PriceCent < 0 {
continue
}
if mappedColor == "" || value.Name == mappedColor {
prices = append(prices, *value.PriceCent)
}
}
}
if len(prices) == 0 {
if mappedColor != "" {
return 0, 0, 0, fmt.Errorf("拼多多颜色“%s”没有可用价格,请先检查商品规格", mappedColor)
}
return 0, 0, 0, errors.New("拼多多商品没有可用的颜色价格,请先采集或录入")
}
sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
low, high := prices[0], prices[len(prices)-1]
return high, low / 5, (high*3 + 1) / 2, nil
}
func batchItemRequestID(batchRequestID string, sybProductID uint64) string {
return uuid.NewSHA1(uuid.NameSpaceOID, []byte(fmt.Sprintf("%s:%d", batchRequestID, sybProductID))).String()
}
func taskNumber(id uint64) string { return fmt.Sprintf("CG-%d", id) }
func serviceErrorFields(err error) (string, string, string) {
var serviceErr *ServiceError
if errors.As(err, &serviceErr) {
next := "retry"
switch serviceErr.Code {
case CodeMappingRequired:
next = "open_mapping"
case CodeStateConflict, CodeRePurchaseRequired:
next = "open_task"
case CodeCapabilityMismatch:
next = "select_device"
case CodeInvalidRequest:
if strings.Contains(serviceErr.Message, "设备") {
next = "select_device"
} else {
next = "refresh"
}
}
return serviceErr.Code, serviceErr.Message, next
}
return CodeInternal, "服务端处理失败", "retry"
}
+114
View File
@@ -0,0 +1,114 @@
package purchase
import (
"context"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/purchasecontract"
"github.com/google/uuid"
"gorm.io/gorm"
)
func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, fixture.pdd.ID)
failed := fixture.syb
failed.ID = 0
failed.OrderCode = "SYB-FAILED"
failed.DetailID++
failed.ParseStatus = models.SYBParseStatusFailed
failed.TargetColor, failed.TargetSize = "", ""
if err := db.Create(&failed).Error; err != nil {
t.Fatal(err)
}
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
SYBProductIDs: []uint64{fixture.syb.ID, failed.ID}, DeviceID: &fixture.device.ID,
})
if err != nil {
t.Fatal(err)
}
if response.EligibleCount != 1 || response.SkippedCount != 1 {
t.Fatalf("unexpected counts: %+v", response)
}
ready := response.Items[0]
if !ready.Eligible || ready.ReferenceUnitPriceCent != 2000 || ready.MinUnitPriceCent != 400 || ready.MaxUnitPriceCent != 3000 || ready.Currency != "CNY" {
t.Fatalf("PDD price guard was not derived: %+v", ready)
}
if response.Items[1].Eligible || response.Items[1].ReasonCode != "SYB_PARSE_FAILED" || response.Items[1].NextAction != "reparse" {
t.Fatalf("failed parse explanation missing: %+v", response.Items[1])
}
}
func TestBatchCreateKeepsPartialSuccessAndReplaysCreatedItems(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, liveCaps(), true)
setCollectedPDDPrice(t, db, fixture.pdd.ID)
blocked := fixture.syb
blocked.ID = 0
blocked.OrderCode = "SYB-BLOCKED"
blocked.DetailID++
if err := db.Create(&blocked).Error; err != nil {
t.Fatal(err)
}
service := testService(db)
_, _, err := service.Create(context.Background(), CreateRequest{
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
SYBProductID: &blocked.ID, DeviceID: &fixture.device.ID,
ReferenceUnitPriceCent: 2000, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000,
Currency: "CNY", RuleSnapshot: purchasecontract.DefaultLiveRule(),
})
if err != nil {
t.Fatal(err)
}
request := BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{fixture.syb.ID, blocked.ID}, DeviceID: &fixture.device.ID}
first, err := service.BatchCreate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if first.CreatedCount != 1 || first.FailedCount != 1 || !first.Items[0].Created || first.Items[1].ReasonCode != CodeStateConflict {
t.Fatalf("partial result mismatch: %+v", first)
}
if first.Items[0].TaskID == nil || first.Items[0].TaskNo == "" {
t.Fatalf("created task identity missing: %+v", first.Items[0])
}
second, err := service.BatchCreate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if second.CreatedCount != 1 || !second.Items[0].Replayed || *second.Items[0].TaskID != *first.Items[0].TaskID {
t.Fatalf("batch replay was not stable: first=%+v second=%+v", first, second)
}
var taskCount int64
db.Model(&models.PurchaseTask{}).Where("syb_product_id = ?", fixture.syb.ID).Count(&taskCount)
if taskCount != 1 {
t.Fatalf("replay created %d tasks", taskCount)
}
}
func TestBatchPreviewRejectsIncompatibleDevice(t *testing.T) {
db := testDB(t)
fixture := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true)
setCollectedPDDPrice(t, db, fixture.pdd.ID)
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{
SYBProductIDs: []uint64{fixture.syb.ID}, DeviceID: &fixture.device.ID,
})
if err != nil {
t.Fatal(err)
}
if response.EligibleCount != 0 || response.Items[0].ReasonCode != CodeCapabilityMismatch || response.Items[0].NextAction != "select_device" {
t.Fatalf("incompatible device was accepted: %+v", response)
}
}
func setCollectedPDDPrice(t *testing.T, db *gorm.DB, productID uint64) {
t.Helper()
if err := db.Model(&models.PDDProduct{}).Where("id = ?", productID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]}]`).Error; err != nil {
t.Fatal(err)
}
}
+40
View File
@@ -99,6 +99,46 @@ func (h Handler) AdminCreate(c *gin.Context) {
}
writeAdminReplay(c, record, replayed)
}
func (h Handler) AdminBatchPreview(c *gin.Context) {
if !allowedOperator(c) {
return
}
var req BatchPreviewRequest
if !decode(c, &req) {
return
}
service, ok := h.service(c)
if !ok {
return
}
response, err := service.BatchPreview(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeAdminData(c, response)
}
func (h Handler) AdminBatchCreate(c *gin.Context) {
if !allowedOperator(c) {
return
}
var req BatchCreateRequest
if !decode(c, &req) {
return
}
service, ok := h.service(c)
if !ok {
return
}
response, err := service.BatchCreate(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
writeAdminData(c, response)
}
func (h Handler) Next(c *gin.Context) {
service, ok := h.service(c)
if !ok {
+3
View File
@@ -15,6 +15,9 @@ func (s *Service) AuthorizeRePurchase(ctx context.Context, id uint64, req Manual
if t.Status != models.PurchaseTaskStatusOrderCreated && t.Status != models.PurchaseTaskStatusFailed && t.Status != models.PurchaseTaskStatusCancelled {
return fail(CodeStateConflict, "当前任务不能授权重新采购")
}
if t.Status == models.PurchaseTaskStatusOrderCreated && t.PaymentReviewStatus == models.PurchasePaymentReviewPaid {
return fail(CodeStateConflict, "该订单已支付,不能授权重新采购")
}
if t.RePurchaseRequestID != nil {
return fail(CodeResultConflict, "重新采购授权已经记录")
}
+2
View File
@@ -23,6 +23,8 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
agent.POST("/:taskId/result", h.Result)
admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
admin.GET("", h.AdminList)
admin.POST("/batch-preview", h.AdminBatchPreview)
admin.POST("/batch", h.AdminBatchCreate)
admin.GET("/:taskId", h.AdminDetail)
admin.POST("", h.AdminCreate)
admin.POST("/:taskId/spec-decision", h.SpecDecision)
+10 -1
View File
@@ -79,6 +79,9 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas
switch previous.Status {
case models.PurchaseTaskStatusFailed, models.PurchaseTaskStatusCancelled, models.PurchaseTaskStatusRehearsalCompleted:
case models.PurchaseTaskStatusOrderCreated:
if previous.PaymentReviewStatus == models.PurchasePaymentReviewPaid {
return fail(CodeRePurchaseRequired, "该商品已有已支付订单,不能再次创建")
}
if previous.RePurchaseAuthorizedAt == nil || previous.RePurchaseConsumedAt != nil {
return fail(CodeRePurchaseRequired, "该商品已经采购成功,需要先授权重新采购")
}
@@ -125,7 +128,13 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas
var sybID, shopeeID *uint64
if req.ExecutionMode == models.PurchaseExecutionModeLive {
sybID, shopeeID = &syb.ID, &shopee.ID
quantity, ref = syb.Quantity, syb.UnitPriceCent
quantity = syb.Quantity
if ref == 0 {
// Backwards-compatible fallback for direct API callers. The Admin
// batch flow supplies a CNY reference derived from the selected PDD
// color price and never uses the cross-border Shopee/SYB amount.
ref = syb.UnitPriceCent
}
if targetColor == "" {
targetColor = syb.TargetColor
}
@@ -335,4 +335,35 @@ func TestSuccessfulTaskNeedsOneTimeRePurchaseAuthorization(t *testing.T) {
t.Fatalf("authorization reused: %v", err)
}
}
func TestPaidOrderCannotBeRepurchasedEvenWhenAuthorized(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
s := testService(db)
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
now := s.Now()
task.SetStatus(models.PurchaseTaskStatusOrderCreated)
task.PDDOrderNo = ptrString("PDD-PAID")
task.OrderSubmittedAt = &now
if err = db.Save(&task).Error; err != nil {
t.Fatal(err)
}
if _, _, err = s.ReviewPayment(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1, Status: models.PurchasePaymentReviewPaid}); err != nil {
t.Fatal(err)
}
if _, _, err = s.AuthorizeRePurchase(context.Background(), task.ID, ManualRequest{RequestID: uuid.NewString(), OperatorID: 1}); code(err) != CodeStateConflict || !strings.Contains(err.Error(), "已支付") {
t.Fatalf("paid order was authorized: %v", err)
}
authorizedAt := s.Now()
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", task.ID).Updates(map[string]any{"re_purchase_authorized_at": authorizedAt, "re_purchase_authorized_by": 1}).Error; err != nil {
t.Fatal(err)
}
if _, err = createLive(t, s, f); code(err) != CodeRePurchaseRequired || !strings.Contains(err.Error(), "已支付") {
t.Fatalf("paid order was allowed to repurchase: %v", err)
}
}
func ptrString(v string) *string { return &v }
@@ -0,0 +1,11 @@
package purchasecontract
import "encoding/json"
// DefaultLiveRule is the reviewed server-owned rule used by the Admin batch
// creation flow. Purchase rules do not yet have their own editable archive;
// every task therefore freezes this exact snapshot so later contract changes
// cannot silently alter work that is already queued.
func DefaultLiveRule() json.RawMessage {
return json.RawMessage(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1","purchase.spec-probe.v1"],"actions":[{"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":1000},{"type":"verifyProduct"},{"type":"openSpecPanel","textAliases":["选择规格"],"swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}},{"type":"probeSpecs"},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}]}`)
}
@@ -0,0 +1,20 @@
package purchasecontract
import (
"strings"
"testing"
)
func TestDefaultLiveRuleIsValidAndNeverContainsPayment(t *testing.T) {
raw := DefaultLiveRule()
if strings.Contains(strings.ToLower(string(raw)), "pay") || strings.Contains(string(raw), "支付") {
t.Fatalf("default live rule contains a payment action: %s", raw)
}
rule, err := Validate(raw, "live")
if err != nil {
t.Fatal(err)
}
if rule.RuleType != RuleTypePurchase || len(rule.Actions) == 0 {
t.Fatalf("unexpected rule: %+v", rule)
}
}
+2
View File
@@ -2,6 +2,8 @@ import request from '@/utils/request'
export function listPurchaseTasks(params) { return request({ url: '/api/admin/v1/purchase-tasks', method: 'get', params }) }
export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) }
export function previewPurchaseTasks(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data }) }
export function createPurchaseTasksBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch', 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 }) }
+10 -2
View File
@@ -121,13 +121,21 @@ export default {
if (task.executionMode === 'rehearsal') return { type: 'info', title: '这是演练任务', description: '演练只验证流程,不允许提交订单。' }
return { type: 'info', title: '任务正在采购流程中', description: '设备串行执行,进入提交订单阶段后不能直接取消。' }
},
canAuthorizeRepurchase() { const t = this.detail.task; return t && ['order_created', 'failed', 'cancelled'].includes(t.status) && !t.rePurchaseAuthorizedAt },
canAuthorizeRepurchase() { const t = this.detail.task; return t && ['order_created', 'failed', 'cancelled'].includes(t.status) && t.paymentReviewStatus !== 'paid' && !t.rePurchaseAuthorizedAt },
canSelectWriteback() { const t = this.detail.task; return t && t.status === 'order_created' && t.paymentReviewStatus === 'paid' && t.writebackStatus === 'not_selected' },
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() }
},
created() { this.load() },
created() {
const taskId = Number(this.$route.query.taskId)
if (Number.isInteger(taskId) && taskId > 0) {
this.query.taskId = String(taskId)
this.load().then(() => this.openDetail({ id: taskId }))
return
}
this.load()
},
methods: {
async load() {
this.loading = true; this.loadError = ''
+108 -4
View File
@@ -6,9 +6,10 @@
<el-form-item><el-tooltip :disabled="isAdmin" content="只有管理员可以开始导入" placement="bottom"><span><el-button type="primary" :icon="Download" :disabled="!isAdmin" @click="openImport">从 SYB 导入</el-button></span></el-tooltip></el-form-item>
<el-form-item><el-button @click="$router.push('/syb-sync-runs/index')">同步记录</el-button></el-form-item>
<el-form-item><span class="selection-count" aria-live="polite">已选择 <strong>{{ selectedProducts.length }}</strong> 条</span></el-form-item>
<el-form-item v-if="canPurchase"><el-radio-group v-model="batchMode" size="small" aria-label="批量操作用途" @change="changeBatchMode"><el-radio-button value="purchase">创建采购</el-radio-button><el-radio-button value="reparse">重新解析</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="货运单号、虾皮商品ID或商品标题" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 140px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button :disabled="selectedProducts.length === 0" @click="openReparseBatch">重新解析</el-button></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-button v-if="batchMode === 'reparse'" :disabled="selectedProducts.length === 0" @click="openReparseBatch">重新解析</el-button><el-button v-if="canPurchase && batchMode === 'purchase'" type="primary" :disabled="selectedProducts.length === 0" @click="openPurchaseBatch">创建采购任务</el-button></el-form-item>
</el-form>
<div v-if="activeRun" class="sync-banner" :class="`is-${activeRun.status}`" aria-live="polite">
<div>
@@ -19,8 +20,9 @@
</div>
<el-button type="primary" link @click="$router.push({ path: '/syb-sync-runs/index', query: { runId: activeRun.id }})">查看详情</el-button>
</div>
<el-alert v-if="canPurchase && batchMode === 'purchase'" title="表头全选只选择当前页中可以创建采购任务的商品;不可创建的原因显示在“采购准备”列。切换到“重新解析”后可选择其他商品。" type="info" :closable="false" show-icon class="notice compact-notice" />
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无 SYB 商品明细" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="48" />
<el-table-column type="selection" width="48" :selectable="rowSelectable" />
<el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column>
<el-table-column label="货运单号" prop="orderCode" min-width="120" />
<el-table-column label="虾皮商品" min-width="421"><template #default="{ row }"><a class="link" href="javascript:void(0)" @click="openShopeeDetail(row.shopeeProductId)">{{ row.shopeeItemId }} ↗</a><div class="muted">{{ row.productTitle || '未取到标题' }}</div></template></el-table-column>
@@ -30,7 +32,8 @@
<el-table-column label="售价" width="110"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column>
<el-table-column label="虾皮关联" width="80"><template #default="{ row }"><el-tag :type="row.shopeeProductId ? 'success' : 'info'">{{ row.shopeeProductId ? '已关联' : '未关联' }}</el-tag></template></el-table-column>
<el-table-column label="解析状态" width="80"><template #default="{ row }"><el-tag :type="parseMeta(row.parseStatus).type">{{ parseMeta(row.parseStatus).label }}</el-tag><el-tag v-if="row.manuallyConfirmed" size="small" type="success" effect="plain" style="margin-left:4px">人工已确认</el-tag></template></el-table-column>
<el-table-column label="操作" width="45" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button></template></el-table-column>
<el-table-column v-if="canPurchase" label="采购准备" min-width="170"><template #default="{ row }"><span v-if="purchaseReadinessLoading" class="muted">正在检查</span><template v-else-if="purchaseReady(row).eligible"><el-tag type="success">可以创建</el-tag><div class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div></template><template v-else><el-tag type="warning">暂不可创建</el-tag><div class="purchase-reason">{{ purchaseReady(row).reason || '请刷新后重试' }}</div><el-button v-if="purchaseActionLabel(purchaseReady(row))" type="primary" link @click="runPurchaseNextAction(row, purchaseReady(row))">{{ purchaseActionLabel(purchaseReady(row)) }}</el-button></template></template></el-table-column>
<el-table-column label="操作" width="92" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && purchaseReady(row).eligible" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
<p class="scope-note">本页不展示采购任务、PDD 订单号、快递单号和支付状态。</p>
@@ -72,6 +75,39 @@
<template #footer><el-button type="primary" @click="reparseResult.open = false; load()">关闭</el-button></template>
</el-dialog>
<!-- 批量创建采购任务确认 -->
<el-dialog v-model="purchaseDialog.open" title="批量创建采购任务" width="900px" :close-on-click-modal="false">
<div v-loading="purchaseDialog.loading">
<el-alert title="每条 SYB 商品分别创建一个采购任务和一个待付款 PDD 订单;系统永不支付。创建前服务端会再次逐条检查。" type="warning" :closable="false" show-icon class="notice" />
<div class="purchase-summary" aria-live="polite"><span>已选择 <strong>{{ purchaseDialog.items.length }}</strong> 条</span><span class="success-text">可以创建 {{ purchaseDialog.eligibleCount }} 条</span><span v-if="purchaseDialog.skippedCount" class="danger-text">将跳过 {{ purchaseDialog.skippedCount }} 条</span></div>
<el-form label-width="110px" class="purchase-settings">
<el-form-item label="Android 设备"><el-select v-model="purchaseDialog.deviceId" clearable placeholder="不指定,由空闲设备领取" style="width:360px" @change="refreshPurchasePreview"><el-option v-for="device in purchaseDevices" :key="device.id" :label="`${device.name} · ${device.model}`" :value="device.id" /></el-select><span class="field-help">默认人工指定;留空时由符合能力的空闲设备领取。</span></el-form-item>
</el-form>
<el-table :data="purchaseDialog.items" border size="small" max-height="380" empty-text="没有可创建的商品">
<el-table-column label="SYB 商品" min-width="150"><template #default="{ row }"><strong>{{ row.orderCode || `ID ${row.sybProductId}` }}</strong><div class="muted">{{ row.shopeeItemId || '未关联蝦皮商品' }}</div></template></el-table-column>
<el-table-column label="商品" min-width="210"><template #default="{ row }"><div class="ellipsis">{{ row.productTitle || '—' }}</div><div class="muted">PDD {{ row.pddGoodsId || '未关联' }}</div></template></el-table-column>
<el-table-column label="目标规格 / 数量" min-width="170"><template #default="{ row }">{{ row.targetColor || '—' }} / {{ row.targetSize || '—' }} ×{{ row.quantity || 0 }}</template></el-table-column>
<el-table-column label="检查结果" min-width="240"><template #default="{ row }"><template v-if="row.eligible"><el-tag type="success">可以创建</el-tag><div class="muted">{{ purchasePriceText(row) }}</div></template><template v-else><el-tag type="warning">跳过</el-tag><div class="purchase-reason">{{ row.reason }}</div></template></template></el-table-column>
</el-table>
<p class="muted">如果某条商品在确认后状态发生变化,只跳过该条,不撤销其他成功任务。</p>
</div>
<template #footer><el-button :disabled="purchaseDialog.saving" @click="purchaseDialog.open = false">返回列表</el-button><el-button type="primary" :loading="purchaseDialog.saving" :disabled="purchaseDialog.loading || purchaseDialog.eligibleCount === 0" @click="submitPurchaseBatch">创建 {{ purchaseDialog.eligibleCount }} 个采购任务</el-button></template>
</el-dialog>
<!-- 批量创建采购任务结果 -->
<el-dialog v-model="purchaseResult.open" title="批量创建结果" width="900px">
<el-alert :title="`成功创建 ${purchaseResult.createdCount} 个,未创建 ${purchaseResult.failedCount} 个`" :type="purchaseResult.failedCount ? 'warning' : 'success'" :closable="false" show-icon class="notice" />
<el-table :data="purchaseResult.items" border size="small" max-height="420">
<el-table-column label="SYB 商品" min-width="150"><template #default="{ row }">{{ row.orderCode || `ID ${row.sybProductId}` }}</template></el-table-column>
<el-table-column label="目标规格 / 数量" min-width="170"><template #default="{ row }">{{ row.targetColor || '—' }} / {{ row.targetSize || '—' }} ×{{ row.quantity || 0 }}</template></el-table-column>
<el-table-column label="结果" width="120"><template #default="{ row }"><el-tag :type="row.created ? 'success' : 'warning'">{{ row.created ? '创建成功' : '未创建' }}</el-tag></template></el-table-column>
<el-table-column label="说明 / 下一步" min-width="260"><template #default="{ row }"><template v-if="row.created"><strong>{{ row.taskNo }}</strong></template><template v-else><div>{{ row.reason }}</div><el-button v-if="purchaseActionLabel(row)" type="primary" link @click="runPurchaseNextAction(row, row)">{{ purchaseActionLabel(row) }}</el-button></template></template></el-table-column>
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button v-if="row.taskId" type="primary" link @click="openPurchaseTask(row.taskId)">查看任务</el-button></template></el-table-column>
</el-table>
<p v-if="purchaseResult.failedCount" class="muted">其他成功任务不会回滚;处理失败原因后,可回到本页重新勾选。</p>
<template #footer><el-button @click="purchaseResult.open = false">返回 SYB 商品</el-button><el-button v-if="firstCreatedTaskId" type="primary" @click="openPurchaseTask(firstCreatedTaskId)">查看成功任务</el-button></template>
</el-dialog>
<!-- 详情 -->
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
<div v-loading="detail.loading" class="drawer-body">
@@ -129,6 +165,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, RefreshLeft, Download } from '@element-plus/icons-vue'
import { listSybProducts, getSybProduct, reparseSybProduct, reparseSybProductsBatch, correctSybProduct, importSybProducts, getSybSyncRun } from '@/api/goauto/syb-products'
import { listSybShops } from '@/api/goauto/syb-shops'
import { listDevices } from '@/api/goauto/devices'
import { createPurchaseTasksBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
export default {
name: 'GoAutoSybProducts',
@@ -136,6 +174,10 @@ export default {
data() {
return {
loading: false, products: [], selectedProducts: [], total: 0,
batchMode: 'purchase', purchaseReadiness: {}, purchaseReadinessLoading: false,
purchaseDevices: [],
purchaseDialog: { open: false, loading: false, saving: false, ids: [], deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 },
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
query: { page: 1, pageSize: 20, keyword: '', parseStatus: '' },
importDialog: { open: false, saving: false, range: null, spanDays: 0 },
activeRun: null, syncPollTimer: null,
@@ -147,6 +189,8 @@ export default {
},
computed: {
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
firstCreatedTaskId() { return this.purchaseResult.items.find(item => item.taskId)?.taskId || null },
formattedRaw() {
if (!this.detail.item) return ''
try { return JSON.stringify(JSON.parse(this.detail.item.rawJson), null, 2) } catch { return this.detail.item.rawJson }
@@ -160,7 +204,7 @@ export default {
this.importDialog.spanDays = Math.round((to - from) / 86400000) + 1
}
},
created() { this.load() },
created() { if (!this.canPurchase) this.batchMode = 'reparse'; this.load() },
beforeUnmount() { this.stopSyncPolling() },
methods: {
async load() {
@@ -171,6 +215,7 @@ export default {
const r = await listSybProducts(this.query)
this.products = r.data.items
this.total = r.data.total
await this.loadPurchaseReadiness()
} finally {
this.loading = false
}
@@ -178,6 +223,20 @@ export default {
search() { this.query.page = 1; this.load() },
reset() { this.query = { page: 1, pageSize: 20, keyword: '', parseStatus: '' }; this.load() },
handleSelectionChange(rows) { this.selectedProducts = rows },
changeBatchMode() { this.selectedProducts = []; this.$refs.productTable?.clearSelection() },
rowSelectable(row) { return this.batchMode !== 'purchase' || (this.canPurchase && this.purchaseReady(row).eligible) },
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
purchaseActionLabel(item) { return { open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
async loadPurchaseReadiness() {
this.purchaseReadiness = {}
if (!this.canPurchase || !this.products.length) return
this.purchaseReadinessLoading = true
try {
const r = await previewPurchaseTasks({ sybProductIds: this.products.map(item => item.id) })
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
} finally { this.purchaseReadinessLoading = false }
},
priceText(row) { if (row.unitPriceCent === null || row.unitPriceCent === undefined) return '—'; return `¥${(row.unitPriceCent / 100).toFixed(2)}` },
parseMeta(status) { return { success: { label: '成功', type: 'success' }, uncertain: { label: '存疑', type: 'warning' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } },
reparseOutcomeLabel(outcome) { return { reparsed: '已更新', skipped_manual: '已跳过(人工确认)', unchanged: '无变化' }[outcome] || outcome },
@@ -188,6 +247,49 @@ export default {
const route = this.$router.resolve({ path: '/shopee-products/index', query: { productId: shopeeProductId }})
window.open(route.href, '_blank', 'noopener')
},
openPurchaseTask(taskId) { this.$router.push({ path: '/purchase-tasks/index', query: { taskId }}) },
runPurchaseNextAction(row, readiness) {
if (readiness.nextAction === 'open_mapping' || readiness.nextAction === 'open_shopee') return this.openShopeeDetail(row.shopeeProductId)
if (readiness.nextAction === 'open_pdd') return this.openShopeeDetail(row.shopeeProductId)
if (readiness.nextAction === 'open_task' && readiness.activeTaskId) return this.openPurchaseTask(readiness.activeTaskId)
if (readiness.nextAction === 'reparse') return this.openDetail(row.sybProductId || row.id)
if (readiness.nextAction === 'select_device') { this.purchaseResult.open = false; return this.load() }
if (readiness.nextAction === 'refresh') return this.load()
},
// ---------------- 批量创建采购任务 ----------------
async openSinglePurchase(row) {
this.$refs.productTable?.clearSelection()
this.$refs.productTable?.toggleRowSelection(row, true)
await this.openPurchaseBatch()
},
async openPurchaseBatch() {
const ids = this.selectedProducts.map(item => item.id)
this.purchaseDialog = { open: true, loading: true, saving: false, ids, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 }
try {
const [preview, devices] = await Promise.all([
previewPurchaseTasks({ sybProductIds: ids }),
listDevices({ page: 1, pageSize: 100, status: 'online' })
])
this.applyPurchasePreview(preview.data)
const required = ['purchase.live.v1', 'purchase.address-update.v1', 'purchase.order-create.v1', 'purchase.spec-probe.v1']
this.purchaseDevices = devices.data.items.filter(device => device.selectable && required.every(capability => (device.capabilities || []).includes(capability)))
} finally { this.purchaseDialog.loading = false }
},
applyPurchasePreview(data) { this.purchaseDialog.items = data.items; this.purchaseDialog.eligibleCount = data.eligibleCount; this.purchaseDialog.skippedCount = data.skippedCount },
async refreshPurchasePreview() {
this.purchaseDialog.loading = true
try { const r = await previewPurchaseTasks({ sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined }); this.applyPurchasePreview(r.data) } finally { this.purchaseDialog.loading = false }
},
async submitPurchaseBatch() {
this.purchaseDialog.saving = true
try {
const r = await createPurchaseTasksBatch({ requestId: crypto.randomUUID(), sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined })
this.purchaseResult = { open: true, items: r.data.items, createdCount: r.data.createdCount, failedCount: r.data.failedCount }
this.purchaseDialog.open = false
await this.load()
} finally { this.purchaseDialog.saving = false }
},
// ---------------- 从 SYB 导入 ----------------
disableFutureDate(date) { return date.getTime() > Date.now() },
@@ -308,7 +410,9 @@ export default {
.link{color:#1677ff;cursor:pointer}
.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}
.notice{margin-bottom:16px}
.compact-notice{margin-bottom:12px}
.dialog-form{margin-bottom:8px}
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.purchase-note{margin:0 0 16px}
.drawer-body{padding:0 4px 24px}
.split{display:grid;grid-template-columns:1fr 1fr;gap:24px}