merge: purchase creator and device filters (#382)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -349,6 +349,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) {
|
||||
@@ -377,7 +378,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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,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.
|
||||
@@ -112,6 +127,7 @@ type StockCreateRequest struct {
|
||||
Quantity int64 `json:"quantity"`
|
||||
MinUnitPriceCent int64 `json:"minUnitPriceCent"`
|
||||
MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`
|
||||
creator creatorStamp
|
||||
}
|
||||
|
||||
type MatchingView struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+59
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }) }
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item label="设备"><el-select v-model="query.deviceId" clearable filterable placeholder="全部设备" style="width:170px"><el-option label="未分派" value="unassigned" /><el-option v-for="item in filterOptions.devices" :key="item.id" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="创建人"><el-select v-model="query.createdBy" clearable filterable placeholder="全部创建人" style="width:170px"><el-option label="Agent 发起" value="agent" /><el-option label="未记录" value="unrecorded" /><el-option v-for="item in filterOptions.creators" :key="item.id" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
|
||||
<el-form-item label="SYB 商品"><el-input v-model="query.sybProductId" class="syb-input" clearable placeholder="商品 ID" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="PDD 订单号"><el-input v-model="query.pddOrderNo" class="order-input" clearable placeholder="订单号" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item label="下单日期"><el-date-picker v-model="query.orderSubmittedRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" clearable style="width:260px" /></el-form-item>
|
||||
@@ -30,6 +32,7 @@
|
||||
<el-table-column label="PDD 商品" min-width="190"><template #default="{ row }"><div class="primary ellipsis">{{ row.pddTitleSnapshot || '标题未记录' }}</div><div class="muted">goods_id {{ row.pddGoodsIdSnapshot || '—' }}</div></template></el-table-column>
|
||||
<el-table-column label="采购规格 / 数量" min-width="190"><template #default="{ row }"><div>{{ specText(row.mappedColorSnapshot, row.mappedSizeSnapshot) }}</div><div class="muted">目标:{{ specText(row.targetColorSnapshot, row.targetSizeSnapshot) }} · {{ row.quantity }} 件</div></template></el-table-column>
|
||||
<el-table-column label="设备" min-width="140"><template #default="{ row }"><div>{{ row.deviceName || '尚未分派' }}</div><div class="muted">账号:{{ row.pddAccountRefSnapshot || '不强制记录' }}</div></template></el-table-column>
|
||||
<el-table-column label="创建人" min-width="110"><template #default="{ row }">{{ creatorLabel(row) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="150" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag><el-tooltip v-if="row.status === 'failed' && !row.retryable" :content="row.retryDisabledReason || '当前任务不能重试'" placement="top"><span class="retry-disabled" tabindex="0">不可重试</span></el-tooltip></template></el-table-column>
|
||||
<el-table-column label="规格匹配" min-width="150"><template #default="{ row }"><el-tag :type="matchingType(row.matching?.status)" effect="plain">{{ matchingLabel(row.matching?.status) }}</el-tag><div v-if="row.matching && !row.matching.executable" class="muted">{{ row.matching.reason || '完成前不可执行' }}</div></template></el-table-column>
|
||||
<el-table-column label="订单 / 支付" min-width="170"><template #default="{ row }"><div>{{ row.pddOrderNo || '尚未取得订单号' }}</div><div v-if="row.pddOrderNo" class="muted">{{ orderInformationComplete(row) ? '信息完整' : '订单信息待补全' }}</div><div class="muted">{{ paymentLabel(row.paymentReviewStatus) }}</div></template></el-table-column>
|
||||
@@ -56,6 +59,7 @@
|
||||
<el-descriptions-item label="数量">{{ detail.task.quantity }} 件</el-descriptions-item><el-descriptions-item label="映射来源">{{ specSourceLabel(detail.task.specSource) }} <el-button v-if="detail.task.shopeeProductId" type="primary" link @click="openShopeeMapping">查看映射</el-button></el-descriptions-item>
|
||||
<el-descriptions-item label="允许单价">{{ priceText(detail.task.minUnitPriceCent, detail.task.currency) }} ~ {{ priceText(detail.task.maxUnitPriceCent, detail.task.currency) }}</el-descriptions-item><el-descriptions-item label="参考单价">{{ priceText(detail.task.referenceUnitPriceCent, detail.task.currency) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="设备">{{ detail.task.deviceName || '尚未分派' }}</el-descriptions-item><el-descriptions-item label="地址后缀">{{ detail.task.addressSuffix || '未生成' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人">{{ creatorLabel(detail.task) }}</el-descriptions-item><el-descriptions-item label="创建来源">{{ createdSourceLabel(detail.task.createdSource) }}</el-descriptions-item>
|
||||
</el-descriptions></section>
|
||||
|
||||
<section v-if="detail.matching"><h3>规格匹配</h3>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user