diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 3d59669..fcfa6b6 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: b54f4cce004cb6eb2398b40f89ad88888b773a3a -synchronized_at: 2026-08-28T09:50:04Z +wiki_revision: 5ccc58883979c6593f9d16d7865d5b44716cb374 +synchronized_at: 2026-08-28T12:43:45Z # 架构与代码地图 @@ -127,6 +127,7 @@ Android Portal/Agent | 任务领取、结果、重置与删除 | `server/app/goauto/task/` | | 管理端基线 | `web/`(go-admin-ui v3.0.0) | | 管理端闭环页面 | `web/src/views/goauto/` | +| 备货采购服务端路径 | `purchase_task.task_type` 与迁移 `1787885300000_stock_purchase.go`;`POST /api/admin/v1/purchase-tasks/stock` 由 `server/app/goauto/purchase/service.go` 校验 PDD 当前可选规格并固化 `direct_select`,复用既有任务状态机和设备/账号互斥;重试、替换和 SYB 回填显式排除 `stock` | | Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 | | Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key),采购员只可查看启用状态 | | Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent | diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 96b4b45..2a0f1fa 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: 6e33660c976c4412c3c456d04d5b4784a3043732 -synchronized_at: 2026-08-28T10:27:03Z +wiki_revision: b78825c5e140a3e24948107628d6ced0db6f6a37 +synchronized_at: 2026-08-28T12:43:51Z # 业务规则与术语 @@ -123,6 +123,8 @@ synchronized_at: 2026-08-28T10:27:03Z ## 采购任务契约 - 采购域使用独立的 `purchase_task`、`purchase_task_attempt` 和可选 `pdd_account` 引用;PDD、虾皮和 SYB 商品表不保存采购订单、支付、物流或回填字段。 +- `purchase_task.task_type` 区分 `syb_order` 与 `stock`。旧任务迁移为 `syb_order`;备货任务只引用已完成采集且处于 `active` 的 PDD 商品,颜色/尺码必须逐字命中当前可选规格,规格来源固定为 `direct_select`,SYB/虾皮身份快照为空。 +- 备货任务不占用 SYB 活动槽,同一 PDD 商品可存在多个待执行备货任务,但设备、可选账号和执行期互斥仍沿用采购安全边界。备货任务不参与商品替换、批量重试、重新采购授权或 SYB 回填;失败后由人员重新创建备货任务。 - 正式任务必须引用一条 SYB 商品明细;演练任务可以从 PDD 商品人工创建且不引用 SYB。 - 创建时固化三个商品身份、蝦皮订单号、目标和映射规格、数量、价格区间、币种、URL、`goods_id`、规则、设备和可选账号引用。商品档案后续修改不改变任务解释。正式任务的蝦皮订单号来自 SYB 商品 `order_code`,同一订单号可以对应多条商品和多个采购任务,不作为唯一键。 - Android 无法可靠识别登录中的 PDD 账号,因此账号引用可空;已知账号才参与账号级串行,未知账号不会阻止采购任务。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 6a36810..26947b0 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: 81d3fec214250f9b5be4b049e65ca6a1144e52ad -synchronized_at: 2026-08-28T10:27:46Z +wiki_revision: c7dd62e1d67e725a83bb343d1a25f18a49b68c8e +synchronized_at: 2026-08-28T12:44:25Z # MVP 共享 API 契约 @@ -418,6 +418,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `failed` / `cancelled` | 终态 | 否 | 同一个 `sybProductId` 最多一个占用活动槽的任务;一个设备最多一个执行中的采购任务;已知的同一个 PDD 账号最多一个执行中的采购任务。终态历史不删除。 +`purchase_task.task_type` 固定为 `syb_order` 或 `stock`,旧记录迁移为 `syb_order`。`stock` 的 `sybProductId`、`shopeeProductId` 及对应身份快照为空,颜色和尺码必须逐字命中当前 `active` PDD 档案中的可选值,`specSource=direct_select`;同商品的多个活动备货任务可以共存,但执行期仍受设备和可选账号互斥约束。`stock` 不进入 SYB 批量预检/批量重试、Agent 重试、商品替换、重新采购授权或 SYB 回填;失败后返回“重新创建备货采购”的可读提示。 ### 规则快照与能力 @@ -485,6 +486,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `GET` | `/api/admin/v1/purchase-tasks` | 分页列表;可按任务 ID、蝦皮订单号 `shopeeOrderNo`、状态、模式、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/stock` | 创建备货采购;`requestId`、`executionMode`、`pddProductId`、可选 `deviceId` / `pddAccountId`、`color`、可选 `size`、`quantity`、`minUnitPriceCent`、`maxUnitPriceCent`。服务端使用内置规则并从所选颜色归档派生参考价;相同 `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` | 一次性授权;创建新任务后自动消耗 | diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index 24f44ea..5e9a519 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -85,6 +85,7 @@ var AdminAPIs = []APIPermission{ {"预检批量采购", "/api/admin/v1/purchase-tasks/batch-preview", "POST", true}, {"批量创建采购任务", "/api/admin/v1/purchase-tasks/batch", "POST", true}, {"批量重试采购任务", "/api/admin/v1/purchase-tasks/batch-retry", "POST", true}, + {"创建备货采购任务", "/api/admin/v1/purchase-tasks/stock", "POST", true}, {"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true}, {"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true}, {"处理采购规格", "/api/admin/v1/purchase-tasks/:taskId/spec-decision", "POST", true}, diff --git a/server/app/goauto/access/purchaser_test.go b/server/app/goauto/access/purchaser_test.go index 0b96088..a7bd8a6 100644 --- a/server/app/goauto/access/purchaser_test.go +++ b/server/app/goauto/access/purchaser_test.go @@ -44,3 +44,12 @@ func TestPurchaserMayOnlyReadReplacementAudit(t *testing.T) { } } } + +func TestPurchaserMayCreateStockPurchase(t *testing.T) { + for _, permission := range PurchaserAPIs() { + if permission.Method == "POST" && permission.Path == "/api/admin/v1/purchase-tasks/stock" { + return + } + } + t.Fatal("missing purchaser stock purchase permission") +} diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 3079e50..a031607 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -48,5 +48,40 @@ func MigratedModels() []any { } func Migrate(db *gorm.DB) error { - return db.AutoMigrate(MigratedModels()...) + if err := db.AutoMigrate(MigratedModels()...); err != nil { + return err + } + // #135: rows created before task_type existed are SYB order tasks. Keep the + // backfill explicit so databases created by older builds converge even when + // their SQL dialect does not materialize a new column default immediately. + if err := db.Table((models.PurchaseTask{}).TableName()). + Where("task_type IS NULL OR task_type = ?", ""). + Update("task_type", models.PurchaseTaskTypeSYBOrder).Error; err != nil { + return err + } + return ensureMySQLDirectSelectConstraint(db) +} + +func ensureMySQLDirectSelectConstraint(db *gorm.DB) error { + if db.Dialector.Name() != "mysql" { + return nil + } + const name = "ck_purchase_task_spec_source" + var total, current int64 + base := `SELECT COUNT(*) FROM information_schema.check_constraints WHERE constraint_schema = DATABASE() AND table_name = 'purchase_task' AND constraint_name = ?` + if err := db.Raw(base, name).Scan(&total).Error; err != nil { + return err + } + if err := db.Raw(base+` AND check_clause LIKE '%direct_select%'`, name).Scan(¤t).Error; err != nil { + return err + } + if current > 0 { + return nil + } + if total > 0 { + if err := db.Exec("ALTER TABLE purchase_task DROP CHECK " + name).Error; err != nil { + return err + } + } + return db.Exec("ALTER TABLE purchase_task ADD CONSTRAINT " + name + " CHECK (spec_source IN ('unresolved','manual_mapping','exact_match','ai_match','direct_select'))").Error } diff --git a/server/app/goauto/migrations/purchase_contract_test.go b/server/app/goauto/migrations/purchase_contract_test.go index 7635468..547ed80 100644 --- a/server/app/goauto/migrations/purchase_contract_test.go +++ b/server/app/goauto/migrations/purchase_contract_test.go @@ -98,6 +98,31 @@ func TestLivePurchaseRequiresSYBButRehearsalMayOmitIt(t *testing.T) { } } +func TestPurchaseTaskTypeDefaultsToSYBAndStockRejectsCrossDomainIDs(t *testing.T) { + db := openDatabase(t) + fixtures := seedPurchaseFixtures(t) + legacy := newPurchaseTask(fixtures, "00000000-0000-0000-0000-000000000003", models.PurchaseTaskStatusFailed) + if err := db.Create(&legacy).Error; err != nil { + t.Fatal(err) + } + if legacy.TaskType != models.PurchaseTaskTypeSYBOrder { + t.Fatalf("default task type = %q", legacy.TaskType) + } + stock := newPurchaseTask(fixtures, "00000000-0000-0000-0000-000000000004", models.PurchaseTaskStatusPending) + stock.TaskType = models.PurchaseTaskTypeStock + if err := db.Create(&stock).Error; err == nil || !strings.Contains(err.Error(), "cannot reference syb or shopee") { + t.Fatalf("stock task accepted cross-domain IDs: %v", err) + } + stock.SYBProductID, stock.ShopeeProductID = nil, nil + stock.SpecSource = "direct_select" + if err := db.Create(&stock).Error; err != nil { + t.Fatalf("valid stock task rejected: %v", err) + } + if stock.ActiveSlot != nil { + t.Fatal("stock task occupied SYB active slot") + } +} + func TestOneActivePurchaseTaskPerSYBAndHistoryIsPreserved(t *testing.T) { db := openDatabase(t) fixtures := seedPurchaseFixtures(t) diff --git a/server/app/goauto/models/purchase.go b/server/app/goauto/models/purchase.go index 2d154e6..b329f62 100644 --- a/server/app/goauto/models/purchase.go +++ b/server/app/goauto/models/purchase.go @@ -8,6 +8,9 @@ import ( ) const ( + PurchaseTaskTypeSYBOrder = "syb_order" + PurchaseTaskTypeStock = "stock" + PurchaseExecutionModeRehearsal = "rehearsal" PurchaseExecutionModeLive = "live" @@ -58,12 +61,12 @@ type PDDAccount struct { func (PDDAccount) TableName() string { return "pdd_account" } -// PurchaseTask is the server-side business fact for one SYB product line and -// one PDD order at most. Re-purchase creates another row; old rows and orders -// are never overwritten or deleted. Product-domain records remain independent: -// this table consumes them through foreign keys plus immutable snapshots. +// PurchaseTask is the server-side business fact for either one SYB order line +// or one direct stock purchase, and at most one PDD order. Re-purchase creates +// another row; old rows and orders are never overwritten or deleted. type PurchaseTask struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + TaskType string `json:"taskType" gorm:"size:16;not null;default:syb_order;index;check:ck_purchase_task_type,task_type IN ('syb_order','stock')"` // MySQL 8.4 rejects a column used by both a foreign key with referential // actions and a cross-field CHECK. The live-mode requirement is therefore @@ -99,7 +102,7 @@ type PurchaseTask struct { TargetSizeSnapshot string `json:"targetSizeSnapshot" gorm:"size:255;not null;default:''"` MappedColorSnapshot string `json:"mappedColorSnapshot" gorm:"size:255;not null;default:''"` MappedSizeSnapshot string `json:"mappedSizeSnapshot" gorm:"size:255;not null;default:''"` - SpecSource string `json:"specSource" gorm:"size:32;not null;default:unresolved;check:ck_purchase_task_spec_source,spec_source IN ('unresolved','manual_mapping','exact_match','ai_match')"` + SpecSource string `json:"specSource" gorm:"size:32;not null;default:unresolved;check:ck_purchase_task_spec_source,spec_source IN ('unresolved','manual_mapping','exact_match','ai_match','direct_select')"` SpecDecisionSnapshot string `json:"-" gorm:"type:json;not null"` SpecDecisionRequestID *string `json:"-" gorm:"size:64;uniqueIndex:ux_purchase_task_spec_decision_request_id"` SpecDecisionBy *uint64 `json:"specDecisionBy"` @@ -177,6 +180,12 @@ func (task *PurchaseTask) SetStatus(status string) error { } func (task *PurchaseTask) syncPurchaseGuardSlots() error { + if task.TaskType == "" { + task.TaskType = PurchaseTaskTypeSYBOrder + } + if task.TaskType != PurchaseTaskTypeSYBOrder && task.TaskType != PurchaseTaskTypeStock { + return fmt.Errorf("unsupported purchase task type %q", task.TaskType) + } one := uint8(1) active := false running := false @@ -201,13 +210,16 @@ func (task *PurchaseTask) syncPurchaseGuardSlots() error { if task.ExecutionMode != PurchaseExecutionModeRehearsal && task.ExecutionMode != PurchaseExecutionModeLive { return fmt.Errorf("unsupported purchase execution mode %q", task.ExecutionMode) } - if task.ExecutionMode == PurchaseExecutionModeLive && task.SYBProductID == nil { + if task.TaskType == PurchaseTaskTypeSYBOrder && task.ExecutionMode == PurchaseExecutionModeLive && task.SYBProductID == nil { return fmt.Errorf("live purchase task requires syb_product_id") } + if task.TaskType == PurchaseTaskTypeStock && (task.SYBProductID != nil || task.ShopeeProductID != nil) { + return fmt.Errorf("stock purchase task cannot reference syb or shopee product") + } if running && task.DeviceID == nil { return fmt.Errorf("running purchase task requires device_id") } - if active && task.SYBProductID != nil { + if active && task.TaskType == PurchaseTaskTypeSYBOrder && task.SYBProductID != nil { task.ActiveSlot = &one } else { task.ActiveSlot = nil diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index 119d513..a5a29f9 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -17,6 +17,7 @@ type AdminListRequest struct { TaskID uint64 Status string ExecutionMode string + TaskType string SYBProductID uint64 ShopeeOrderNo string PDDOrderNo string @@ -24,6 +25,7 @@ type AdminListRequest struct { type AdminTaskItem struct { ID uint64 `json:"id"` + TaskType string `json:"taskType"` ExecutionMode string `json:"executionMode"` Status string `json:"status"` SYBProductID *uint64 `json:"sybProductId,omitempty"` @@ -117,6 +119,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis if req.ExecutionMode != "" && req.ExecutionMode != models.PurchaseExecutionModeRehearsal && req.ExecutionMode != models.PurchaseExecutionModeLive { return AdminListResponse{}, fail(CodeInvalidRequest, "executionMode 无效") } + if req.TaskType != "" && req.TaskType != models.PurchaseTaskTypeSYBOrder && req.TaskType != models.PurchaseTaskTypeStock { + return AdminListResponse{}, fail(CodeInvalidRequest, "taskType 无效") + } query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{}) if req.TaskID > 0 { query = query.Where("id = ?", req.TaskID) @@ -127,6 +132,9 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis if req.ExecutionMode != "" { query = query.Where("execution_mode = ?", req.ExecutionMode) } + if req.TaskType != "" { + query = query.Where("task_type = ?", req.TaskType) + } if req.SYBProductID > 0 { query = query.Where("syb_product_id = ?", req.SYBProductID) } @@ -208,7 +216,7 @@ func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]strin func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retry retryDecision) AdminTaskItem { item := AdminTaskItem{ - ID: task.ID, ExecutionMode: task.ExecutionMode, Status: task.Status, + ID: task.ID, TaskType: task.TaskType, ExecutionMode: task.ExecutionMode, Status: task.Status, SYBProductID: task.SYBProductID, ShopeeProductID: task.ShopeeProductID, PDDProductID: task.PDDProductID, DeviceID: task.DeviceID, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot, ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot, diff --git a/server/app/goauto/purchase/agent_history.go b/server/app/goauto/purchase/agent_history.go index 77e8a38..a2de6f1 100644 --- a/server/app/goauto/purchase/agent_history.go +++ b/server/app/goauto/purchase/agent_history.go @@ -26,6 +26,7 @@ type AgentHistoryRequest struct { type AgentPurchaseItem struct { TaskID uint64 `json:"taskId"` + TaskType string `json:"taskType"` Status string `json:"status"` ShopeeOrderNo string `json:"shopeeOrderNo"` PDDGoodsID string `json:"pddGoodsId"` @@ -151,7 +152,7 @@ func agentPurchaseItem(task models.PurchaseTask, retry retryDecision) AgentPurch retryDisabledReason = retry.Reason } return AgentPurchaseItem{ - TaskID: task.ID, Status: task.Status, ShopeeOrderNo: task.ShopeeOrderNoSnapshot, + TaskID: task.ID, TaskType: task.TaskType, Status: task.Status, ShopeeOrderNo: task.ShopeeOrderNoSnapshot, PDDGoodsID: task.PDDGoodsIDSnapshot, PDDTitle: task.PDDTitleSnapshot, MappedColor: task.MappedColorSnapshot, MappedSize: task.MappedSizeSnapshot, Quantity: task.Quantity, ActualUnitPriceCent: task.ActualUnitPriceCent, Currency: task.Currency, diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 2add9a2..8164f5a 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -51,6 +51,7 @@ func (h Handler) AdminList(c *gin.Context) { response, err := service.AdminList(c.Request.Context(), AdminListRequest{ Page: page, PageSize: pageSize, TaskID: taskID, SYBProductID: sybProductID, Status: strings.TrimSpace(c.Query("status")), ExecutionMode: strings.TrimSpace(c.Query("executionMode")), + TaskType: strings.TrimSpace(c.Query("taskType")), ShopeeOrderNo: strings.TrimSpace(c.Query("shopeeOrderNo")), PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")), }) @@ -61,6 +62,26 @@ func (h Handler) AdminList(c *gin.Context) { writeAdminData(c, response) } +func (h Handler) AdminCreateStock(c *gin.Context) { + if !allowedOperator(c) { + return + } + var req StockCreateRequest + if !decode(c, &req) { + return + } + service, ok := h.service(c) + if !ok { + return + } + record, replayed, err := service.CreateStock(c.Request.Context(), req) + if err != nil { + writeError(c, err) + return + } + writeAdminReplay(c, record, replayed) +} + func (h Handler) AdminDetail(c *gin.Context) { if !allowedOperator(c) { return diff --git a/server/app/goauto/purchase/manual.go b/server/app/goauto/purchase/manual.go index 5256aab..cd6b649 100644 --- a/server/app/goauto/purchase/manual.go +++ b/server/app/goauto/purchase/manual.go @@ -12,6 +12,9 @@ import ( func (s *Service) AuthorizeRePurchase(ctx context.Context, id uint64, req ManualRequest) (models.PurchaseTask, bool, error) { return s.manual(ctx, id, req, func(t *models.PurchaseTask) error { + if t.TaskType == models.PurchaseTaskTypeStock { + return fail(CodeStateConflict, "备货采购不支持重新采购授权,请重新创建任务") + } if t.Status != models.PurchaseTaskStatusOrderCreated && t.Status != models.PurchaseTaskStatusCancelled { return fail(CodeStateConflict, "当前任务不能授权重新采购") } @@ -64,6 +67,9 @@ func (s *Service) SelectWriteback(ctx context.Context, id uint64, req ManualRequ replayed = true return nil } + if out.TaskType == models.PurchaseTaskTypeStock { + return fail(CodeStateConflict, "备货采购不参与顺云宝回填") + } if out.Status != models.PurchaseTaskStatusOrderCreated || out.PaymentReviewStatus != models.PurchasePaymentReviewPaid { return fail(CodeStateConflict, "只有已支付订单可以加入回填候选") } diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go index 89dd801..1cd0d1b 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -285,6 +285,9 @@ func (s *Service) retryStateEligibility(ctx context.Context, task models.Purchas deny := func(code, message string) retryDecision { return retryDecision{ReasonCode: code, Reason: message} } + if task.TaskType == models.PurchaseTaskTypeStock { + return deny(CodeRetryNotAllowed, "备货采购失败后请重新创建备货采购任务") + } if task.Status != models.PurchaseTaskStatusFailed { return deny(CodeRetryNotAllowed, "只有采购失败任务可以重试") } diff --git a/server/app/goauto/purchase/retry_test.go b/server/app/goauto/purchase/retry_test.go index 19593dc..4ed1bea 100644 --- a/server/app/goauto/purchase/retry_test.go +++ b/server/app/goauto/purchase/retry_test.go @@ -84,6 +84,34 @@ func TestBatchRetryCreatesNewTaskPreservesFailureAndReplays(t *testing.T) { } } +func TestStockTaskIsExcludedFromBatchRetry(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + task, _, err := service.CreateStock(context.Background(), StockCreateRequest{ + RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, + PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL", + Quantity: 1, MinUnitPriceCent: 100, MaxUnitPriceCent: 3000, + }) + if err != nil { + t.Fatal(err) + } + if err = task.SetStatus(models.PurchaseTaskStatusFailed); err != nil { + t.Fatal(err) + } + if err = db.Save(&task).Error; err != nil { + t.Fatal(err) + } + result, err := service.BatchRetry(context.Background(), BatchRetryRequest{RequestID: uuid.NewString(), TaskIDs: []uint64{task.ID}}) + if err != nil { + t.Fatal(err) + } + if result.CreatedCount != 0 || result.FailedCount != 1 || result.Items[0].ReasonCode != CodeRetryNotAllowed || !strings.Contains(result.Items[0].Reason, "重新创建备货采购") { + t.Fatalf("stock retry was not rejected clearly: %+v", result) + } +} + func TestBatchRetryKeepsPartialSuccessAndRejectsUnsafeTask(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true) diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index 8163eb9..592bf10 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -29,6 +29,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin.POST("/batch-preview", h.AdminBatchPreview) admin.POST("/batch", h.AdminBatchCreate) admin.POST("/batch-retry", h.AdminBatchRetry) + admin.POST("/stock", h.AdminCreateStock) 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 b6f5108..46e118d 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -44,6 +44,13 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas if _, err := uuid.Parse(req.RequestID); err != nil { return models.PurchaseTask{}, false, fail(CodeInvalidRequest, "requestId 无效") } + taskType := strings.TrimSpace(req.TaskType) + if taskType == "" { + taskType = models.PurchaseTaskTypeSYBOrder + } + if taskType != models.PurchaseTaskTypeSYBOrder && taskType != models.PurchaseTaskTypeStock { + return models.PurchaseTask{}, false, fail(CodeInvalidRequest, "taskType 无效") + } if len(req.RuleSnapshot) == 0 { return models.PurchaseTask{}, false, fail(CodeInvalidRequest, "请选择采购规则") } @@ -63,7 +70,23 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas var pdd models.PDDProduct var syb models.SYBProduct var shopee models.ShopeeProduct - if req.ExecutionMode == models.PurchaseExecutionModeLive { + if taskType == models.PurchaseTaskTypeStock { + if req.SYBProductID != nil { + return fail(CodeInvalidRequest, "备货采购不能关联顺云宝商品") + } + if req.PDDProductID == nil { + return fail(CodeInvalidRequest, "备货采购必须选择拼多多商品") + } + if req.ExecutionMode != models.PurchaseExecutionModeLive && req.ExecutionMode != models.PurchaseExecutionModeRehearsal { + return fail(CodeInvalidRequest, "executionMode 只支持 rehearsal 或 live") + } + if err := tx.First(&pdd, *req.PDDProductID).Error; err != nil { + return notFound(err, "拼多多商品不存在") + } + if pdd.Status != "active" { + return fail(CodeInvalidRequest, "拼多多商品尚未采集完成或已停用") + } + } else if req.ExecutionMode == models.PurchaseExecutionModeLive { if req.SYBProductID == nil { return fail(CodeInvalidRequest, "正式采购必须选择顺云宝商品") } @@ -140,7 +163,21 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas targetColor, targetSize, mappedColor, mappedSize, specSource := strings.TrimSpace(req.TargetColor), strings.TrimSpace(req.TargetSize), "", "", "unresolved" decisionSnapshot := "{}" var sybID, shopeeID *uint64 - if req.ExecutionMode == models.PurchaseExecutionModeLive { + if taskType == models.PurchaseTaskTypeStock { + targetColor, targetSize = strings.TrimSpace(req.TargetColor), strings.TrimSpace(req.TargetSize) + mappedColor, mappedSize, ref, err = directStockSelection(pdd.SpecsJSON, targetColor, targetSize) + if err != nil { + return err + } + targetColor, targetSize, specSource, currency = mappedColor, mappedSize, "direct_select", "CNY" + decision, marshalErr := json.Marshal(map[string]any{ + "source": "direct_select", "mappedColor": mappedColor, "mappedSize": mappedSize, + }) + if marshalErr != nil { + return internal(marshalErr) + } + decisionSnapshot = string(decision) + } else if req.ExecutionMode == models.PurchaseExecutionModeLive { sybID, shopeeID = &syb.ID, &shopee.ID quantity = syb.Quantity if ref == 0 { @@ -162,7 +199,10 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas } candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize) matchRequest := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes} - if pdd.Status != "active" { + if taskType == models.PurchaseTaskTypeStock { + // Direct stock selection was already validated above. It never enters + // mapping, deterministic matching, or the external AI fallback. + } else if pdd.Status != "active" { mappedColor, mappedSize, specSource = "", "", "unresolved" } else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" { if !mappingTargetsValid(candidates, targetColor, targetSize, mappedColor, mappedSize) { @@ -198,7 +238,7 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas if req.shopeeOrderNoSnapshotOverride != "" { shopeeOrderNoSnapshot = req.shopeeOrderNoSnapshotOverride } - out = models.PurchaseTask{SYBProductID: sybID, ShopeeProductID: shopeeID, PDDProductID: pdd.ID, DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID, + out = models.PurchaseTask{TaskType: taskType, SYBProductID: sybID, ShopeeProductID: shopeeID, PDDProductID: pdd.ID, DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID, ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeOrderNoSnapshot: shopeeOrderNoSnapshot, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName, PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, SpecDecisionSnapshot: decisionSnapshot, Quantity: quantity, ReferenceUnitPriceCent: ref, MinUnitPriceCent: minPrice, MaxUnitPriceCent: maxPrice, Currency: currency, RuleType: rule.RuleType, RuleSchemaVersion: rule.SchemaVersion, RequiredCapabilitiesJSON: string(required), RuleSnapshot: string(req.RuleSnapshot), CreateRequestID: req.RequestID, @@ -218,11 +258,68 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas return out, replayed, err } +func (s *Service) CreateStock(ctx context.Context, req StockCreateRequest) (models.PurchaseTask, bool, error) { + rule := purchasecontract.DefaultLiveRule() + if req.ExecutionMode == models.PurchaseExecutionModeRehearsal { + rule = purchasecontract.DefaultRehearsalRule() + } + pddID := req.PDDProductID + return s.Create(ctx, CreateRequest{ + RequestID: req.RequestID, TaskType: models.PurchaseTaskTypeStock, + ExecutionMode: req.ExecutionMode, PDDProductID: &pddID, + DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID, + TargetColor: req.Color, TargetSize: req.Size, Quantity: req.Quantity, + MinUnitPriceCent: req.MinUnitPriceCent, MaxUnitPriceCent: req.MaxUnitPriceCent, + Currency: "CNY", RuleSnapshot: rule, + }) +} + type specCandidates struct { Colors []string Sizes []string } +func directStockSelection(raw, color, size string) (mappedColor, mappedSize string, reference int64, err error) { + var dimensions []product.SpecDimension + if json.Unmarshal([]byte(raw), &dimensions) != nil { + return "", "", 0, fail(CodeInvalidRequest, "拼多多商品规格数据无效,请先重新采集") + } + color, size = strings.TrimSpace(color), strings.TrimSpace(size) + colorDimension, sizeDimension, priceFound := false, false, false + for _, dimension := range dimensions { + switch dimension.Role { + case "color": + colorDimension = true + for _, value := range dimension.Values { + if value.Selectable && strings.TrimSpace(value.Name) == color { + mappedColor = color + if value.PriceCent != nil && *value.PriceCent >= 0 { + reference = *value.PriceCent + priceFound = true + } + } + } + case "size": + sizeDimension = true + for _, value := range dimension.Values { + if value.Selectable && strings.TrimSpace(value.Name) == size { + mappedSize = size + } + } + } + } + if colorDimension && mappedColor == "" { + return "", "", 0, fail(CodeMappingRequired, "所选颜色不在当前可采购规格中") + } + if sizeDimension && mappedSize == "" { + return "", "", 0, fail(CodeMappingRequired, "所选尺码不在当前可采购规格中") + } + if !colorDimension || mappedColor == "" || !priceFound { + return "", "", 0, fail(CodeInvalidRequest, "所选颜色没有可用价格,请先重新采集") + } + return mappedColor, mappedSize, reference, nil +} + // archiveCandidates only returns selectable PDD values and only considers the // target roles. Incomplete or malformed archives deliberately fall back to a // first spec-probe instead of guessing a nearby option. diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 48b48fe..305a556 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -117,6 +117,46 @@ func TestRehearsalCannotContainOrderActions(t *testing.T) { } } +func TestCreateStockUsesExactCollectedSpecsAndAllowsMultipleActiveTasks(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200},{"name":"白色","selectable":false,"priceCent":1300}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } + service := testService(db) + req := StockCreateRequest{ + RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, + PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL", + Quantity: 3, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500, + } + first, replayed, err := service.CreateStock(context.Background(), req) + if err != nil || replayed { + t.Fatalf("create stock task: %+v replayed=%v err=%v", first, replayed, err) + } + if first.TaskType != models.PurchaseTaskTypeStock || first.SYBProductID != nil || first.ShopeeProductID != nil || first.SpecSource != "direct_select" { + t.Fatalf("stock identity/spec boundary not frozen: %+v", first) + } + if first.MappedColorSnapshot != "黑色" || first.MappedSizeSnapshot != "XL" || first.ReferenceUnitPriceCent != 1200 { + t.Fatalf("unexpected direct selection snapshots: %+v", first) + } + if first.ActiveSlot != nil { + t.Fatalf("stock task must not occupy SYB active slot: %+v", first.ActiveSlot) + } + replay, replayed, err := service.CreateStock(context.Background(), req) + if err != nil || !replayed || replay.ID != first.ID { + t.Fatalf("stock replay mismatch: id=%d replayed=%v err=%v", replay.ID, replayed, err) + } + req.RequestID = uuid.NewString() + second, _, err := service.CreateStock(context.Background(), req) + if err != nil || second.ID == first.ID { + t.Fatalf("second active stock task should coexist: %+v err=%v", second, err) + } + req.RequestID, req.Color = uuid.NewString(), "白色" + if _, _, err = service.CreateStock(context.Background(), req); code(err) != CodeMappingRequired { + t.Fatalf("unselectable stock color accepted: %v", err) + } +} + func TestCreateLiveSnapshotsShopeeOrderNumber(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true) diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 1ee9cd9..db95f2b 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -44,6 +44,7 @@ func internal(err error) error { type CreateRequest struct { RequestID string `json:"requestId"` + TaskType string `json:"-"` ExecutionMode string `json:"executionMode"` SYBProductID *uint64 `json:"sybProductId,omitempty"` PDDProductID *uint64 `json:"pddProductId,omitempty"` @@ -65,6 +66,22 @@ type CreateRequest struct { shopeeOrderNoSnapshotOverride string } +// StockCreateRequest is intentionally detached from SYB/Shopee identities. +// The server supplies the reviewed purchase rule and derives the immutable +// reference price from the selected color in the collected PDD archive. +type StockCreateRequest struct { + RequestID string `json:"requestId"` + ExecutionMode string `json:"executionMode"` + PDDProductID uint64 `json:"pddProductId"` + DeviceID *uint64 `json:"deviceId,omitempty"` + PDDAccountID *uint64 `json:"pddAccountId,omitempty"` + Color string `json:"color"` + Size string `json:"size,omitempty"` + Quantity int64 `json:"quantity"` + MinUnitPriceCent int64 `json:"minUnitPriceCent"` + MaxUnitPriceCent int64 `json:"maxUnitPriceCent"` +} + type ActionRequest struct { RequestID string `json:"requestId"` } diff --git a/server/app/goauto/purchasecontract/default.go b/server/app/goauto/purchasecontract/default.go index b055fc4..956ed62 100644 --- a/server/app/goauto/purchasecontract/default.go +++ b/server/app/goauto/purchasecontract/default.go @@ -9,3 +9,9 @@ import "encoding/json" 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","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"}]}`) } + +// DefaultRehearsalRule is the non-ordering counterpart used when an operator +// explicitly chooses rehearsal for a stock purchase task. +func DefaultRehearsalRule() json.RawMessage { + return json.RawMessage(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":1000},{"type":"verifyProduct"},{"type":"openSpecPanel","swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"}]}`) +} diff --git a/server/app/goauto/replacement/origin.go b/server/app/goauto/replacement/origin.go index cc5af50..a31bb77 100644 --- a/server/app/goauto/replacement/origin.go +++ b/server/app/goauto/replacement/origin.go @@ -53,6 +53,10 @@ func (service *Service) InspectOrigin(ctx context.Context, originType string, ta } else if err != nil { return OriginInspection{}, internal(err) } + if task.TaskType == models.PurchaseTaskTypeStock { + result.DisabledReason = "备货采购商品失效后,请重新创建备货采购" + return result, nil + } result.SourceProductID = task.PDDProductID if task.ShopeeProductID != nil { result.ShopeeProductID = *task.ShopeeProductID diff --git a/server/cmd/migrate/migration/version-local/1787885300000_stock_purchase.go b/server/cmd/migrate/migration/version-local/1787885300000_stock_purchase.go new file mode 100644 index 0000000..faebfa8 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787885300000_stock_purchase.go @@ -0,0 +1,27 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// #135 adds the additive purchase_task.task_type discriminator, backfills all +// existing rows to syb_order, and expands spec_source for direct PDD selection. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateStockPurchase) +} + +func migrateStockPurchase(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}