From 6cc3bb98d099683aeab8e5ab5e7cfb340cb79f17 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 12 Oct 2026 17:14:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(db):=20purchase=5Ftask=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20created=5Fby/created=5Fsource=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=20(#382)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 只新增可空 created_by(索引)与非空默认空串的 created_source,不回填历史行; 迁移按列/索引存在性守卫,可重复执行。 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/models/purchase.go | 9 ++- .../1791700000000_purchase_task_creator.go | 37 ++++++++++++ ...791700000000_purchase_task_creator_test.go | 59 +++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 server/cmd/migrate/migration/version-local/1791700000000_purchase_task_creator.go create mode 100644 server/cmd/migrate/migration/version-local/1791700000000_purchase_task_creator_test.go diff --git a/server/app/goauto/models/purchase.go b/server/app/goauto/models/purchase.go index 9a57f4a..c0e83d4 100644 --- a/server/app/goauto/models/purchase.go +++ b/server/app/goauto/models/purchase.go @@ -170,8 +170,13 @@ type PurchaseTask struct { CancelReason *string `json:"cancelReason" gorm:"size:500"` ErrorCode *string `json:"errorCode" gorm:"size:64;index"` ErrorMessage *string `json:"errorMessage" gorm:"size:1000"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + // CreatedBy/CreatedSource 只在新建任务时写入一次(#382):admin=管理端账号, + // client_api=客户端密钥的授权人,agent=设备重试(CreatedBy 为空);空串表示 + // 迁移前的历史任务,不回填。 + CreatedBy *uint64 `json:"createdBy" gorm:"index"` + CreatedSource string `json:"createdSource" gorm:"size:16;not null;default:''"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (PurchaseTask) TableName() string { return "purchase_task" } diff --git a/server/cmd/migrate/migration/version-local/1791700000000_purchase_task_creator.go b/server/cmd/migrate/migration/version-local/1791700000000_purchase_task_creator.go new file mode 100644 index 0000000..37f49a1 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1791700000000_purchase_task_creator.go @@ -0,0 +1,37 @@ +package version_local + +import ( + "runtime" + + "go-admin/app/goauto/models" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +func init() { + _, file, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(file), MigratePurchaseTaskCreator) +} + +// MigratePurchaseTaskCreator only adds the #382 creator columns to +// purchase_task: nullable created_by (indexed) and created_source defaulting +// to the empty string. Existing rows are not rewritten or backfilled. Each step is guarded so +// a partial run (MySQL DDL commits implicitly) can be re-run safely. +func MigratePurchaseTaskCreator(db *gorm.DB, version string) error { + m := db.Migrator() + for _, column := range []string{"CreatedBy", "CreatedSource"} { + if !m.HasColumn(&models.PurchaseTask{}, column) { + if err := m.AddColumn(&models.PurchaseTask{}, column); err != nil { + return err + } + } + } + if !m.HasIndex(&models.PurchaseTask{}, "idx_purchase_task_created_by") { + if err := m.CreateIndex(&models.PurchaseTask{}, "CreatedBy"); err != nil { + return err + } + } + return db.Where("version = ?", version).FirstOrCreate(&common.Migration{Version: version}).Error +} diff --git a/server/cmd/migrate/migration/version-local/1791700000000_purchase_task_creator_test.go b/server/cmd/migrate/migration/version-local/1791700000000_purchase_task_creator_test.go new file mode 100644 index 0000000..4af85c1 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1791700000000_purchase_task_creator_test.go @@ -0,0 +1,59 @@ +package version_local + +import ( + "testing" + + "go-admin/app/goauto/models" + common "go-admin/common/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestMigratePurchaseTaskCreatorAddsColumnsKeepsLegacyRowsAndIsIdempotent(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + conn, _ := db.DB() + defer conn.Close() + if err = db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatal(err) + } + if err = db.Exec("CREATE TABLE purchase_task (id integer primary key, status text NOT NULL)").Error; err != nil { + t.Fatal(err) + } + if err = db.Exec("INSERT INTO purchase_task(id,status) VALUES(1,'failed')").Error; err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err = MigratePurchaseTaskCreator(db, "test_382"); err != nil { + t.Fatal(err) + } + } + for _, column := range []string{"CreatedBy", "CreatedSource"} { + if !db.Migrator().HasColumn(&models.PurchaseTask{}, column) { + t.Fatalf("missing column %s", column) + } + } + if !db.Migrator().HasIndex(&models.PurchaseTask{}, "idx_purchase_task_created_by") { + t.Fatal("missing created_by index") + } + var row struct { + Status string + CreatedBy *uint64 + CreatedSource string + } + if err = db.Table("purchase_task").Select("status, created_by, created_source").Where("id = 1").Scan(&row).Error; err != nil { + t.Fatal(err) + } + if row.Status != "failed" || row.CreatedBy != nil || row.CreatedSource != "" { + t.Fatalf("legacy row changed: %+v", row) + } + var count int64 + db.Model(&common.Migration{}).Where("version = ?", "test_382").Count(&count) + if count != 1 { + t.Fatal("version not idempotent") + } +} From ac7f81141c597431791f87b3d1e53004cdc286c9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 12 Oct 2026 17:14:51 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(purchase):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E4=BB=BB=E5=8A=A1=E5=88=9B=E5=BB=BA=E4=BA=BA?= =?UTF-8?q?=E4=B8=8E=E6=9D=A5=E6=BA=90=EF=BC=8C=E5=88=97=E8=A1=A8=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=AE=BE=E5=A4=87/=E5=88=9B=E5=BB=BA=E4=BA=BA?= =?UTF-8?q?=E7=AD=9B=E9=80=89=20(#382)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建时写入一次创建人与来源:Admin/备货/批量创建=admin,客户端密钥=client_api(授权人),Agent 重试=agent(无创建人);幂等重放不改写 - AdminList 新增 deviceId(含 unassigned)与 createdBy(含 agent/unrecorded)筛选,非法值返回 CodeInvalidRequest - 列表与详情返回 createdBy/createdByName/createdSource - 新增 GET /purchase-tasks/filter-options 返回实际出现过的设备与创建人 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/purchase/admin_query.go | 162 ++++++- server/app/goauto/purchase/batch.go | 3 +- .../goauto/purchase/creator_filter_test.go | 417 ++++++++++++++++++ server/app/goauto/purchase/handler.go | 35 ++ server/app/goauto/purchase/retry.go | 1 + server/app/goauto/purchase/router.go | 1 + server/app/goauto/purchase/service.go | 3 +- server/app/goauto/purchase/types.go | 16 + 8 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 server/app/goauto/purchase/creator_filter_test.go diff --git a/server/app/goauto/purchase/admin_query.go b/server/app/goauto/purchase/admin_query.go index 942152b..3199927 100644 --- a/server/app/goauto/purchase/admin_query.go +++ b/server/app/goauto/purchase/admin_query.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "sort" + "strconv" "strings" "time" "unicode" @@ -29,6 +31,35 @@ type AdminListRequest struct { // SYB products createdFrom/createdTo filter) over order_submitted_at. OrderSubmittedFrom string OrderSubmittedTo string + // DeviceID is a numeric device id or "unassigned" (#382). + DeviceID string + // CreatedBy is a numeric user id, "agent" or "unrecorded" (#382). + CreatedBy string +} + +const ( + adminFilterUnassigned = "unassigned" + adminFilterAgent = "agent" + adminFilterUnrecorded = "unrecorded" +) + +// parseAdminIDFilter returns (id, keyword, error); at most one of id/keyword is +// set. Empty input means the filter is absent. +func parseAdminIDFilter(raw, label string, keywords ...string) (uint64, string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, "", nil + } + for _, keyword := range keywords { + if raw == keyword { + return 0, keyword, nil + } + } + id, err := strconv.ParseUint(raw, 10, 64) + if err != nil || id == 0 { + return 0, "", fail(CodeInvalidRequest, label+" 无效") + } + return id, "", nil } // adminOrderDayLocation matches the SYB products page's createdFrom/createdTo @@ -78,6 +109,9 @@ type AdminTaskItem struct { PDDProductID uint64 `json:"pddProductId"` DeviceID *uint64 `json:"deviceId,omitempty"` DeviceName string `json:"deviceName,omitempty"` + CreatedBy *uint64 `json:"createdBy,omitempty"` + CreatedByName string `json:"createdByName,omitempty"` + CreatedSource string `json:"createdSource"` ShopeeItemIDSnapshot string `json:"shopeeItemIdSnapshot"` ShopeeOrderNoSnapshot string `json:"shopeeOrderNoSnapshot"` ShopeeTitleSnapshot string `json:"shopeeTitleSnapshot"` @@ -219,6 +253,14 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis if err != nil { return AdminListResponse{}, err } + deviceID, deviceKeyword, err := parseAdminIDFilter(req.DeviceID, "deviceId", adminFilterUnassigned) + if err != nil { + return AdminListResponse{}, err + } + createdByID, createdByKeyword, err := parseAdminIDFilter(req.CreatedBy, "createdBy", adminFilterAgent, adminFilterUnrecorded) + if err != nil { + return AdminListResponse{}, err + } query := s.DB.WithContext(ctx).Model(&models.PurchaseTask{}) if submittedFrom != nil { query = query.Where("order_submitted_at >= ?", *submittedFrom) @@ -229,6 +271,18 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis if req.TaskID > 0 { query = query.Where("id = ?", req.TaskID) } + if deviceID > 0 { + query = query.Where("device_id = ?", deviceID) + } else if deviceKeyword == adminFilterUnassigned { + query = query.Where("device_id IS NULL") + } + if createdByID > 0 { + query = query.Where("created_by = ?", createdByID) + } else if createdByKeyword == adminFilterAgent { + query = query.Where("created_source = ?", CreatedSourceAgent) + } else if createdByKeyword == adminFilterUnrecorded { + query = query.Where("created_source = ?", "") + } if req.Status == adminStatusSYBWritebackSucceeded { query = query.Where("id IN (?)", s.DB.WithContext(ctx).Model(&models.PurchaseOrderWriteback{}).Select("purchase_task_id").Where("status = ?", "succeeded")) } else if req.Status != "" { @@ -264,6 +318,10 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis if err != nil { return AdminListResponse{}, err } + creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), tasks) + if err != nil { + return AdminListResponse{}, err + } matching, err := s.matchingViews(ctx, tasks) if err != nil { return AdminListResponse{}, err @@ -279,6 +337,7 @@ func (s *Service) AdminList(ctx context.Context, req AdminListRequest) (AdminLis } for _, task := range tasks { item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)) + applyCreatorName(&item, creatorNames) item.OrderWriteback = writebacks[task.ID] item.Matching = matching[task.ID] if task.ShopeeProductID != nil { @@ -327,6 +386,10 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe if err != nil { return AdminDetailResponse{}, err } + creatorNames, err := loadCreatorNames(s.DB.WithContext(ctx), []models.PurchaseTask{task}) + if err != nil { + return AdminDetailResponse{}, err + } var attempts []models.PurchaseTaskAttempt if err := s.DB.WithContext(ctx).Where("task_id = ?", task.ID).Order("attempt_number ASC, id ASC").Find(&attempts).Error; err != nil { return AdminDetailResponse{}, internal(err) @@ -345,6 +408,7 @@ func (s *Service) AdminDetail(ctx context.Context, taskID uint64) (AdminDetailRe return AdminDetailResponse{}, matchErr } item := adminTaskItem(task, deviceNames, s.retryQueryEligibility(ctx, task, true)) + applyCreatorName(&item, creatorNames) writebacks, wbErr := s.OrderWritebackViews(ctx, []models.PurchaseTask{task}) if wbErr != nil { return AdminDetailResponse{}, wbErr @@ -384,11 +448,107 @@ func loadDeviceNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]strin return names, nil } +// userDisplayNames returns sys_user display names (nickname first, then +// username) for the given ids. Ids without a user row are absent from the map. +func userDisplayNames(db *gorm.DB, ids []uint64) (map[uint64]string, error) { + names := make(map[uint64]string, len(ids)) + if len(ids) == 0 { + return names, nil + } + var rows []struct { + UserID uint64 + Username string + NickName string + } + if err := db.Table("sys_user").Select("user_id, username, nick_name").Where("user_id IN ?", ids).Scan(&rows).Error; err != nil { + return nil, internal(err) + } + for _, row := range rows { + name := strings.TrimSpace(row.NickName) + if name == "" { + name = strings.TrimSpace(row.Username) + } + names[row.UserID] = name + } + return names, nil +} + +func loadCreatorNames(db *gorm.DB, tasks []models.PurchaseTask) (map[uint64]string, error) { + ids := make([]uint64, 0, len(tasks)) + seen := make(map[uint64]bool) + for _, task := range tasks { + if task.CreatedBy != nil && !seen[*task.CreatedBy] { + ids = append(ids, *task.CreatedBy) + seen[*task.CreatedBy] = true + } + } + return userDisplayNames(db, ids) +} + +func applyCreatorName(item *AdminTaskItem, names map[uint64]string) { + if item.CreatedBy != nil { + item.CreatedByName = names[*item.CreatedBy] + } +} + +// AdminFilterOption lists the devices and creators that actually appear on +// purchase tasks, so purchasers need no access to device or user management. +type AdminFilterOption struct { + ID uint64 `json:"id"` + Name string `json:"name"` +} + +type AdminFilterOptionsResponse struct { + Devices []AdminFilterOption `json:"devices"` + Creators []AdminFilterOption `json:"creators"` +} + +func (s *Service) AdminFilterOptions(ctx context.Context) (AdminFilterOptionsResponse, error) { + db := s.DB.WithContext(ctx) + var deviceIDs, creatorIDs []uint64 + if err := db.Model(&models.PurchaseTask{}).Where("device_id IS NOT NULL").Distinct().Pluck("device_id", &deviceIDs).Error; err != nil { + return AdminFilterOptionsResponse{}, internal(err) + } + if err := db.Model(&models.PurchaseTask{}).Where("created_by IS NOT NULL").Distinct().Pluck("created_by", &creatorIDs).Error; err != nil { + return AdminFilterOptionsResponse{}, internal(err) + } + result := AdminFilterOptionsResponse{Devices: []AdminFilterOption{}, Creators: []AdminFilterOption{}} + if len(deviceIDs) > 0 { + var devices []models.AgentDevice + if err := db.Select("id, name").Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil { + return AdminFilterOptionsResponse{}, internal(err) + } + for _, device := range devices { + result.Devices = append(result.Devices, AdminFilterOption{ID: device.ID, Name: device.Name}) + } + } + names, err := userDisplayNames(db, creatorIDs) + if err != nil { + return AdminFilterOptionsResponse{}, err + } + for _, id := range creatorIDs { + name := names[id] + if name == "" { + name = "用户#" + strconv.FormatUint(id, 10) + } + result.Creators = append(result.Creators, AdminFilterOption{ID: id, Name: name}) + } + for _, options := range [][]AdminFilterOption{result.Devices, result.Creators} { + sort.Slice(options, func(i, j int) bool { + if options[i].Name != options[j].Name { + return options[i].Name < options[j].Name + } + return options[i].ID < options[j].ID + }) + } + return result, nil +} + func adminTaskItem(task models.PurchaseTask, deviceNames map[uint64]string, retry retryDecision) AdminTaskItem { item := AdminTaskItem{ 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, + DeviceID: task.DeviceID, CreatedBy: task.CreatedBy, CreatedSource: task.CreatedSource, ShopeeItemIDSnapshot: task.ShopeeItemIDSnapshot, ShopeeOrderNoSnapshot: task.ShopeeOrderNoSnapshot, ShopeeTitleSnapshot: task.ShopeeTitleSnapshot, ShopeeShopNameSnapshot: task.ShopeeShopNameSnapshot, PDDGoodsIDSnapshot: task.PDDGoodsIDSnapshot, PDDTitleSnapshot: task.PDDTitleSnapshot, TargetColorSnapshot: task.TargetColorSnapshot, TargetSizeSnapshot: task.TargetSizeSnapshot, diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index f377041..a3a6e2c 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -29,6 +29,7 @@ type BatchCreateRequest struct { RequestID string `json:"requestId"` SYBProductIDs []uint64 `json:"sybProductIds"` DeviceID *uint64 `json:"deviceId,omitempty"` + creator creatorStamp } type BatchPreviewItem struct { @@ -313,7 +314,7 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc SYBProductID: &sybID, DeviceID: req.DeviceID, ReferenceUnitPriceCent: preview.ReferenceUnitPriceCent, MinUnitPriceCent: preview.MinUnitPriceCent, MaxUnitPriceCent: preview.MaxUnitPriceCent, - Currency: preview.Currency, RuleSnapshot: rawRule, + Currency: preview.Currency, RuleSnapshot: rawRule, creator: req.creator, }) if createErr != nil { preview.Eligible = false diff --git a/server/app/goauto/purchase/creator_filter_test.go b/server/app/goauto/purchase/creator_filter_test.go new file mode 100644 index 0000000..4ce41cb --- /dev/null +++ b/server/app/goauto/purchase/creator_filter_test.go @@ -0,0 +1,417 @@ +package purchase + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + "go-admin/common/clientprincipal" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "github.com/google/uuid" + "gorm.io/gorm" +) + +func ensureSysUserTable(t *testing.T, db *gorm.DB) { + t.Helper() + if err := db.Exec("CREATE TABLE IF NOT EXISTS sys_user (user_id integer primary key, username text, nick_name text)").Error; err != nil { + t.Fatal(err) + } +} + +func addSysUser(t *testing.T, db *gorm.DB, id uint64, username, nick string) { + t.Helper() + ensureSysUserTable(t, db) + if err := db.Exec("INSERT INTO sys_user(user_id, username, nick_name) VALUES(?,?,?)", id, username, nick).Error; err != nil { + t.Fatal(err) + } +} + +func stockRequest(f fixture) StockCreateRequest { + return StockCreateRequest{ + RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, + PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL", + Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500, + } +} + +func stockFixture(t *testing.T, db *gorm.DB, f fixture) { + t.Helper() + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } +} + +func extraDevice(name string) models.AgentDevice { + return models.AgentDevice{Name: name, InstallID: uuid.NewString(), Manufacturer: "T", Model: "T", AndroidVersion: "15", AgentVersion: "1", PDDVersion: "7", + Status: "online", TokenDigest: uuid.NewString(), TokenIssuedAt: time.Now()} +} + +func uptr(v uint64) *uint64 { return &v } + +func TestCreatorFromContextMapsCallerToSourceAndUser(t *testing.T) { + gin.SetMode(gin.TestMode) + newCtx := func() *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/", nil) + return c + } + jwtCtx := newCtx() + jwtCtx.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(7), "rolekey": "purchaser"}) + if got := creatorFromContext(jwtCtx); got.Source != CreatedSourceAdmin || got.By == nil || *got.By != 7 { + t.Fatalf("jwt creator: %+v", got) + } + clientCtx := newCtx() + clientprincipal.Set(clientCtx, clientprincipal.Identity{KeyID: 3, AuthorizedBy: 9}) + if got := creatorFromContext(clientCtx); got.Source != CreatedSourceClientAPI || got.By == nil || *got.By != 9 { + t.Fatalf("client creator: %+v", got) + } + anonymous := newCtx() + if got := creatorFromContext(anonymous); got.Source != CreatedSourceAdmin || got.By != nil { + t.Fatalf("zero operator must be NULL: %+v", got) + } +} + +func TestCreateEntriesStampCreatorOnceAndReplayKeepsIt(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + stockFixture(t, db, f) + service := testService(db) + + req := stockRequest(f) + req.creator = creatorStamp{By: uptr(11), Source: CreatedSourceAdmin} + first, replayed, err := service.CreateStock(context.Background(), req) + if err != nil || replayed { + t.Fatalf("create stock: %v replayed=%v", err, replayed) + } + if first.CreatedBy == nil || *first.CreatedBy != 11 || first.CreatedSource != CreatedSourceAdmin { + t.Fatalf("stock creator not stamped: %+v %v", first.CreatedBy, first.CreatedSource) + } + req.creator = creatorStamp{By: uptr(99), Source: CreatedSourceClientAPI} + replay, replayed, err := service.CreateStock(context.Background(), req) + if err != nil || !replayed || replay.ID != first.ID { + t.Fatalf("stock replay: %v", err) + } + var stored models.PurchaseTask + if err = db.First(&stored, first.ID).Error; err != nil { + t.Fatal(err) + } + if stored.CreatedBy == nil || *stored.CreatedBy != 11 || stored.CreatedSource != CreatedSourceAdmin { + t.Fatalf("replay rewrote creator: %+v %v", stored.CreatedBy, stored.CreatedSource) + } + + single, err := createLive(t, service, f) + if err != nil { + t.Fatal(err) + } + if single.CreatedBy != nil || single.CreatedSource != "" { + t.Fatalf("unstamped create must stay unrecorded: %+v %q", single.CreatedBy, single.CreatedSource) + } +} + +func TestBatchCreateStampsCreatorAndReplayDoesNotRewrite(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + request := BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID, + creator: creatorStamp{By: uptr(21), Source: CreatedSourceClientAPI}} + first, err := service.BatchCreate(context.Background(), request) + if err != nil || first.CreatedCount != 1 || first.Items[0].TaskID == nil { + t.Fatalf("batch create: %+v %v", first, err) + } + request.creator = creatorStamp{By: uptr(22), Source: CreatedSourceAdmin} + replay, err := service.BatchCreate(context.Background(), request) + if err != nil || replay.CreatedCount != 1 || !replay.Items[0].Replayed { + t.Fatalf("batch replay: %+v %v", replay, err) + } + var task models.PurchaseTask + if err = db.First(&task, *first.Items[0].TaskID).Error; err != nil { + t.Fatal(err) + } + if task.CreatedBy == nil || *task.CreatedBy != 21 || task.CreatedSource != CreatedSourceClientAPI { + t.Fatalf("batch creator: %+v %q", task.CreatedBy, task.CreatedSource) + } +} + +func TestAgentRetryTaskIsAgentSourceWithoutCreator(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "当前黑色", "target_size": "均碼"}).Error; err != nil { + t.Fatal(err) + } + result, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatal(err) + } + var created, source models.PurchaseTask + if err = db.First(&created, result.TaskID).Error; err != nil { + t.Fatal(err) + } + if created.CreatedBy != nil || created.CreatedSource != CreatedSourceAgent { + t.Fatalf("agent retry creator: %+v %q", created.CreatedBy, created.CreatedSource) + } + if err = db.First(&source, failed.ID).Error; err != nil || source.CreatedSource != "" { + t.Fatalf("source task must be untouched: %q %v", source.CreatedSource, err) + } +} + +func callAdminHandler(t *testing.T, db *gorm.DB, handler func(Handler, *gin.Context), method, target, body string, setup func(*gin.Context)) *httptest.ResponseRecorder { + t.Helper() + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(method, target, strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + setup(c) + handler(Handler{DB: db}, c) + return w +} + +func TestAdminCreateStockHandlerStampsJWTAndClientCreators(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + stockFixture(t, db, f) + body := func() string { + raw, _ := json.Marshal(stockRequest(f)) + return string(raw) + } + for _, tc := range []struct { + name string + setup func(*gin.Context) + by uint64 + source string + }{ + {"admin", func(c *gin.Context) { + c.Set("JWT_PAYLOAD", jwt.MapClaims{"identity": float64(5), "rolekey": "purchaser"}) + }, 5, CreatedSourceAdmin}, + {"client", func(c *gin.Context) { + clientprincipal.Set(c, clientprincipal.Identity{KeyID: 1, AuthorizedBy: 8}) + c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "admin"}) + }, 8, CreatedSourceClientAPI}, + } { + t.Run(tc.name, func(t *testing.T) { + w := callAdminHandler(t, db, Handler.AdminCreateStock, http.MethodPost, "/api/admin/v1/purchase-tasks/stock", body(), tc.setup) + var out struct { + Data models.PurchaseTask `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != http.StatusOK || out.Data.ID == 0 { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + var stored models.PurchaseTask + if err := db.First(&stored, out.Data.ID).Error; err != nil { + t.Fatal(err) + } + if stored.CreatedBy == nil || *stored.CreatedBy != tc.by || stored.CreatedSource != tc.source { + t.Fatalf("stored creator: %+v %q", stored.CreatedBy, stored.CreatedSource) + } + }) + } +} + +// seedCreatorRows inserts tasks with distinct devices/creators/sources. +func seedCreatorRows(t *testing.T, db *gorm.DB, f fixture) (devB models.AgentDevice, ids map[string]uint64) { + t.Helper() + s := testService(db) + base := createAdminQueryTask(t, s, f) + devB = extraDevice("Pixel") + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&devB).Error; err != nil { + t.Fatal(err) + } + ids = map[string]uint64{} + add := func(key string, device *uint64, by *uint64, source string, status string) { + row := base + row.ID = 0 + row.CreateRequestID = uuid.NewString() + row.DeviceID, row.CreatedBy, row.CreatedSource, row.Status = device, by, source, status + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&row).Error; err != nil { + t.Fatal(err) + } + ids[key] = row.ID + } + add("adminA_devA", &f.device.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed) + add("adminA_devB", &devB.ID, uptr(1), CreatedSourceAdmin, models.PurchaseTaskStatusFailed) + add("adminB_unassigned", nil, uptr(2), CreatedSourceAdmin, models.PurchaseTaskStatusCancelled) + add("agent_devA", &f.device.ID, nil, CreatedSourceAgent, models.PurchaseTaskStatusFailed) + add("legacy_devB", &devB.ID, nil, "", models.PurchaseTaskStatusFailed) + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", base.ID).Update("created_source", "").Error; err != nil { + t.Fatal(err) + } + ids["base"] = base.ID + return devB, ids +} + +func listIDs(t *testing.T, s *Service, req AdminListRequest) map[uint64]bool { + t.Helper() + out, err := s.AdminList(context.Background(), req) + if err != nil { + t.Fatal(err) + } + got := map[uint64]bool{} + for _, item := range out.Items { + got[item.ID] = true + } + return got +} + +func sameIDs(t *testing.T, got map[uint64]bool, ids map[string]uint64, keys ...string) { + t.Helper() + if len(got) != len(keys) { + t.Fatalf("got %v want keys %v (%v)", got, keys, ids) + } + for _, key := range keys { + if !got[ids[key]] { + t.Fatalf("missing %s in %v (%v)", key, got, ids) + } + } +} + +func TestAdminListDeviceAndCreatedByFilters(t *testing.T) { + db := testDB(t) + f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true) + addSysUser(t, db, 1, "alice", "爱丽丝") + addSysUser(t, db, 2, "bob", "") + devB, ids := seedCreatorRows(t, db, f) + s := testService(db) + devA := f.device.ID + + sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA)}), ids, "base", "adminA_devA", "agent_devA") + sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID)}), ids, "adminA_devB", "legacy_devB") + sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: "unassigned"}), ids, "adminB_unassigned") + sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1"}), ids, "adminA_devA", "adminA_devB") + sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "agent"}), ids, "agent_devA") + sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "unrecorded"}), ids, "base", "legacy_devB") + // Combined with each other and with existing filters. + sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devB.ID), CreatedBy: "1"}), ids, "adminA_devB") + sameIDs(t, listIDs(t, s, AdminListRequest{DeviceID: itoa(devA), CreatedBy: "unrecorded"}), ids, "base") + sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "2", Status: models.PurchaseTaskStatusCancelled, DeviceID: "unassigned"}), ids, "adminB_unassigned") + sameIDs(t, listIDs(t, s, AdminListRequest{CreatedBy: "1", Status: models.PurchaseTaskStatusCancelled}), ids) + + for _, bad := range []AdminListRequest{ + {DeviceID: "abc"}, {DeviceID: "0"}, {DeviceID: "-1"}, {DeviceID: "agent"}, + {CreatedBy: "abc"}, {CreatedBy: "0"}, {CreatedBy: "unassigned"}, + } { + if _, err := s.AdminList(context.Background(), bad); code(err) != CodeInvalidRequest { + t.Fatalf("%+v accepted: %v", bad, err) + } + } + + out, err := s.AdminList(context.Background(), AdminListRequest{PageSize: 100}) + if err != nil { + t.Fatal(err) + } + for _, item := range out.Items { + switch item.ID { + case ids["adminA_devA"]: + if item.CreatedBy == nil || *item.CreatedBy != 1 || item.CreatedByName != "爱丽丝" || item.CreatedSource != CreatedSourceAdmin { + t.Fatalf("nick name item: %+v", item) + } + case ids["adminB_unassigned"]: + if item.CreatedByName != "bob" { + t.Fatalf("username fallback: %q", item.CreatedByName) + } + case ids["agent_devA"]: + if item.CreatedBy != nil || item.CreatedSource != CreatedSourceAgent || item.CreatedByName != "" { + t.Fatalf("agent item: %+v", item) + } + case ids["legacy_devB"]: + if item.CreatedSource != "" { + t.Fatalf("legacy item: %+v", item) + } + } + } + detail, err := s.AdminDetail(context.Background(), ids["adminA_devA"]) + if err != nil || detail.Task.CreatedByName != "爱丽丝" || detail.Task.CreatedSource != CreatedSourceAdmin { + t.Fatalf("detail creator: %+v %v", detail.Task, err) + } +} + +func itoa(v uint64) string { + raw, _ := json.Marshal(v) + return string(raw) +} + +func TestAdminListHandlerRejectsInvalidDeviceAndCreatedBy(t *testing.T) { + db := testDB(t) + for _, query := range []string{"deviceId=abc", "createdBy=abc", "deviceId=0"} { + w := callAdminHandler(t, db, Handler.AdminList, http.MethodGet, "/api/admin/v1/purchase-tasks?"+query, "", func(c *gin.Context) { + c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": "purchaser"}) + }) + if w.Code != http.StatusUnprocessableEntity || !strings.Contains(w.Body.String(), CodeInvalidRequest) { + t.Fatalf("%s: %d %s", query, w.Code, w.Body.String()) + } + } +} + +func TestAdminFilterOptionsListsOnlyUsedDevicesAndCreatorsSorted(t *testing.T) { + db := testDB(t) + f := seed(t, db, []string{purchasecontract.CapabilityPurchaseRehearsalV1}, true) + addSysUser(t, db, 1, "zed", "") + addSysUser(t, db, 2, "amy", "Amy") + addSysUser(t, db, 3, "unused", "Unused") + devB, ids := seedCreatorRows(t, db, f) + _ = ids + unused := extraDevice("Unused") + if err := db.Session(&gorm.Session{SkipHooks: true}).Create(&unused).Error; err != nil { + t.Fatal(err) + } + out, err := testService(db).AdminFilterOptions(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(out.Devices) != 2 || out.Devices[0].Name != "Pixel" || out.Devices[0].ID != devB.ID || out.Devices[1].ID != f.device.ID { + t.Fatalf("devices: %+v", out.Devices) + } + if len(out.Creators) != 2 || out.Creators[0].Name != "Amy" || out.Creators[0].ID != 2 || out.Creators[1].Name != "zed" { + t.Fatalf("creators: %+v", out.Creators) + } +} + +func TestAdminFilterOptionsEmptyReturnsEmptyArrays(t *testing.T) { + empty, err := testService(testDB(t)).AdminFilterOptions(context.Background()) + if err != nil || empty.Devices == nil || empty.Creators == nil || len(empty.Devices)+len(empty.Creators) != 0 { + t.Fatalf("empty options must be empty arrays: %+v %v", empty, err) + } +} + +func TestAdminFilterOptionsHandlerAccess(t *testing.T) { + db := testDB(t) + for _, role := range []string{"admin", "purchaser", "after_sales", "other", ""} { + w := callAdminHandler(t, db, Handler.AdminFilterOptions, http.MethodGet, "/api/admin/v1/purchase-tasks/filter-options", "", func(c *gin.Context) { + c.Set("JWT_PAYLOAD", jwt.MapClaims{"rolekey": role}) + }) + want := role == "admin" || role == "purchaser" || role == "after_sales" + if want && w.Code != http.StatusOK { + t.Fatalf("role %q denied: %d %s", role, w.Code, w.Body.String()) + } + if !want && w.Code != http.StatusForbidden { + t.Fatalf("role %q allowed: %d", role, w.Code) + } + } +} + +func TestFilterOptionsRouteDoesNotCollideWithTaskID(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + group := engine.Group("/api/admin/v1/purchase-tasks") + group.GET("", func(c *gin.Context) { c.String(200, "list") }) + group.GET("/filter-options", func(c *gin.Context) { c.String(200, "options") }) + group.GET("/:taskId", func(c *gin.Context) { c.String(200, "detail") }) + for path, want := range map[string]string{"/api/admin/v1/purchase-tasks/filter-options": "options", "/api/admin/v1/purchase-tasks/12": "detail"} { + w := httptest.NewRecorder() + engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Body.String() != want { + t.Fatalf("%s routed to %q", path, w.Body.String()) + } + } +} diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 2534311..4cd97f1 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -58,6 +58,8 @@ func (h Handler) AdminList(c *gin.Context) { PDDOrderNo: strings.TrimSpace(c.Query("pddOrderNo")), OrderSubmittedFrom: c.Query("orderSubmittedFrom"), OrderSubmittedTo: c.Query("orderSubmittedTo"), + DeviceID: c.Query("deviceId"), + CreatedBy: c.Query("createdBy"), }) if err != nil { writeError(c, err) @@ -66,6 +68,22 @@ func (h Handler) AdminList(c *gin.Context) { writeAdminData(c, response) } +func (h Handler) AdminFilterOptions(c *gin.Context) { + if !allowedOperator(c) { + return + } + service, ok := h.service(c) + if !ok { + return + } + response, err := service.AdminFilterOptions(c.Request.Context()) + if err != nil { + writeError(c, err) + return + } + writeAdminData(c, response) +} + func (h Handler) AdminCreateStock(c *gin.Context) { if !allowedOperator(c) { return @@ -78,6 +96,7 @@ func (h Handler) AdminCreateStock(c *gin.Context) { if !ok { return } + req.creator = creatorFromContext(c) record, replayed, err := service.CreateStock(c.Request.Context(), req) if err != nil { writeError(c, err) @@ -118,6 +137,7 @@ func (h Handler) AdminCreate(c *gin.Context) { if !ok { return } + req.creator = creatorFromContext(c) record, replayed, err := service.Create(c.Request.Context(), req) if err != nil { writeError(c, err) @@ -178,6 +198,7 @@ func (h Handler) AdminBatchCreate(c *gin.Context) { if !ok { return } + req.creator = creatorFromContext(c) response, err := service.BatchCreate(c.Request.Context(), req) if err != nil { writeError(c, err) @@ -550,6 +571,20 @@ func writeError(c *gin.Context, err error) { } c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable}) } + +// creatorFromContext maps the authenticated caller to the #382 creator stamp. +// A zero operator id is stored as NULL rather than a fake user. +func creatorFromContext(c *gin.Context) creatorStamp { + stamp := creatorStamp{Source: CreatedSourceAdmin} + if _, ok := clientprincipal.Get(c); ok { + stamp.Source = CreatedSourceClientAPI + } + if id := operatorID(c); id > 0 { + stamp.By = &id + } + return stamp +} + func operatorID(c *gin.Context) uint64 { if id, ok := clientprincipal.Get(c); ok { return id.AuthorizedBy diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go index c02111c..547d310 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -196,6 +196,7 @@ func (s *Service) batchRetryCreate(ctx context.Context, req BatchRetryRequest) ( MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent, Currency: candidate.preview.Currency, RuleSnapshot: rawRule, + creator: creatorStamp{Source: CreatedSourceAgent}, }) item := response.Items[candidate.resultIndex] if createErr != nil { diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index e29d968..38d4738 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) { agent.POST("/:taskId/attempts/:attemptId/failure-snapshot", h.UploadFailureSnapshot) admin := engine.Group("/api/admin/v1/purchase-tasks").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) admin.GET("", h.AdminList) + admin.GET("/filter-options", h.AdminFilterOptions) admin.POST("/batch-preview", h.AdminBatchPreview) admin.POST("/batch-spec-match", h.AdminBatchSpecMatch) admin.POST("/batch", h.AdminBatchCreate) diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index 6591b8c..57d0c60 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -346,6 +346,7 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas 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, + CreatedBy: req.creator.By, CreatedSource: req.creator.Source, PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, WritebackStatus: models.PurchaseWritebackStatusNotSelected} if err := tx.Create(&out).Error; err != nil { if isDuplicate(err) { @@ -374,7 +375,7 @@ func (s *Service) CreateStock(ctx context.Context, req StockCreateRequest) (mode 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, + Currency: "CNY", RuleSnapshot: rule, creator: req.creator, }) } diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index a452ced..aa1222f 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -92,8 +92,23 @@ type CreateRequest struct { // immutable source-task value instead of accepting a client-supplied // identity snapshot. shopeeOrderNoSnapshotOverride string + // creator is server-only (#382): written once when a task row is created. + creator creatorStamp } +// creatorStamp records who created a purchase task. It is never decoded from +// request bodies; the handler or the internal caller fills it. +type creatorStamp struct { + By *uint64 + Source string +} + +const ( + CreatedSourceAdmin = "admin" + CreatedSourceClientAPI = "client_api" + CreatedSourceAgent = "agent" +) + // 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. @@ -108,6 +123,7 @@ type StockCreateRequest struct { Quantity int64 `json:"quantity"` MinUnitPriceCent int64 `json:"minUnitPriceCent"` MaxUnitPriceCent int64 `json:"maxUnitPriceCent"` + creator creatorStamp } type MatchingView struct { From 9e54263ee60282c5dc903d6cc26d01ba75a370f9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 12 Oct 2026 17:17:08 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(web):=20=E9=87=87=E8=B4=AD=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=A2=9E=E5=8A=A0=E8=AE=BE=E5=A4=87/=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E4=BA=BA=E7=AD=9B=E9=80=89=E4=B8=8E=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E4=BA=BA=E5=B1=95=E7=A4=BA=20(#382)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 筛选区新增「设备」「创建人」可清空可搜索下拉,选项来自 filter-options,含未分派/Agent 发起/未记录;参与查询、重置与导出 - 表格在设备列后新增「创建人」列,详情新增创建人与创建来源 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- web/src/api/goauto/purchase-tasks.js | 1 + web/src/views/goauto/purchase-tasks/index.vue | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/web/src/api/goauto/purchase-tasks.js b/web/src/api/goauto/purchase-tasks.js index 6bc5fc3..cb0bd37 100644 --- a/web/src/api/goauto/purchase-tasks.js +++ b/web/src/api/goauto/purchase-tasks.js @@ -19,6 +19,7 @@ export function listPurchaseTasks(params) { } }) } +export function getPurchaseTaskFilterOptions() { return request({ url: '/api/admin/v1/purchase-tasks/filter-options', method: 'get' }) } export function getPurchaseTask(taskId) { return request({ url: `/api/admin/v1/purchase-tasks/${taskId}`, method: 'get' }) } export function previewPurchaseTasks(data, options = {}) { return request({ url: '/api/admin/v1/purchase-tasks/batch-preview', method: 'post', data, ...options }) } export function matchPurchaseSpecsBatch(data) { return request({ url: '/api/admin/v1/purchase-tasks/batch-spec-match', method: 'post', data, timeout: 610000 }) } diff --git a/web/src/views/goauto/purchase-tasks/index.vue b/web/src/views/goauto/purchase-tasks/index.vue index c446345..385702a 100644 --- a/web/src/views/goauto/purchase-tasks/index.vue +++ b/web/src/views/goauto/purchase-tasks/index.vue @@ -14,6 +14,8 @@ + + @@ -30,6 +32,7 @@ + @@ -56,6 +59,7 @@ {{ detail.task.quantity }} 件{{ specSourceLabel(detail.task.specSource) }} 查看映射 {{ priceText(detail.task.minUnitPriceCent, detail.task.currency) }} ~ {{ priceText(detail.task.maxUnitPriceCent, detail.task.currency) }}{{ priceText(detail.task.referenceUnitPriceCent, detail.task.currency) }} {{ detail.task.deviceName || '尚未分派' }}{{ detail.task.addressSuffix || '未生成' }} + {{ creatorLabel(detail.task) }}{{ createdSourceLabel(detail.task.createdSource) }}

规格匹配

@@ -153,7 +157,7 @@ import { parsePurchaseOrderFilter } from '@/utils/purchase-order-filter' import { ElMessage, ElMessageBox } from 'element-plus' import { RefreshLeft, Search } from '@element-plus/icons-vue' -import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks' +import { authorizeRepurchase, cancelPurchaseTask, choosePurchaseMatching, getPurchaseTask, getPurchaseTaskFilterOptions, listPurchaseTasks, requeuePurchaseMatching, resolveUnknownPurchaseTask, retryPurchaseTasksBatch, reviewPurchasePayment, selectPurchaseWriteback, writebackPurchaseOrderNumbers } from '@/api/goauto/purchase-tasks' import { createRequestId } from '@/utils/request-id' import { saveExcel } from '@/utils/save-excel' import { EXPORT_MAX_ROWS, PURCHASE_EXPORT_HEADER, PURCHASE_EXPORT_NUMBER_COLS, exportFailureMessage, purchaseExportRow, runListExport } from '@/utils/list-export' @@ -175,7 +179,8 @@ export default { exporting: false, exportProgress: { done: 0, steps: 0 }, detailSession: 0, snapshots: { items: {}, loading: false, error: false }, orderWriteback: { open: false, saving: false, items: [] }, - query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] }, + query: { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [], deviceId: '', createdBy: '' }, + filterOptions: { devices: [], creators: [] }, detail: { open: false, loading: false, task: null, attempts: [], matching: null }, matchingAction: { saving: false }, matchingDialog: { open: false, saving: false, color: '', size: '' }, retrySelection: [], @@ -214,6 +219,7 @@ export default { beforeUnmount() { this.clearSnapshots() }, deactivated() { this.detail.open = false; this.clearSnapshots() }, created() { + this.loadFilterOptions() const taskId = Number(this.$route.query.taskId) if (Number.isInteger(taskId) && taskId > 0) { this.query.taskId = String(taskId) @@ -241,7 +247,17 @@ export default { return params }, search() { this.query.page = 1; this.load() }, - reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [] }; this.load() }, + reset() { this.query = { page: 1, pageSize: 20, taskId: '', taskType: '', shopeeOrderNo: '', status: '', executionMode: 'live', sybProductId: '', pddOrderNo: '', orderSubmittedRange: [], deviceId: '', createdBy: '' }; this.load() }, + // #382: options only list devices/creators that appear on purchase tasks; failure leaves the static options usable. + async loadFilterOptions() { + try { const r = await getPurchaseTaskFilterOptions(); this.filterOptions = { devices: r.data.devices || [], creators: r.data.creators || [] } } catch (error) { this.filterOptions = { devices: [], creators: [] } } + }, + createdSourceLabel(source) { return { admin: '管理端', client_api: '客户端接口', agent: 'Agent' }[source] || '未记录' }, + creatorLabel(task) { + if (task.createdSource === 'agent') return 'Agent' + if (!task.createdSource) return '未记录' + return task.createdByName || (task.createdBy ? `用户 #${task.createdBy}` : '未记录') + }, // #372: export every page of the filters as they are at click time (params are copied once). async exportExcel() { if (this.exporting || this.orderFilter.error) return