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 @@ 从 SYB 导入 同步记录 已选择 {{ selectedProducts.length }} 条 + 创建采购重新解析 - 查询重置重新解析 + 查询重置重新解析创建采购任务
@@ -19,8 +20,9 @@
查看详情
+ - + @@ -30,7 +32,8 @@ - + +

本页不展示采购任务、PDD 订单号、快递单号和支付状态。

@@ -72,6 +75,39 @@ + + +
+ +
已选择 {{ purchaseDialog.items.length }} 条可以创建 {{ purchaseDialog.eligibleCount }} 条将跳过 {{ purchaseDialog.skippedCount }} 条
+ + 默认人工指定;留空时由符合能力的空闲设备领取。 + + + + + + + +

如果某条商品在确认后状态发生变化,只跳过该条,不撤销其他成功任务。

+
+ +
+ + + + + + + + + + + +

其他成功任务不会回滚;处理失败原因后,可回到本页重新勾选。

+ +
+
@@ -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}