feat(goauto): add managed purchase rules (#127)
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
goautodevice "go-admin/app/goauto/device"
|
||||
goautoproduct "go-admin/app/goauto/product"
|
||||
goautopurchase "go-admin/app/goauto/purchase"
|
||||
goautopurchaserule "go-admin/app/goauto/purchaserule"
|
||||
goautoreplacement "go-admin/app/goauto/replacement"
|
||||
goautorule "go-admin/app/goauto/rule"
|
||||
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
|
||||
@@ -54,6 +55,7 @@ func InitRouter() {
|
||||
goautoaimatching.InitRouter(r, authMiddleware)
|
||||
goautotask.InitRouter(r, authMiddleware)
|
||||
goautopurchase.InitRouter(r, authMiddleware)
|
||||
goautopurchaserule.InitRouter(r, authMiddleware)
|
||||
goautoproduct.InitRouter(r, authMiddleware)
|
||||
goautoreplacement.InitRouter(r, authMiddleware)
|
||||
goautorule.InitRouter(r, authMiddleware)
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
ModuleSYBSyncRuns = "syb_sync_runs"
|
||||
ModuleSYBInnerCodes = "syb_inner_codes"
|
||||
ModuleCollectionRules = "collection_rules"
|
||||
ModulePurchaseRules = "purchase_rules"
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
@@ -77,6 +78,7 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
|
||||
ModuleKeys: []string{
|
||||
ModuleSYBShops,
|
||||
ModuleCollectionRules,
|
||||
ModulePurchaseRules,
|
||||
ModuleDevices,
|
||||
ModuleAIMatching,
|
||||
},
|
||||
@@ -92,9 +94,10 @@ var goAutoModuleMetadata = []ModuleDefinition{
|
||||
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 55, PurchaserDefault: true},
|
||||
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 56, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 57, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 58, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 59, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 60, PurchaserHardHidden: true},
|
||||
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 58, PurchaserHardHidden: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 59, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 61, PurchaserHardHidden: true},
|
||||
}
|
||||
|
||||
// GoAutoModules returns independent copies so callers cannot mutate the
|
||||
@@ -141,6 +144,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModuleSYBInnerCodes
|
||||
case strings.HasPrefix(path, "/api/admin/v1/collection-rules"):
|
||||
return ModuleCollectionRules
|
||||
case strings.HasPrefix(path, "/api/admin/v1/purchase-rules"):
|
||||
return ModulePurchaseRules
|
||||
case strings.HasPrefix(path, "/api/admin/v1/collection-tasks"):
|
||||
return ModuleCollectionTasks
|
||||
case strings.HasPrefix(path, "/api/admin/v1/purchase-tasks"):
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
modules := GoAutoModules()
|
||||
if len(modules) != 11 {
|
||||
t.Fatalf("got %d modules, want 11", len(modules))
|
||||
if len(modules) != 12 {
|
||||
t.Fatalf("got %d modules, want 12", len(modules))
|
||||
}
|
||||
|
||||
owners := make(map[string]int)
|
||||
@@ -56,8 +56,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
|
||||
}
|
||||
previous = module.Sort
|
||||
}
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 60 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..60", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 61 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..61", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
|
||||
|
||||
wantOrder := [][]string{
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
|
||||
{ModuleSYBShops, ModuleCollectionRules, ModuleDevices, ModuleAIMatching},
|
||||
{ModuleSYBShops, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
for groupIndex, group := range groups {
|
||||
|
||||
@@ -74,6 +74,13 @@ var AdminAPIs = []APIPermission{
|
||||
{"修改采集规则", "/api/admin/v1/collection-rules/:ruleId", "PATCH", false},
|
||||
{"删除采集规则", "/api/admin/v1/collection-rules/:ruleId", "DELETE", false},
|
||||
|
||||
{"查看采购规则", "/api/admin/v1/purchase-rules", "GET", false},
|
||||
{"查看当前采购规则", "/api/admin/v1/purchase-rules/current", "GET", false},
|
||||
{"新增采购规则", "/api/admin/v1/purchase-rules", "POST", false},
|
||||
{"修改采购规则", "/api/admin/v1/purchase-rules/:ruleId", "PATCH", false},
|
||||
{"删除采购规则", "/api/admin/v1/purchase-rules/:ruleId", "DELETE", false},
|
||||
{"设置当前采购规则", "/api/admin/v1/purchase-rules/current", "PUT", false},
|
||||
|
||||
{"查看采集任务", "/api/admin/v1/collection-tasks", "GET", true},
|
||||
{"创建采集任务", "/api/admin/v1/collection-tasks", "POST", true},
|
||||
{"批量创建采集任务", "/api/admin/v1/collection-tasks/batch", "POST", true},
|
||||
|
||||
@@ -50,6 +50,8 @@ func MigratedModels() []any {
|
||||
&models.PurchaseTaskAttempt{},
|
||||
&models.PurchaseSpecMatchWorkItem{},
|
||||
&models.CollectionRule{},
|
||||
&models.PurchaseRule{},
|
||||
&models.PurchaseRuleSetting{},
|
||||
&models.AgentManualCollectionSetting{},
|
||||
&models.CollectionTask{},
|
||||
&models.CollectionTaskAttempt{},
|
||||
|
||||
@@ -112,6 +112,35 @@ type CollectionRule struct {
|
||||
|
||||
func (CollectionRule) TableName() string { return "collection_rule" }
|
||||
|
||||
// PurchaseRule stores an administrator-reviewed purchase execution contract.
|
||||
// Tasks copy ContentJSON into their immutable rule snapshot at creation time.
|
||||
type PurchaseRule struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"size:120;not null"`
|
||||
ContentJSON string `json:"content" gorm:"type:text;not null"`
|
||||
LastCreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_create_request_id"`
|
||||
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_update_request_id"`
|
||||
LastDeleteRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_delete_request_id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
}
|
||||
|
||||
func (PurchaseRule) TableName() string { return "purchase_rule" }
|
||||
|
||||
// PurchaseRuleSetting selects the single rule used by newly created live
|
||||
// purchase tasks. There is deliberately no built-in fallback at runtime.
|
||||
type PurchaseRuleSetting struct {
|
||||
ID uint8 `json:"id" gorm:"primaryKey;autoIncrement:false"`
|
||||
RuleID uint64 `json:"ruleId" gorm:"not null;index"`
|
||||
Rule PurchaseRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
|
||||
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_purchase_rule_setting_request_id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (PurchaseRuleSetting) TableName() string { return "purchase_rule_setting" }
|
||||
|
||||
// CollectionTask stores both task lifecycle and result summary. ActiveSlot and
|
||||
// DeviceRunSlot are nullable guard columns: NULL permits multiple terminal rows,
|
||||
// while value 1 makes the composite unique indexes enforce active-task limits
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/product"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
"go-admin/app/goauto/purchaserule"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@@ -84,11 +85,15 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
|
||||
if err != nil {
|
||||
return BatchPreviewResponse{}, err
|
||||
}
|
||||
_, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if err != nil {
|
||||
return BatchPreviewResponse{}, err
|
||||
}
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, ids)
|
||||
if err != nil {
|
||||
return BatchPreviewResponse{}, internal(err)
|
||||
}
|
||||
deviceErr := s.validateBatchDevice(ctx, req.DeviceID)
|
||||
deviceErr := s.validateBatchDevice(ctx, req.DeviceID, currentRule)
|
||||
items := make([]BatchPreviewItem, 0, len(ids))
|
||||
eligible := 0
|
||||
for _, id := range ids {
|
||||
@@ -202,7 +207,11 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc
|
||||
if err != nil {
|
||||
return BatchCreateResponse{}, err
|
||||
}
|
||||
deviceErr := s.validateBatchDevice(ctx, req.DeviceID)
|
||||
_, rawRule, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if err != nil {
|
||||
return BatchCreateResponse{}, err
|
||||
}
|
||||
deviceErr := s.validateBatchDevice(ctx, req.DeviceID, currentRule)
|
||||
result := BatchCreateResponse{Items: make([]BatchCreateItem, 0, len(ids))}
|
||||
for _, id := range ids {
|
||||
itemRequestID := batchItemRequestID(req.RequestID, id)
|
||||
@@ -234,7 +243,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: purchasecontract.DefaultLiveRule(),
|
||||
Currency: preview.Currency, RuleSnapshot: rawRule,
|
||||
})
|
||||
if createErr != nil {
|
||||
preview.Eligible = false
|
||||
@@ -266,7 +275,7 @@ func validateBatchIDs(raw []uint64) ([]uint64, error) {
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64) error {
|
||||
func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rule purchasecontract.RuleSnapshot) error {
|
||||
if deviceID == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -277,10 +286,6 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64) err
|
||||
if record.Status != models.DeviceStatusOnline || record.TokenRevokedAt != nil {
|
||||
return fail(CodeInvalidRequest, "所选设备当前不可用,请选择在线设备或留空")
|
||||
}
|
||||
rule, err := purchasecontract.Validate(purchasecontract.DefaultLiveRule(), models.PurchaseExecutionModeLive)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule))
|
||||
}
|
||||
|
||||
|
||||
@@ -155,8 +155,8 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
if matcher.calls != 0 {
|
||||
t.Fatalf("read-only preview called AI matcher %d times", matcher.calls)
|
||||
}
|
||||
if queries != 4 {
|
||||
t.Fatalf("batch preview used %d queries, want 4 bulk queries", queries)
|
||||
if queries != 5 {
|
||||
t.Fatalf("batch preview used %d queries, want 5 bounded queries including current purchase rule", queries)
|
||||
}
|
||||
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible {
|
||||
t.Fatalf("unresolved row should remain selectable for full create-time matching: %+v", response)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
"go-admin/app/goauto/purchaserule"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@@ -91,10 +92,9 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
|
||||
return err
|
||||
}
|
||||
|
||||
rawRule := purchasecontract.DefaultLiveRule()
|
||||
rule, err := purchasecontract.Validate(rawRule, models.PurchaseExecutionModeLive)
|
||||
_, rawRule, rule, err := purchaserule.CurrentRule(ctx, tx, models.PurchaseExecutionModeLive)
|
||||
if err != nil {
|
||||
return fail(CodeInvalidRequest, "当前采购规则不可用,请联系管理员")
|
||||
return err
|
||||
}
|
||||
required := purchasecontract.RequiredCapabilities(rule)
|
||||
if err := ensureCapabilities(deviceRecord, required); err != nil {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
"go-admin/app/goauto/purchaserule"
|
||||
"go-admin/app/goauto/replacement"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -76,6 +76,10 @@ func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchR
|
||||
if err != nil {
|
||||
return BatchRetryResponse{}, err
|
||||
}
|
||||
_, rawRule, _, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if err != nil {
|
||||
return BatchRetryResponse{}, err
|
||||
}
|
||||
|
||||
response := BatchRetryResponse{Items: make([]BatchRetryItem, len(ids))}
|
||||
prepared := make([]preparedRetry, 0, len(ids))
|
||||
@@ -124,7 +128,7 @@ func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchR
|
||||
MinUnitPriceCent: candidate.preview.MinUnitPriceCent,
|
||||
MaxUnitPriceCent: candidate.preview.MaxUnitPriceCent,
|
||||
Currency: candidate.preview.Currency,
|
||||
RuleSnapshot: purchasecontract.DefaultLiveRule(),
|
||||
RuleSnapshot: rawRule,
|
||||
})
|
||||
item := response.Items[candidate.resultIndex]
|
||||
if createErr != nil {
|
||||
@@ -316,7 +320,12 @@ func (s *Service) retryDeviceEligibility(ctx context.Context, task models.Purcha
|
||||
return retryDecision{ReasonCode: code, Reason: message}
|
||||
}
|
||||
if task.DeviceID != nil {
|
||||
if err := s.validateBatchDevice(ctx, task.DeviceID); err != nil {
|
||||
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if ruleErr != nil {
|
||||
code, message, _ := serviceErrorFields(ruleErr)
|
||||
return deny(code, message)
|
||||
}
|
||||
if err := s.validateBatchDevice(ctx, task.DeviceID, currentRule); err != nil {
|
||||
code, message, _ := serviceErrorFields(err)
|
||||
if code == CodeInvalidRequest {
|
||||
message = "原设备当前不可用,请先恢复设备"
|
||||
|
||||
@@ -39,6 +39,13 @@ func testDB(t *testing.T) *gorm.DB {
|
||||
if err = migrations.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaultRule := models.PurchaseRule{Name: "测试当前采购规则", ContentJSON: string(purchasecontract.DefaultLiveRule())}
|
||||
if err = db.Create(&defaultRule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&models.PurchaseRuleSetting{ID: 1, RuleID: defaultRule.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
func testService(db *gorm.DB) *Service {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package purchaserule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct{ DB *gorm.DB }
|
||||
|
||||
func (h Handler) service(c *gin.Context) (*Service, bool) {
|
||||
db := h.DB
|
||||
var err error
|
||||
if db == nil {
|
||||
db, err = pkg.GetOrm(c)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(c, internal(err))
|
||||
return nil, false
|
||||
}
|
||||
return NewService(db), true
|
||||
}
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
page, err := positive(c.Query("page"), 1)
|
||||
if err != nil {
|
||||
writeError(c, invalid("page 必须是正整数"))
|
||||
return
|
||||
}
|
||||
size, err := positive(c.Query("pageSize"), 20)
|
||||
if err != nil {
|
||||
writeError(c, invalid("pageSize 必须是正整数"))
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, err := s.List(c.Request.Context(), page, size, c.Query("name"))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": v})
|
||||
}
|
||||
func (h Handler) Current(c *gin.Context) {
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, err := s.Current(c.Request.Context())
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(200, gin.H{"code": 200, "data": v})
|
||||
}
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
req, err := decode[SaveRequest](c)
|
||||
if err != nil {
|
||||
writeError(c, invalid("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, err := s.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": v})
|
||||
}
|
||||
func (h Handler) Update(c *gin.Context) {
|
||||
id, err := ruleID(c)
|
||||
if err != nil {
|
||||
writeError(c, invalid("ruleId 无效"))
|
||||
return
|
||||
}
|
||||
req, err := decode[SaveRequest](c)
|
||||
if err != nil {
|
||||
writeError(c, invalid("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, err := s.Update(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": v})
|
||||
}
|
||||
func (h Handler) Delete(c *gin.Context) {
|
||||
id, err := ruleID(c)
|
||||
if err != nil {
|
||||
writeError(c, invalid("ruleId 无效"))
|
||||
return
|
||||
}
|
||||
req, err := decode[DeleteRequest](c)
|
||||
if err != nil {
|
||||
writeError(c, invalid("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
replayed, err := s.Delete(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"ruleId": id, "replayed": replayed}})
|
||||
}
|
||||
func (h Handler) SetCurrent(c *gin.Context) {
|
||||
req, err := decode[SetCurrentRequest](c)
|
||||
if err != nil {
|
||||
writeError(c, invalid("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rule, replayed, err := s.SetCurrent(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"rule": rule, "replayed": replayed}})
|
||||
}
|
||||
func decode[T any](c *gin.Context) (T, error) {
|
||||
var v T
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 300<<10)
|
||||
d := json.NewDecoder(c.Request.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err := d.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return v, errors.New("one object required")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func positive(v string, f int) (int, error) {
|
||||
if v == "" {
|
||||
return f, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
return 0, errors.New("invalid")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
func ruleID(c *gin.Context) (uint64, error) { return strconv.ParseUint(c.Param("ruleId"), 10, 64) }
|
||||
func writeError(c *gin.Context, err error) {
|
||||
var target *ServiceError
|
||||
if !errors.As(err, &target) {
|
||||
target = internal(err).(*ServiceError)
|
||||
}
|
||||
status := 500
|
||||
switch target.Code {
|
||||
case CodeInvalidRequest, CodeRuleInvalid:
|
||||
status = 422
|
||||
case CodeRuleNotFound, CodeCurrentRuleMissing:
|
||||
status = 404
|
||||
case CodeCurrentRuleConflict:
|
||||
status = 409
|
||||
}
|
||||
c.JSON(status, gin.H{"code": target.Code, "message": target.Message, "retryable": target.Retryable})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package purchaserule
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
h := Handler{}
|
||||
g := engine.Group("/api/admin/v1/purchase-rules").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
|
||||
g.GET("", h.List)
|
||||
g.GET("/current", h.Current)
|
||||
g.POST("", h.Create)
|
||||
g.PATCH("/:ruleId", h.Update)
|
||||
g.DELETE("/:ruleId", h.Delete)
|
||||
g.PUT("/current", h.SetCurrent)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package purchaserule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
CodeInvalidRequest = "INVALID_REQUEST"
|
||||
CodeRuleInvalid = "PURCHASE_RULE_INVALID"
|
||||
CodeRuleNotFound = "PURCHASE_RULE_NOT_FOUND"
|
||||
CodeCurrentRuleMissing = "CURRENT_PURCHASE_RULE_MISSING"
|
||||
CodeCurrentRuleConflict = "CURRENT_PURCHASE_RULE_CONFLICT"
|
||||
CodeInternal = "INTERNAL_ERROR"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
Code, Message string
|
||||
Retryable bool
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ServiceError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
func (e *ServiceError) Unwrap() error { return e.Cause }
|
||||
|
||||
type SaveRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Name string `json:"name"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
type DeleteRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
type SetCurrentRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
RuleID uint64 `json:"ruleId"`
|
||||
}
|
||||
type RuleItem struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
type ListResponse struct {
|
||||
Items []RuleItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
type SaveResponse struct {
|
||||
Rule RuleItem `json:"rule"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
|
||||
|
||||
func CurrentRule(ctx context.Context, db *gorm.DB, mode string) (models.PurchaseRule, json.RawMessage, purchasecontract.RuleSnapshot, error) {
|
||||
var setting models.PurchaseRuleSetting
|
||||
if err := db.WithContext(ctx).Joins("Rule").First(&setting, 1).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return models.PurchaseRule{}, nil, purchasecontract.RuleSnapshot{}, currentMissing("未配置当前采购规则,请联系管理员")
|
||||
}
|
||||
return models.PurchaseRule{}, nil, purchasecontract.RuleSnapshot{}, internal(err)
|
||||
}
|
||||
raw := json.RawMessage(setting.Rule.ContentJSON)
|
||||
rule, err := purchasecontract.Validate(raw, mode)
|
||||
if err != nil {
|
||||
return models.PurchaseRule{}, nil, purchasecontract.RuleSnapshot{}, currentMissing("当前采购规则无效,请联系管理员")
|
||||
}
|
||||
return setting.Rule, raw, rule, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, page, size int, name string) (ListResponse, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 20
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
q := s.DB.WithContext(ctx).Model(&models.PurchaseRule{})
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
q = q.Where("name LIKE ?", "%"+name+"%")
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
var rows []models.PurchaseRule
|
||||
if err := q.Order("updated_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
currentID := uint64(0)
|
||||
var setting models.PurchaseRuleSetting
|
||||
if err := s.DB.WithContext(ctx).First(&setting, 1).Error; err == nil {
|
||||
currentID = setting.RuleID
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
items := make([]RuleItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
value, err := item(row, row.ID == currentID)
|
||||
if err != nil {
|
||||
return ListResponse{}, err
|
||||
}
|
||||
items = append(items, value)
|
||||
}
|
||||
return ListResponse{Items: items, Total: total, Page: page, PageSize: size}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Current(ctx context.Context) (RuleItem, error) {
|
||||
record, _, _, err := CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if err != nil {
|
||||
return RuleItem{}, err
|
||||
}
|
||||
return item(record, true)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, req SaveRequest) (SaveResponse, error) {
|
||||
name, content, err := validateSave(req)
|
||||
if err != nil {
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
var replay models.PurchaseRule
|
||||
db := s.DB.WithContext(ctx)
|
||||
if err := db.Where("last_create_request_id = ?", req.RequestID).First(&replay).Error; err == nil {
|
||||
value, e := item(replay, false)
|
||||
return SaveResponse{Rule: value, Replayed: true}, e
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return SaveResponse{}, internal(err)
|
||||
}
|
||||
record := models.PurchaseRule{Name: name, ContentJSON: content, LastCreateRequestID: &req.RequestID}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
return SaveResponse{}, internal(err)
|
||||
}
|
||||
value, err := item(record, false)
|
||||
return SaveResponse{Rule: value}, err
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id uint64, req SaveRequest) (SaveResponse, error) {
|
||||
if id == 0 {
|
||||
return SaveResponse{}, invalid("ruleId 无效")
|
||||
}
|
||||
name, content, err := validateSave(req)
|
||||
if err != nil {
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
db := s.DB.WithContext(ctx)
|
||||
var replay models.PurchaseRule
|
||||
if err := db.Where("id = ? AND last_update_request_id = ?", id, req.RequestID).First(&replay).Error; err == nil {
|
||||
value, e := item(replay, false)
|
||||
return SaveResponse{Rule: value, Replayed: true}, e
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return SaveResponse{}, internal(err)
|
||||
}
|
||||
r := db.Model(&models.PurchaseRule{}).Where("id = ?", id).Updates(map[string]any{"name": name, "content_json": content, "last_update_request_id": req.RequestID})
|
||||
if r.Error != nil {
|
||||
return SaveResponse{}, internal(r.Error)
|
||||
}
|
||||
if r.RowsAffected == 0 {
|
||||
return SaveResponse{}, notFound()
|
||||
}
|
||||
var updated models.PurchaseRule
|
||||
if err := db.First(&updated, id).Error; err != nil {
|
||||
return SaveResponse{}, internal(err)
|
||||
}
|
||||
value, err := item(updated, false)
|
||||
return SaveResponse{Rule: value}, err
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id uint64, req DeleteRequest) (bool, error) {
|
||||
if id == 0 {
|
||||
return false, invalid("ruleId 无效")
|
||||
}
|
||||
if err := validateRequestID(req.RequestID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
db := s.DB.WithContext(ctx)
|
||||
var replay models.PurchaseRule
|
||||
if err := db.Unscoped().Where("id = ? AND last_delete_request_id = ?", id, req.RequestID).First(&replay).Error; err == nil {
|
||||
return true, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, internal(err)
|
||||
}
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var setting models.PurchaseRuleSetting
|
||||
if err := tx.First(&setting, 1).Error; err == nil && setting.RuleID == id {
|
||||
return conflict("当前采购规则不能删除,请先切换规则")
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return internal(err)
|
||||
}
|
||||
var row models.PurchaseRule
|
||||
if err := tx.First(&row, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return notFound()
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
if err := tx.Model(&row).Update("last_delete_request_id", req.RequestID).Error; err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return tx.Delete(&row).Error
|
||||
})
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (s *Service) SetCurrent(ctx context.Context, req SetCurrentRequest) (RuleItem, bool, error) {
|
||||
if err := validateRequestID(req.RequestID); err != nil {
|
||||
return RuleItem{}, false, err
|
||||
}
|
||||
if req.RuleID == 0 {
|
||||
return RuleItem{}, false, invalid("ruleId 无效")
|
||||
}
|
||||
db := s.DB.WithContext(ctx)
|
||||
replayed := false
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.PurchaseRuleSetting
|
||||
if err := tx.Where("last_update_request_id = ?", req.RequestID).First(¤t).Error; err == nil {
|
||||
if current.RuleID != req.RuleID {
|
||||
return conflict("requestId 已用于其他当前规则设置")
|
||||
}
|
||||
replayed = true
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return internal(err)
|
||||
}
|
||||
var row models.PurchaseRule
|
||||
if err := tx.First(&row, req.RuleID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return notFound()
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
if _, err := purchasecontract.Validate([]byte(row.ContentJSON), models.PurchaseExecutionModeLive); err != nil {
|
||||
return invalidRule(err.Error())
|
||||
}
|
||||
setting := models.PurchaseRuleSetting{ID: 1, RuleID: req.RuleID, LastUpdateRequestID: &req.RequestID}
|
||||
return tx.Save(&setting).Error
|
||||
})
|
||||
if err != nil {
|
||||
return RuleItem{}, false, err
|
||||
}
|
||||
var row models.PurchaseRule
|
||||
if err := db.First(&row, req.RuleID).Error; err != nil {
|
||||
return RuleItem{}, false, internal(err)
|
||||
}
|
||||
value, err := item(row, true)
|
||||
return value, replayed, err
|
||||
}
|
||||
|
||||
func validateSave(req SaveRequest) (string, string, error) {
|
||||
if err := validateRequestID(req.RequestID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" || len(name) > 120 {
|
||||
return "", "", invalid("name 必填且长度不能超过 120")
|
||||
}
|
||||
if len(req.Content) == 0 || len(req.Content) > 256<<10 {
|
||||
return "", "", invalidRule("content 必填且不能超过 256 KiB")
|
||||
}
|
||||
rule, err := purchasecontract.Validate(req.Content, models.PurchaseExecutionModeLive)
|
||||
if err != nil {
|
||||
return "", "", invalidRule(err.Error())
|
||||
}
|
||||
normalized, err := json.Marshal(rule)
|
||||
if err != nil {
|
||||
return "", "", internal(err)
|
||||
}
|
||||
return name, string(normalized), nil
|
||||
}
|
||||
func validateRequestID(id string) error {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(id)); err != nil {
|
||||
return invalid("requestId 必须是 UUID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func item(row models.PurchaseRule, current bool) (RuleItem, error) {
|
||||
raw := json.RawMessage(row.ContentJSON)
|
||||
if !json.Valid(raw) {
|
||||
return RuleItem{}, internal(errors.New("stored purchase rule is invalid JSON"))
|
||||
}
|
||||
return RuleItem{ID: row.ID, Name: row.Name, Content: raw, IsCurrent: current, CreatedAt: row.CreatedAt.UTC().Format("2006-01-02T15:04:05.999999999Z07:00"), UpdatedAt: row.UpdatedAt.UTC().Format("2006-01-02T15:04:05.999999999Z07:00")}, nil
|
||||
}
|
||||
func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} }
|
||||
func invalidRule(m string) error { return &ServiceError{Code: CodeRuleInvalid, Message: m} }
|
||||
func notFound() error { return &ServiceError{Code: CodeRuleNotFound, Message: "采购规则不存在"} }
|
||||
func currentMissing(m string) error { return &ServiceError{Code: CodeCurrentRuleMissing, Message: m} }
|
||||
func conflict(m string) error { return &ServiceError{Code: CodeCurrentRuleConflict, Message: m} }
|
||||
func internal(err error) error {
|
||||
return &ServiceError{Code: CodeInternal, Message: "服务端处理失败", Retryable: true, Cause: err}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package purchaserule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func purchaseRuleTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migrations.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCurrentRuleRequiresConfiguredValidRule(t *testing.T) {
|
||||
db := purchaseRuleTestDB(t)
|
||||
_, _, _, err := CurrentRule(context.Background(), db, models.PurchaseExecutionModeLive)
|
||||
var serviceErr *ServiceError
|
||||
if !errors.As(err, &serviceErr) || serviceErr.Code != CodeCurrentRuleMissing {
|
||||
t.Fatalf("missing current rule error=%v", err)
|
||||
}
|
||||
record := models.PurchaseRule{Name: "损坏规则", ContentJSON: `{}`}
|
||||
if err = db.Create(&record).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&models.PurchaseRuleSetting{ID: 1, RuleID: record.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _, err = CurrentRule(context.Background(), db, models.PurchaseExecutionModeLive)
|
||||
if !errors.As(err, &serviceErr) || serviceErr.Code != CodeCurrentRuleMissing {
|
||||
t.Fatalf("invalid current rule error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndSwitchCurrentRule(t *testing.T) {
|
||||
db := purchaseRuleTestDB(t)
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), SaveRequest{RequestID: uuid.NewString(), Name: "正式规则", Content: purchasecontract.DefaultLiveRule()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, replayed, err := service.SetCurrent(context.Background(), SetCurrentRequest{RequestID: uuid.NewString(), RuleID: created.Rule.ID})
|
||||
if err != nil || replayed || !current.IsCurrent {
|
||||
t.Fatalf("set current: %+v replayed=%v err=%v", current, replayed, err)
|
||||
}
|
||||
_, raw, _, err := CurrentRule(context.Background(), db, models.PurchaseExecutionModeLive)
|
||||
if err != nil || string(raw) != string(purchasecontract.DefaultLiveRule()) {
|
||||
t.Fatalf("current bytes=%s err=%v", raw, err)
|
||||
}
|
||||
if _, err = service.Delete(context.Background(), created.Rule.ID, DeleteRequest{RequestID: uuid.NewString()}); err == nil {
|
||||
t.Fatal("current rule deletion must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migratePurchaseRules)
|
||||
}
|
||||
|
||||
func migratePurchaseRules(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := migrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
var setting models.PurchaseRuleSetting
|
||||
if err := tx.First(&setting, 1).Error; err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return err
|
||||
}
|
||||
record := models.PurchaseRule{Name: "默认 PDD 正式采购规则", ContentJSON: string(purchasecontract.DefaultLiveRule())}
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
setting = models.PurchaseRuleSetting{ID: 1, RuleID: record.ID}
|
||||
if err := tx.Create(&setting).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := ensureGoAutoMenusAndPurchaserBindings(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureGoAutoMenuGroups(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
goautomodels "go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMigratePurchaseRulesSeedsExactDefaultAndMenu(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:purchase-rules?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}, &migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &purchaserCasbinRule{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migratePurchaseRules(db, "1787983600000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var setting goautomodels.PurchaseRuleSetting
|
||||
if err = db.Preload("Rule").First(&setting, 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if setting.Rule.ContentJSON != string(purchasecontract.DefaultLiveRule()) {
|
||||
t.Fatal("seeded purchase rule does not equal DefaultLiveRule bytes")
|
||||
}
|
||||
var menu migrationmodels.SysMenu
|
||||
if err = db.Where("menu_name = ?", "GoAutoPurchaseRules").First(&menu).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parent migrationmodels.SysMenu
|
||||
if err = db.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if menu.ParentId != parent.MenuId {
|
||||
t.Fatalf("purchase rule menu parent=%d want=%d", menu.ParentId, parent.MenuId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listPurchaseRules(params) { return request({ url: '/api/admin/v1/purchase-rules', method: 'get', params }) }
|
||||
export function createPurchaseRule(data) { return request({ url: '/api/admin/v1/purchase-rules', method: 'post', data }) }
|
||||
export function updatePurchaseRule(ruleId, data) { return request({ url: `/api/admin/v1/purchase-rules/${ruleId}`, method: 'patch', data }) }
|
||||
export function deletePurchaseRule(ruleId, data) { return request({ url: `/api/admin/v1/purchase-rules/${ruleId}`, method: 'delete', data }) }
|
||||
export function setCurrentPurchaseRule(data) { return request({ url: '/api/admin/v1/purchase-rules/current', method: 'put', data }) }
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<BasicLayout><template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<div class="page-heading">
|
||||
<div><h1>采购规则</h1><p>维护 PDD 采购执行规则。只有标记为“当前”的规则用于新任务,已有任务继续使用自己的规则快照。</p></div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">新建采购规则</el-button>
|
||||
</div>
|
||||
<el-alert type="warning" :closable="false" show-icon title="规则缺失或校验失败时,新建和重试采购任务会被明确阻断,不会回退到内置规则。" />
|
||||
<el-form :model="query" inline class="search-form" @submit.prevent="load">
|
||||
<el-form-item label="规则名称"><el-input v-model="query.name" clearable placeholder="输入规则名称" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table v-loading="loading" :data="items" border stripe empty-text="暂无采购规则">
|
||||
<el-table-column label="ID" prop="id" width="80" />
|
||||
<el-table-column label="规则名称" prop="name" min-width="200" />
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前规则</el-tag><span v-else>历史版本</span></template></el-table-column>
|
||||
<el-table-column label="规则摘要" min-width="360"><template #default="{ row }">{{ summary(row.content) }}</template></el-table-column>
|
||||
<el-table-column label="更新时间" prop="updatedAt" width="190" />
|
||||
<el-table-column label="操作" width="260" fixed="right"><template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="success" :disabled="row.isCurrent || switching === row.id" :loading="switching === row.id" @click="setCurrent(row)">设为当前</el-button>
|
||||
<el-button link type="danger" :disabled="row.isCurrent" @click="remove(row)">删除</el-button>
|
||||
</template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:page="query.page" v-model:limit="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
<el-dialog v-model="dialog.open" :title="dialog.ruleId ? '编辑采购规则' : '新建采购规则'" width="760px" destroy-on-close>
|
||||
<el-form ref="form" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="规则名称" prop="name"><el-input v-model="form.name" maxlength="120" show-word-limit /></el-form-item>
|
||||
<el-form-item label="规则 JSON" prop="contentText"><el-input v-model="form.contentText" type="textarea" :rows="18" class="json-editor" spellcheck="false" /><div class="field-help">仅支持 schemaVersion=1、ruleType=pddPurchase;服务端会再次执行严格字段和安全动作校验。</div></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button :disabled="dialog.saving" @click="dialog.open=false">取消</el-button><el-button type="primary" :loading="dialog.saving" @click="save">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</template></BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { createPurchaseRule, deletePurchaseRule, listPurchaseRules, setCurrentPurchaseRule, updatePurchaseRule } from '@/api/goauto/purchase-rules'
|
||||
|
||||
const defaultRule = { schemaVersion: 1, ruleType: 'pddPurchase', requiredCapabilities: ['purchase.live.v1'], actions: [{ type: 'openProduct' }, { type: 'verifyProduct' }, { type: 'openSpecPanel' }, { type: 'selectSpec' }, { type: 'setQuantity' }, { type: 'verifyUnitPrice' }, { type: 'verifyOrderSummary' }] }
|
||||
export default {
|
||||
name: 'GoAutoPurchaseRules',
|
||||
setup() { return { Plus, RefreshLeft, Search } },
|
||||
data() {
|
||||
const jsonRule = (_r, value, callback) => { try { const parsed = JSON.parse(value); if (!parsed || Array.isArray(parsed) || parsed.schemaVersion !== 1 || parsed.ruleType !== 'pddPurchase') return callback(new Error('请输入有效的 PDD 采购规则 JSON')); callback() } catch (_) { callback(new Error('请输入有效 JSON')) } }
|
||||
return { loading: false, switching: null, items: [], total: 0, query: { page: 1, pageSize: 20, name: '' }, dialog: { open: false, saving: false, ruleId: null }, form: { name: '', contentText: '' }, rules: { name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }], contentText: [{ required: true, validator: jsonRule, trigger: 'blur' }] }}
|
||||
},
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
async load() { this.loading = true; try { const response = await listPurchaseRules(this.query); this.items = response.data.items; this.total = response.data.total } finally { this.loading = false } },
|
||||
search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, name: '' }; this.load() },
|
||||
summary(content) { return `${content.requiredCapabilities?.length || 0} 项能力 · ${content.actions?.length || 0} 个动作` },
|
||||
openCreate() { this.dialog = { open: true, saving: false, ruleId: null }; this.form = { name: '', contentText: JSON.stringify(defaultRule, null, 2) } },
|
||||
openEdit(row) { this.dialog = { open: true, saving: false, ruleId: row.id }; this.form = { name: row.name, contentText: JSON.stringify(row.content, null, 2) } },
|
||||
async save() { const valid = await this.$refs.form.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content: JSON.parse(this.form.contentText) }; try { if (this.dialog.ruleId) await updatePurchaseRule(this.dialog.ruleId, payload); else await createPurchaseRule(payload); ElMessage.success('采购规则已保存'); this.dialog.open = false; await this.load() } finally { this.dialog.saving = false } },
|
||||
async setCurrent(row) { await ElMessageBox.confirm(`设为当前后,新建及安全重试的采购任务将使用“${row.name}”。`, '切换当前采购规则', { type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }); this.switching = row.id; try { await setCurrentPurchaseRule({ requestId: crypto.randomUUID(), ruleId: row.id }); ElMessage.success('当前采购规则已切换'); await this.load() } finally { this.switching = null } },
|
||||
async remove(row) { await ElMessageBox.confirm(`确定删除“${row.name}”吗?已有任务快照不受影响。`, '删除采购规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deletePurchaseRule(row.id, { requestId: crypto.randomUUID() }); ElMessage.success('采购规则已删除'); await this.load() }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-card{min-height:calc(100vh - 124px)}.page-heading{display:flex;justify-content:space-between;gap:16px;margin-bottom:18px}.page-heading h1{margin:0 0 6px;font-size:24px;color:#1f2937}.page-heading p,.field-help{margin:0;color:#64748b;line-height:1.5}.search-form{margin:18px 0 16px;padding:16px 16px 0;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}:deep(.json-editor textarea){font-family:"Cascadia Code",Consolas,monospace;line-height:1.55}.field-help{margin-top:6px;font-size:13px}@media(max-width:768px){.page-heading{flex-direction:column}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user