feat(goauto): parameterize purchase price guard (#146)
This commit is contained in:
@@ -97,7 +97,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba
|
||||
items := make([]BatchPreviewItem, 0, len(ids))
|
||||
eligible := 0
|
||||
for _, id := range ids {
|
||||
item := s.previewFromDataset(ctx, id, dataset, false)
|
||||
item := s.previewFromDataset(ctx, id, dataset, false, purchasecontract.EffectivePriceGuard(currentRule))
|
||||
item.applyProcessStage(processStageFromDataset(id, dataset, item))
|
||||
if item.Eligible && deviceErr != nil {
|
||||
item.Eligible = false
|
||||
@@ -227,7 +227,7 @@ func (s *Service) BatchCreate(ctx context.Context, req BatchCreateRequest) (Batc
|
||||
return BatchCreateResponse{}, internal(err)
|
||||
}
|
||||
|
||||
preview := s.previewOneDeterministic(ctx, id)
|
||||
preview := s.previewOneDeterministic(ctx, id, purchasecontract.EffectivePriceGuard(currentRule))
|
||||
if preview.Eligible && deviceErr != nil {
|
||||
preview.Eligible = false
|
||||
preview.ReasonCode, preview.Reason, preview.NextAction = serviceErrorFields(deviceErr)
|
||||
@@ -290,23 +290,29 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rul
|
||||
}
|
||||
|
||||
func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
_, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if ruleErr != nil {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(ruleErr)
|
||||
return item
|
||||
}
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
|
||||
if err != nil {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
|
||||
return item
|
||||
}
|
||||
return s.previewFromDataset(ctx, id, dataset, true)
|
||||
return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule))
|
||||
}
|
||||
|
||||
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64) BatchPreviewItem {
|
||||
func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceGuard) BatchPreviewItem {
|
||||
dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id})
|
||||
if err != nil {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry"
|
||||
return item
|
||||
}
|
||||
return s.previewFromDataset(ctx, id, dataset, false)
|
||||
return s.previewFromDataset(ctx, id, dataset, false, guard)
|
||||
}
|
||||
|
||||
func replayBatchPreview(task models.PurchaseTask) BatchPreviewItem {
|
||||
@@ -329,7 +335,7 @@ func taskPointerValue(value *uint64) uint64 {
|
||||
return *value
|
||||
}
|
||||
|
||||
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool) BatchPreviewItem {
|
||||
func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceGuard) BatchPreviewItem {
|
||||
item := BatchPreviewItem{SYBProductID: id}
|
||||
syb, found := dataset.sybByID[id]
|
||||
if !found {
|
||||
@@ -388,7 +394,7 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
}
|
||||
}
|
||||
}
|
||||
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor)
|
||||
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
|
||||
if err != nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd"
|
||||
return item
|
||||
@@ -417,7 +423,7 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat
|
||||
return item
|
||||
}
|
||||
|
||||
func purchasePriceRange(raw, mappedColor string) (reference, minPrice, maxPrice int64, err error) {
|
||||
func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceGuard) (reference, minPrice, maxPrice int64, err error) {
|
||||
var dimensions []product.SpecDimension
|
||||
if json.Unmarshal([]byte(raw), &dimensions) != nil {
|
||||
return 0, 0, 0, errors.New("拼多多商品规格数据无效,请先重新采集")
|
||||
@@ -444,7 +450,8 @@ func purchasePriceRange(raw, mappedColor string) (reference, minPrice, maxPrice
|
||||
}
|
||||
sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
|
||||
low, high := prices[0], prices[len(prices)-1]
|
||||
return high, low / 5, (high*3 + 1) / 2, nil
|
||||
minRatio, maxRatio := int64(guard.MinRatio), int64(guard.MaxRatio)
|
||||
return high, low * minRatio / purchasecontract.RatioScale, (high*maxRatio + purchasecontract.RatioScale - 1) / purchasecontract.RatioScale, nil
|
||||
}
|
||||
|
||||
func batchItemRequestID(batchRequestID string, sybProductID uint64) string {
|
||||
|
||||
@@ -43,6 +43,20 @@ func TestBatchPreviewUsesPDDPriceAndExplainsIneligibleRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchasePriceRangeUsesExactConfiguredRatiosAndLegacyDefaults(t *testing.T) {
|
||||
raw := `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1001},{"name":"白色","selectable":true,"priceCent":1003}]}]`
|
||||
legacy := purchasecontract.PriceGuard{MinRatio: purchasecontract.Ratio(purchasecontract.DefaultMinRatio), MaxRatio: purchasecontract.Ratio(purchasecontract.DefaultMaxRatio)}
|
||||
ref, minPrice, maxPrice, err := purchasePriceRange(raw, "", legacy)
|
||||
if err != nil || ref != 1003 || minPrice != 200 || maxPrice != 1505 {
|
||||
t.Fatalf("legacy range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
|
||||
}
|
||||
custom := purchasecontract.PriceGuard{MinRatio: purchasecontract.Ratio(3333), MaxRatio: purchasecontract.Ratio(22500)}
|
||||
ref, minPrice, maxPrice, err = purchasePriceRange(raw, "", custom)
|
||||
if err != nil || ref != 1003 || minPrice != 333 || maxPrice != 2257 {
|
||||
t.Fatalf("custom range=%d,%d,%d err=%v", ref, minPrice, maxPrice, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchPreviewUsesLinkActionBeforePDDMapping(t *testing.T) {
|
||||
db := testDB(t)
|
||||
fixture := seed(t, db, liveCaps(), true)
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/purchasecontract"
|
||||
"go-admin/app/goauto/purchaserule"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -61,8 +63,12 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
preview := s.previewFromDataset(ctx, id, dataset, false)
|
||||
preview := s.previewFromDataset(ctx, id, dataset, false, purchasecontract.EffectivePriceGuard(currentRule))
|
||||
result[id] = processStageFromDataset(id, dataset, preview)
|
||||
}
|
||||
return result, nil
|
||||
|
||||
@@ -8,6 +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"
|
||||
|
||||
@@ -254,19 +255,25 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P
|
||||
if err != nil {
|
||||
return retryDecision{ReasonCode: CodeInternal, Reason: "服务端处理失败"}
|
||||
}
|
||||
_, _, currentRule, err := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive)
|
||||
if err != nil {
|
||||
code, message, _ := serviceErrorFields(err)
|
||||
return retryDecision{ReasonCode: code, Reason: message}
|
||||
}
|
||||
guard := purchasecontract.EffectivePriceGuard(currentRule)
|
||||
syb, found := dataset.sybByID[*task.SYBProductID]
|
||||
if !found || syb.ShopeeProductID == nil {
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
shopee, found := dataset.shopeeByID[*syb.ShopeeProductID]
|
||||
if !found || shopee.PDDProductID == nil {
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
pdd, found := dataset.pddByID[*shopee.PDDProductID]
|
||||
if !found {
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize)
|
||||
@@ -274,7 +281,7 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P
|
||||
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) {
|
||||
return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"}
|
||||
}
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false)
|
||||
preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard)
|
||||
if !preview.Eligible {
|
||||
return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -72,10 +73,50 @@ type SwipePlan struct {
|
||||
}
|
||||
|
||||
type RuleSnapshot struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
RuleType string `json:"ruleType"`
|
||||
RequiredCapabilities []string `json:"requiredCapabilities"`
|
||||
Actions []Action `json:"actions"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
RuleType string `json:"ruleType"`
|
||||
RequiredCapabilities []string `json:"requiredCapabilities"`
|
||||
Actions []Action `json:"actions"`
|
||||
PriceGuard *PriceGuard `json:"priceGuard,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
RatioScale = int64(10_000)
|
||||
DefaultMinRatio = int64(2_000)
|
||||
DefaultMaxRatio = int64(15_000)
|
||||
)
|
||||
|
||||
// PriceGuard uses fixed basis points internally so cent calculations never
|
||||
// depend on binary floating-point rounding. JSON remains a regular decimal.
|
||||
type PriceGuard struct {
|
||||
MinRatio Ratio `json:"minRatio"`
|
||||
MaxRatio Ratio `json:"maxRatio"`
|
||||
}
|
||||
|
||||
type Ratio int64
|
||||
|
||||
func (r *Ratio) UnmarshalJSON(raw []byte) error {
|
||||
var value float64
|
||||
if err := json.Unmarshal(raw, &value); err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return errors.New("价格比例必须是数字")
|
||||
}
|
||||
scaled := value * float64(RatioScale)
|
||||
if math.Abs(scaled-math.Round(scaled)) > 1e-7 {
|
||||
return errors.New("价格比例最多支持 4 位小数")
|
||||
}
|
||||
*r = Ratio(math.Round(scaled))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r Ratio) MarshalJSON() ([]byte, error) {
|
||||
return []byte(strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", float64(r)/float64(RatioScale)), "0"), ".")), nil
|
||||
}
|
||||
|
||||
func EffectivePriceGuard(rule RuleSnapshot) PriceGuard {
|
||||
if rule.PriceGuard == nil {
|
||||
return PriceGuard{MinRatio: Ratio(DefaultMinRatio), MaxRatio: Ratio(DefaultMaxRatio)}
|
||||
}
|
||||
return *rule.PriceGuard
|
||||
}
|
||||
|
||||
func Validate(raw []byte, executionMode string) (RuleSnapshot, error) {
|
||||
@@ -97,6 +138,18 @@ func Validate(raw []byte, executionMode string) (RuleSnapshot, error) {
|
||||
if len(rule.Actions) == 0 || len(rule.Actions) > 64 {
|
||||
return rule, errors.New("actions 必须包含 1..64 个类型化动作")
|
||||
}
|
||||
if rule.PriceGuard != nil {
|
||||
minRatio, maxRatio := int64(rule.PriceGuard.MinRatio), int64(rule.PriceGuard.MaxRatio)
|
||||
if minRatio < 1_000 || minRatio > 10_000 {
|
||||
return rule, errors.New("priceGuard.minRatio 必须在 0.1..1.0 之间")
|
||||
}
|
||||
if maxRatio < 10_000 || maxRatio > 30_000 {
|
||||
return rule, errors.New("priceGuard.maxRatio 必须在 1.0..3.0 之间")
|
||||
}
|
||||
if minRatio > maxRatio {
|
||||
return rule, errors.New("priceGuard.minRatio 不能大于 maxRatio")
|
||||
}
|
||||
}
|
||||
capabilities := make(map[string]bool, len(rule.RequiredCapabilities))
|
||||
for _, capability := range rule.RequiredCapabilities {
|
||||
if !capabilityPattern.MatchString(capability) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package purchasecontract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -33,6 +34,38 @@ func TestTypeOnlyRuleRemainsCompatible(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceGuardDefaultsAndExactDecimals(t *testing.T) {
|
||||
legacy, err := Validate(DefaultLiveRule(), "live")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if guard := EffectivePriceGuard(legacy); int64(guard.MinRatio) != 2000 || int64(guard.MaxRatio) != 15000 {
|
||||
t.Fatalf("legacy defaults=%+v", guard)
|
||||
}
|
||||
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":{"minRatio":0.3333,"maxRatio":2.25},"actions":[{"type":"openProduct"}]}`)
|
||||
rule, err := Validate(raw, "live")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guard := EffectivePriceGuard(rule)
|
||||
if int64(guard.MinRatio) != 3333 || int64(guard.MaxRatio) != 22500 {
|
||||
t.Fatalf("fixed ratios=%+v", guard)
|
||||
}
|
||||
encoded, err := json.Marshal(rule)
|
||||
if err != nil || !strings.Contains(string(encoded), `"minRatio":0.3333`) || !strings.Contains(string(encoded), `"maxRatio":2.25`) {
|
||||
t.Fatalf("encoded=%s err=%v", encoded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceGuardRejectsUnsafeBounds(t *testing.T) {
|
||||
for _, guard := range []string{`{"minRatio":0.09,"maxRatio":1.5}`, `{"minRatio":0.2,"maxRatio":3.01}`, `{"minRatio":1,"maxRatio":0.9}`, `{"minRatio":0.12345,"maxRatio":1.5}`} {
|
||||
raw := []byte(`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1"],"priceGuard":` + guard + `,"actions":[{"type":"openProduct"}]}`)
|
||||
if _, err := Validate(raw, "live"); err == nil {
|
||||
t.Fatalf("accepted price guard %s", guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParameterizedRuleRejectsUnknownFields(t *testing.T) {
|
||||
tests := []string{
|
||||
`{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","selector":{"text":"打开"}}]}`,
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
<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>
|
||||
<div class="ratio-grid">
|
||||
<el-form-item label="最低价比例"><el-input-number v-model="form.minRatio" :min="0.1" :max="1" :step="0.05" :precision="4" controls-position="right" /><div class="field-help">范围 0.1~1.0,默认 0.2;最低价向下取整到分。</div></el-form-item>
|
||||
<el-form-item label="最高价比例"><el-input-number v-model="form.maxRatio" :min="1" :max="3" :step="0.05" :precision="4" controls-position="right" /><div class="field-help">范围 1.0~3.0,默认 1.5;最高价向上取整到分。</div></el-form-item>
|
||||
</div>
|
||||
<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>
|
||||
@@ -39,22 +43,22 @@ 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' }] }
|
||||
const defaultRule = { schemaVersion: 1, ruleType: 'pddPurchase', requiredCapabilities: ['purchase.live.v1'], priceGuard: { minRatio: 0.2, maxRatio: 1.5 }, 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' }] }}
|
||||
return { loading: false, switching: null, items: [], total: 0, query: { page: 1, pageSize: 20, name: '' }, dialog: { open: false, saving: false, ruleId: null }, form: { name: '', minRatio: 0.2, maxRatio: 1.5, 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 } },
|
||||
summary(content) { const guard = content.priceGuard || { minRatio: 0.2, maxRatio: 1.5 }; return `${content.requiredCapabilities?.length || 0} 项能力 · ${content.actions?.length || 0} 个动作 · 价格 ${guard.minRatio}~${guard.maxRatio} 倍` },
|
||||
openCreate() { this.dialog = { open: true, saving: false, ruleId: null }; this.form = { name: '', minRatio: 0.2, maxRatio: 1.5, contentText: JSON.stringify(defaultRule, null, 2) } },
|
||||
openEdit(row) { const guard = row.content.priceGuard || { minRatio: 0.2, maxRatio: 1.5 }; this.dialog = { open: true, saving: false, ruleId: row.id }; this.form = { name: row.name, minRatio: guard.minRatio, maxRatio: guard.maxRatio, contentText: JSON.stringify(row.content, null, 2) } },
|
||||
async save() { const valid = await this.$refs.form.validate().catch(() => false); if (!valid) return; if (this.form.minRatio > this.form.maxRatio) { ElMessage.error('最低价比例不能大于最高价比例'); return } this.dialog.saving = true; const content = JSON.parse(this.form.contentText); content.priceGuard = { minRatio: this.form.minRatio, maxRatio: this.form.maxRatio }; const payload = { requestId: crypto.randomUUID(), name: this.form.name.trim(), content }; 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() }
|
||||
}
|
||||
@@ -62,5 +66,5 @@ export default {
|
||||
</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}}
|
||||
.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}.ratio-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.ratio-grid :deep(.el-input-number){width:100%}: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}.ratio-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user