feat(goauto): add stock purchase task type (#135)

This commit is contained in:
QiuSW
2026-08-28 20:53:51 +08:00
parent 0f522d5339
commit 679f4691a4
21 changed files with 367 additions and 21 deletions
+3 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: b54f4cce004cb6eb2398b40f89ad88888b773a3a
synchronized_at: 2026-08-28T09:50:04Z
wiki_revision: 5ccc58883979c6593f9d16d7865d5b44716cb374
synchronized_at: 2026-08-28T12:43:45Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -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 |
+4 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 6e33660c976c4412c3c456d04d5b4784a3043732
synchronized_at: 2026-08-28T10:27:03Z
wiki_revision: b78825c5e140a3e24948107628d6ced0db6f6a37
synchronized_at: 2026-08-28T12:43:51Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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 账号,因此账号引用可空;已知账号才参与账号级串行,未知账号不会阻止采购任务。
+4 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 81d3fec214250f9b5be4b049e65ca6a1144e52ad
synchronized_at: 2026-08-28T10:27:46Z
wiki_revision: c7dd62e1d67e725a83bb343d1a25f18a49b68c8e
synchronized_at: 2026-08-28T12:44:25Z
<!-- gitea-wiki-mirror:end -->
# 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` | 一次性授权;创建新任务后自动消耗 |
+1
View File
@@ -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},
@@ -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")
}
+36 -1
View File
@@ -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(&current).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
}
@@ -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)
+20 -8
View File
@@ -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
+9 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
+21
View File
@@ -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
+6
View File
@@ -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, "只有已支付订单可以加入回填候选")
}
+3
View File
@@ -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, "只有采购失败任务可以重试")
}
+28
View File
@@ -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)
+1
View File
@@ -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)
+101 -4
View File
@@ -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.
@@ -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)
+17
View File
@@ -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"`
}
@@ -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"}]}`)
}
+4
View File
@@ -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
@@ -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
})
}