From 7e4f4042653d63abc7a19b56c9c3c9bc0bfde2f7 Mon Sep 17 00:00:00 2001
From: QiuSW <105186638@qq.com>
Date: Fri, 21 Aug 2026 10:15:19 +0800
Subject: [PATCH] feat(#44): create purchase tasks from SYB products
---
docs/02-architecture-and-code-map.md | 8 +-
docs/03-business-rules-and-glossary.md | 13 +-
docs/08-agent-api-contract.md | 14 +-
.../app/goauto/purchase/admin_query_test.go | 13 +
server/app/goauto/purchase/batch.go | 318 ++++++++++++++++++
server/app/goauto/purchase/batch_test.go | 114 +++++++
server/app/goauto/purchase/handler.go | 40 +++
server/app/goauto/purchase/manual.go | 3 +
server/app/goauto/purchase/router.go | 2 +
server/app/goauto/purchase/service.go | 11 +-
server/app/goauto/purchase/service_test.go | 31 ++
server/app/goauto/purchasecontract/default.go | 11 +
.../goauto/purchasecontract/default_test.go | 20 ++
web/src/api/goauto/purchase-tasks.js | 2 +
web/src/views/goauto/purchase-tasks/index.vue | 12 +-
web/src/views/goauto/syb-products/index.vue | 112 +++++-
16 files changed, 702 insertions(+), 22 deletions(-)
create mode 100644 server/app/goauto/purchase/batch.go
create mode 100644 server/app/goauto/purchase/batch_test.go
create mode 100644 server/app/goauto/purchasecontract/default.go
create mode 100644 server/app/goauto/purchasecontract/default_test.go
diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md
index a889dbe..6ec75d0 100644
--- a/docs/02-architecture-and-code-map.md
+++ b/docs/02-architecture-and-code-map.md
@@ -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
# 架构与代码地图
@@ -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` |
diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index ea20bbb..1dbdc07 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -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
# 业务规则与术语
@@ -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 商品列表发起。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。
## 自动化边界
diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md
index 3b41f67..63b1f32 100644
--- a/docs/08-agent-api-contract.md
+++ b/docs/08-agent-api-contract.md
@@ -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
# 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 实现)
diff --git a/server/app/goauto/purchase/admin_query_test.go b/server/app/goauto/purchase/admin_query_test.go
index 81402f7..fc18539 100644
--- a/server/app/goauto/purchase/admin_query_test.go
+++ b/server/app/goauto/purchase/admin_query_test.go
@@ -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()
diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go
new file mode 100644
index 0000000..856fb2d
--- /dev/null
+++ b/server/app/goauto/purchase/batch.go
@@ -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"
+}
diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go
new file mode 100644
index 0000000..3a56135
--- /dev/null
+++ b/server/app/goauto/purchase/batch_test.go
@@ -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)
+ }
+}
diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go
index ee9106e..cbe5830 100644
--- a/server/app/goauto/purchase/handler.go
+++ b/server/app/goauto/purchase/handler.go
@@ -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 {
diff --git a/server/app/goauto/purchase/manual.go b/server/app/goauto/purchase/manual.go
index fd93083..01580db 100644
--- a/server/app/goauto/purchase/manual.go
+++ b/server/app/goauto/purchase/manual.go
@@ -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, "重新采购授权已经记录")
}
diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go
index 3c90da2..81aa60e 100644
--- a/server/app/goauto/purchase/router.go
+++ b/server/app/goauto/purchase/router.go
@@ -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)
diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go
index 69834b8..662d824 100644
--- a/server/app/goauto/purchase/service.go
+++ b/server/app/goauto/purchase/service.go
@@ -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
}
diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go
index 9b28ddf..da32c2d 100644
--- a/server/app/goauto/purchase/service_test.go
+++ b/server/app/goauto/purchase/service_test.go
@@ -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 }
diff --git a/server/app/goauto/purchasecontract/default.go b/server/app/goauto/purchasecontract/default.go
new file mode 100644
index 0000000..8fe64a3
--- /dev/null
+++ b/server/app/goauto/purchasecontract/default.go
@@ -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"}]}`)
+}
diff --git a/server/app/goauto/purchasecontract/default_test.go b/server/app/goauto/purchasecontract/default_test.go
new file mode 100644
index 0000000..50df237
--- /dev/null
+++ b/server/app/goauto/purchasecontract/default_test.go
@@ -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)
+ }
+}
diff --git a/web/src/api/goauto/purchase-tasks.js b/web/src/api/goauto/purchase-tasks.js
index 4328881..767bc3e 100644
--- a/web/src/api/goauto/purchase-tasks.js
+++ b/web/src/api/goauto/purchase-tasks.js
@@ -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 }) }
diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue
index ef9badd..3698696 100644
--- a/web/src/views/goauto/purchase-tasks/index.vue
+++ b/web/src/views/goauto/purchase-tasks/index.vue
@@ -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 = ''
diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue
index ba0488b..f503c1a 100644
--- a/web/src/views/goauto/syb-products/index.vue
+++ b/web/src/views/goauto/syb-products/index.vue
@@ -6,9 +6,10 @@
本页不展示采购任务、PDD 订单号、快递单号和支付状态。
@@ -72,6 +75,39 @@如果某条商品在确认后状态发生变化,只跳过该条,不撤销其他成功任务。
+其他成功任务不会回滚;处理失败原因后,可回到本页重新勾选。
+