fix: 采购价格上限按数量计算总价 (#181)
This commit is contained in:
@@ -162,7 +162,7 @@ func taskPayload(t *model.Task) gin.H {
|
||||
payload["options"] = options
|
||||
}
|
||||
}
|
||||
// 采购任务必须带数量和价格上限,这是 Client 的价格保护
|
||||
// 采购任务必须带数量和订单总价上限,这是 Client 的价格保护。
|
||||
if t.Quantity > 0 {
|
||||
payload["quantity"] = t.Quantity
|
||||
}
|
||||
|
||||
@@ -663,13 +663,13 @@ func (h *Handler) SybMatch(c *gin.Context) {
|
||||
// 2. 已采集成功(pdd_data 非空)
|
||||
// 3. 已有商品规格映射
|
||||
// 4. 数量 > 0
|
||||
// 5. **价格上限已填且 > 0**(Client 的价格保护,没有它会拒绝执行)
|
||||
// 5. **单价上限已填且 > 0**,后端按最新数量计算订单总价上限
|
||||
// 6. 已选择分配的客户端
|
||||
//
|
||||
// 校验不过的**不要静默跳过**,要列出来告诉操作员缺什么。
|
||||
func (h *Handler) SybCreateTask(c *gin.Context) {
|
||||
ids := c.PostFormArray("ids")
|
||||
prices := c.PostFormArray("max_price_yuan")
|
||||
unitPrices := c.PostFormArray("unit_price_limit_yuan")
|
||||
mappingKeys := c.PostFormArray("mapping_option_key")
|
||||
contextVersions := c.PostFormArray("context_version")
|
||||
if len(ids) == 0 {
|
||||
@@ -679,8 +679,8 @@ func (h *Handler) SybCreateTask(c *gin.Context) {
|
||||
requests := make([]service.PurchaseTaskRequest, 0, len(ids))
|
||||
preFailures := make([]service.TaskCreateError, 0)
|
||||
for i, sybID := range ids {
|
||||
if i >= len(prices) {
|
||||
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: "人民币价格上限不能为空"})
|
||||
if i >= len(unitPrices) {
|
||||
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: "人民币单价上限不能为空"})
|
||||
continue
|
||||
}
|
||||
if i >= len(mappingKeys) || i >= len(contextVersions) ||
|
||||
@@ -688,13 +688,13 @@ func (h *Handler) SybCreateTask(c *gin.Context) {
|
||||
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: "规格确认信息已失效,请刷新页面后重试"})
|
||||
continue
|
||||
}
|
||||
cent, err := service.ParsePriceYuanToCent(prices[i])
|
||||
cent, err := service.ParsePriceYuanToCent(unitPrices[i])
|
||||
if err != nil {
|
||||
preFailures = append(preFailures, service.TaskCreateError{SybID: sybID, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
requests = append(requests, service.PurchaseTaskRequest{
|
||||
SybID: sybID, MaxPriceCent: cent,
|
||||
SybID: sybID, UnitPriceLimitCent: cent,
|
||||
MappingOptionKey: strings.TrimSpace(mappingKeys[i]),
|
||||
ContextVersion: strings.TrimSpace(contextVersions[i]),
|
||||
})
|
||||
|
||||
+6
-2
@@ -96,14 +96,18 @@ func TestPurchaseModal_未选规格行保持隐藏且不提交(t *testing.T) {
|
||||
".purchase-confirm-row[hidden] { display: none; }",
|
||||
},
|
||||
"templates/syb/list.html": {
|
||||
`class="purchase-confirm-row" data-purchase-row="{{.SybID}}" hidden`,
|
||||
`class="purchase-confirm-row" data-purchase-row="{{.SybID}}" data-purchase-quantity="{{.Quantity}}" hidden`,
|
||||
`name="mapping_option_key" value="{{.MappingOptionKey}}" disabled`,
|
||||
`name="context_version" value="{{.ContextVersion}}" disabled`,
|
||||
`name="max_price_yuan" min="0.01" step="0.01" value="{{.DefaultMaxPrice}}" required disabled`,
|
||||
`data-purchase-quantity="{{.Quantity}}"`,
|
||||
`name="unit_price_limit_yuan"`,
|
||||
`value="{{.DefaultUnitPrice}}" required disabled`,
|
||||
`data-total-price-output aria-live="polite"`,
|
||||
},
|
||||
"static/js/app.js": {
|
||||
"row.hidden = !enabled;",
|
||||
"input.disabled = !enabled;",
|
||||
"updateTotalPrice(row);",
|
||||
},
|
||||
}
|
||||
for path, wants := range files {
|
||||
|
||||
@@ -206,7 +206,7 @@ type ShopeeSKU struct {
|
||||
// SybOrder 是一张顺运宝货运单里的**一个商品明细行**(不是一张货运单,
|
||||
// 一张货运单可以有多个商品,各占一行)。
|
||||
//
|
||||
// PriceTwdCent 是**台币分**,跟采购任务的人民币价格上限没有换算关系,
|
||||
// PriceTwdCent 是**台币分**,跟采购任务的人民币订单总价上限没有换算关系,
|
||||
// 不要互相赋值,见 docs/admin/01-requirements.md §7。
|
||||
//
|
||||
// ShopeeSKUID 是 #88 之前的蝦皮规格识别结果。数据库列因只追加迁移保留,
|
||||
@@ -355,7 +355,7 @@ type Task struct {
|
||||
PddGoodsID string
|
||||
PddOptions string // JSON,采购任务的目标规格
|
||||
Quantity int
|
||||
MaxPriceCent int64 // 人民币分
|
||||
MaxPriceCent int64 // 人民币订单总价上限,单位分
|
||||
|
||||
ResultData string
|
||||
ErrorCode string
|
||||
|
||||
@@ -156,9 +156,10 @@ func mappingIsValid(context repository.SybOrderContext) bool {
|
||||
}
|
||||
|
||||
// PurchaseTaskRequest 是一条采购任务的人工确认输入。价格单位固定为人民币分。
|
||||
// UnitPriceLimitCent 是采购员确认的单价上限;订单总价上限必须在事务内按最新数量计算。
|
||||
type PurchaseTaskRequest struct {
|
||||
SybID string
|
||||
MaxPriceCent int64
|
||||
UnitPriceLimitCent int64
|
||||
MappingOptionKey string
|
||||
ContextVersion string
|
||||
}
|
||||
@@ -240,6 +241,11 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu
|
||||
result.Failures = append(result.Failures, TaskCreateError{SybID: request.SybID, Reason: reason})
|
||||
continue
|
||||
}
|
||||
totalPriceLimitCent, err := CalculateOrderPriceLimitCent(request.UnitPriceLimitCent, context.Order.Quantity)
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, TaskCreateError{SybID: request.SybID, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
taskID, err := repository.NextTaskID(tx, model.TaskPurchase)
|
||||
if err != nil {
|
||||
return PurchaseTaskResult{}, err
|
||||
@@ -252,7 +258,7 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu
|
||||
GoodsID: context.Order.ShopeeGoodsID,
|
||||
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
|
||||
PddOptions: optionsJSON, Quantity: context.Order.Quantity,
|
||||
MaxPriceCent: request.MaxPriceCent,
|
||||
MaxPriceCent: totalPriceLimitCent,
|
||||
}); err != nil {
|
||||
return PurchaseTaskResult{}, err
|
||||
}
|
||||
@@ -306,8 +312,8 @@ func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (
|
||||
if context.Order.Quantity <= 0 {
|
||||
return "采购数量必须大于 0", context, "", nil
|
||||
}
|
||||
if request.MaxPriceCent <= 0 {
|
||||
return "人民币价格上限必须大于 0", context, "", nil
|
||||
if request.UnitPriceLimitCent <= 0 {
|
||||
return "人民币单价上限必须大于 0", context, "", nil
|
||||
}
|
||||
active, err := repository.HasActivePurchaseTask(q, request.SybID)
|
||||
if err != nil {
|
||||
@@ -319,28 +325,42 @@ func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (
|
||||
return "", context, choice.OptionsJSON, nil
|
||||
}
|
||||
|
||||
// parsePriceYuanToCent 把网页输入的人民币元精确转成分,不使用浮点数。
|
||||
// CalculateOrderPriceLimitCent 用整数分计算订单总价上限,避免浮点误差和乘法溢出。
|
||||
func CalculateOrderPriceLimitCent(unitPriceLimitCent int64, quantity int) (int64, error) {
|
||||
if unitPriceLimitCent <= 0 {
|
||||
return 0, fmt.Errorf("人民币单价上限必须大于 0")
|
||||
}
|
||||
if quantity <= 0 {
|
||||
return 0, fmt.Errorf("采购数量必须大于 0")
|
||||
}
|
||||
if unitPriceLimitCent > math.MaxInt64/int64(quantity) {
|
||||
return 0, fmt.Errorf("订单总价上限过大")
|
||||
}
|
||||
return unitPriceLimitCent * int64(quantity), nil
|
||||
}
|
||||
|
||||
// ParsePriceYuanToCent 把网页输入的人民币单价上限精确转成分,不使用浮点数。
|
||||
func ParsePriceYuanToCent(raw string) (int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("价格上限不能为空")
|
||||
return 0, fmt.Errorf("人民币单价上限不能为空")
|
||||
}
|
||||
var whole, fraction string
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) > 2 {
|
||||
return 0, fmt.Errorf("价格格式不正确")
|
||||
return 0, fmt.Errorf("单价格式不正确")
|
||||
}
|
||||
whole = parts[0]
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
}
|
||||
if whole == "" || len(fraction) > 2 {
|
||||
return 0, fmt.Errorf("价格最多保留两位小数")
|
||||
return 0, fmt.Errorf("单价最多保留两位小数")
|
||||
}
|
||||
for _, value := range []string{whole, fraction} {
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return 0, fmt.Errorf("价格只能填写数字")
|
||||
return 0, fmt.Errorf("单价只能填写数字")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,17 +371,17 @@ func ParsePriceYuanToCent(raw string) (int64, error) {
|
||||
}
|
||||
var yuan, cent int64
|
||||
if _, err := fmt.Sscan(whole, &yuan); err != nil {
|
||||
return 0, fmt.Errorf("价格格式不正确")
|
||||
return 0, fmt.Errorf("单价格式不正确")
|
||||
}
|
||||
if _, err := fmt.Sscan(fraction, ¢); err != nil {
|
||||
return 0, fmt.Errorf("价格格式不正确")
|
||||
return 0, fmt.Errorf("单价格式不正确")
|
||||
}
|
||||
if yuan > (math.MaxInt64-cent)/100 {
|
||||
return 0, fmt.Errorf("价格上限过大")
|
||||
return 0, fmt.Errorf("人民币单价上限过大")
|
||||
}
|
||||
total := yuan*100 + cent
|
||||
if total <= 0 {
|
||||
return 0, fmt.Errorf("价格上限必须大于 0")
|
||||
return 0, fmt.Errorf("人民币单价上限必须大于 0")
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -143,7 +144,8 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultMaxPrice != "39.90" {
|
||||
if list.Total != 1 || !list.Rows[0].CanPurchase || list.Rows[0].DefaultUnitPrice != "39.90" ||
|
||||
list.Rows[0].DefaultTotalPrice != "39.90" {
|
||||
t.Fatalf("可采购阶段不对: %+v", list)
|
||||
}
|
||||
row := list.Rows[0]
|
||||
@@ -300,6 +302,9 @@ func stringsReplaceGoodsID(raw string) string {
|
||||
func TestCreatePurchaseTasks_安全字段和重复保护(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
key := seedPurchasableWorkflow(t, db, "SYB-1")
|
||||
if _, err := db.Exec(`UPDATE syb_orders SET quantity=2 WHERE syb_id='SYB-1'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveSybMapping(db, "SYB-1", key, "USR-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -309,7 +314,7 @@ func TestCreatePurchaseTasks_安全字段和重复保护(t *testing.T) {
|
||||
}
|
||||
|
||||
result, err := CreatePurchaseTasks(db, admin,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 4200}}, "CLIENT-1")
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-1", UnitPriceLimitCent: 4200}}, "CLIENT-1")
|
||||
if err != nil || result.Created != 1 || len(result.Failures) != 0 {
|
||||
t.Fatalf("创建结果=%+v err=%v", result, err)
|
||||
}
|
||||
@@ -324,18 +329,18 @@ func TestCreatePurchaseTasks_安全字段和重复保护(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taskID != "cg1" || status != "assigned" || task.MaxPriceCent != 4200 || task.MaxPriceCent == 999900 ||
|
||||
task.Quantity != 1 || task.PddOptions != key || task.AssignedClient != "CLIENT-1" {
|
||||
if taskID != "cg1" || status != "assigned" || task.MaxPriceCent != 8400 || task.MaxPriceCent == 999900 ||
|
||||
task.Quantity != 2 || task.PddOptions != key || task.AssignedClient != "CLIENT-1" {
|
||||
t.Fatalf("采购任务字段不安全或不完整: status=%s task=%+v", status, task)
|
||||
}
|
||||
claimed, err := repository.ClaimNextTask(db, "CLIENT-1", []string{"purchase"})
|
||||
if err != nil || claimed == nil || claimed.PddOptions != key ||
|
||||
claimed.Quantity != 1 || claimed.MaxPriceCent != 4200 {
|
||||
claimed.Quantity != 2 || claimed.MaxPriceCent != 8400 {
|
||||
t.Fatalf("现有 Client 领取契约读不到完整采购参数: task=%+v err=%v", claimed, err)
|
||||
}
|
||||
|
||||
second, err := CreatePurchaseTasks(db, admin,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 4300}}, "CLIENT-1")
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-1", UnitPriceLimitCent: 4300}}, "CLIENT-1")
|
||||
if err != nil || second.Created != 0 || len(second.Failures) != 1 {
|
||||
t.Fatalf("重复提交应逐条拒绝: result=%+v err=%v", second, err)
|
||||
}
|
||||
@@ -363,7 +368,7 @@ func TestCreatePurchaseTasks_成功或待核对任务禁止重复采购(t *testi
|
||||
t.Fatal(err)
|
||||
}
|
||||
created, err := CreatePurchaseTasks(db, admin,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-LOCKED", MaxPriceCent: 4200}}, "CLIENT-1")
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-LOCKED", UnitPriceLimitCent: 4200}}, "CLIENT-1")
|
||||
if err != nil || created.Created != 1 {
|
||||
t.Fatalf("准备采购任务失败: result=%+v err=%v", created, err)
|
||||
}
|
||||
@@ -373,7 +378,7 @@ func TestCreatePurchaseTasks_成功或待核对任务禁止重复采购(t *testi
|
||||
|
||||
before := countPurchaseTasksForSyb(t, db, "SYB-LOCKED")
|
||||
result, err := CreatePurchaseTasks(db, admin,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-LOCKED", MaxPriceCent: 4300}}, "CLIENT-1")
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-LOCKED", UnitPriceLimitCent: 4300}}, "CLIENT-1")
|
||||
if err != nil || result.Created != 0 || len(result.Failures) != 1 ||
|
||||
!strings.Contains(result.Failures[0].Reason, tc.wantReason) {
|
||||
t.Fatalf("%s 任务必须阻止重复采购: result=%+v err=%v", tc.status, result, err)
|
||||
@@ -408,11 +413,11 @@ func TestCreatePurchaseTasks_客户端权限和逐条失败(t *testing.T) {
|
||||
AssignClient(db, admin, "CLIENT-A", buyerA.UserID, time.Now())
|
||||
|
||||
if _, err := CreatePurchaseTasks(db, buyerB,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 4200}}, "CLIENT-A"); err == nil {
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-1", UnitPriceLimitCent: 4200}}, "CLIENT-A"); err == nil {
|
||||
t.Fatal("采购员不得使用其他采购员的客户端")
|
||||
}
|
||||
result, err := CreatePurchaseTasks(db, buyerA,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-1", MaxPriceCent: 0}, {SybID: "NOT-FOUND", MaxPriceCent: 100}}, "CLIENT-A")
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-1", UnitPriceLimitCent: 0}, {SybID: "NOT-FOUND", UnitPriceLimitCent: 100}}, "CLIENT-A")
|
||||
if err != nil || result.Created != 0 || len(result.Failures) != 2 {
|
||||
t.Fatalf("业务失败应逐条返回且不创建: result=%+v err=%v", result, err)
|
||||
}
|
||||
@@ -425,8 +430,8 @@ func TestCreatePurchaseTasks_部分业务失败不影响有效明细(t *testing.
|
||||
admin, _, _ := prepareClientAssignmentUsers(t, db)
|
||||
RegisterClient(db, model.Client{ClientID: "CLIENT-1"}, true)
|
||||
result, err := CreatePurchaseTasks(db, admin, []PurchaseTaskRequest{
|
||||
{SybID: "NOT-FOUND", MaxPriceCent: 100},
|
||||
{SybID: "SYB-1", MaxPriceCent: 4200},
|
||||
{SybID: "NOT-FOUND", UnitPriceLimitCent: 100},
|
||||
{SybID: "SYB-1", UnitPriceLimitCent: 4200},
|
||||
}, "CLIENT-1")
|
||||
if err != nil || result.Created != 1 || len(result.Failures) != 1 || result.Failures[0].SybID != "NOT-FOUND" {
|
||||
t.Fatalf("部分失败边界不对: result=%+v err=%v", result, err)
|
||||
@@ -446,7 +451,7 @@ func TestCreatePurchaseTasksWithOptions_正常采购员可创建真实任务(t *
|
||||
}
|
||||
|
||||
result, err := CreatePurchaseTasksWithOptions(db, buyer,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-LIVE", MaxPriceCent: 4200}},
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-LIVE", UnitPriceLimitCent: 4200}},
|
||||
PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive})
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("正常采购员创建真实任务失败: result=%+v err=%v", result, err)
|
||||
@@ -470,7 +475,7 @@ func TestCreatePurchaseTasksWithOptions_真实模式不要求Client预先声明L
|
||||
SaveSybMapping(db, "SYB-GATE", key, "USR-1")
|
||||
admin, _, _ := prepareClientAssignmentUsers(t, db)
|
||||
RegisterClient(db, model.Client{ClientID: "CLIENT-DRY", Capabilities: `{"purchase_mode":"dry_run"}`}, true)
|
||||
request := []PurchaseTaskRequest{{SybID: "SYB-GATE", MaxPriceCent: 4200}}
|
||||
request := []PurchaseTaskRequest{{SybID: "SYB-GATE", UnitPriceLimitCent: 4200}}
|
||||
|
||||
result, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{
|
||||
ClientID: "CLIENT-DRY", ExecutionMode: model.TaskExecutionLive,
|
||||
@@ -496,7 +501,7 @@ func TestCreatePurchaseTasksWithOptions_离线Live客户端可提前指派(t *te
|
||||
}
|
||||
|
||||
result, err := CreatePurchaseTasksWithOptions(db, admin,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-OFFLINE", MaxPriceCent: 4200}},
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-OFFLINE", UnitPriceLimitCent: 4200}},
|
||||
PurchaseTaskOptions{ClientID: "CLIENT-OFFLINE", ExecutionMode: model.TaskExecutionLive})
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("离线 live 客户端应可提前指派: result=%+v err=%v", result, err)
|
||||
@@ -518,7 +523,7 @@ func TestCreatePurchaseTasksWithOptions_拒绝弹窗打开后变化的规格映
|
||||
RegisterClient(db, model.Client{ClientID: "CLIENT-LIVE", Capabilities: `{"purchase_mode":"live"}`}, true)
|
||||
|
||||
result, err := CreatePurchaseTasksWithOptions(db, admin,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-STALE", MaxPriceCent: 3990,
|
||||
[]PurchaseTaskRequest{{SybID: "SYB-STALE", UnitPriceLimitCent: 3990,
|
||||
MappingOptionKey: blackKey, ContextVersion: oldVersion}},
|
||||
PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive})
|
||||
if err != nil || result.Created != 0 || len(result.Failures) != 1 ||
|
||||
@@ -540,3 +545,18 @@ func TestParsePriceYuanToCent_精确转换(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateOrderPriceLimitCent_按数量计算且拒绝溢出(t *testing.T) {
|
||||
got, err := CalculateOrderPriceLimitCent(1180, 2)
|
||||
if err != nil || got != 2360 {
|
||||
t.Fatalf("1180 × 2 = %d, %v; want 2360", got, err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
unit int64
|
||||
qty int
|
||||
}{{0, 2}, {1180, 0}, {math.MaxInt64, 2}} {
|
||||
if _, err := CalculateOrderPriceLimitCent(tc.unit, tc.qty); err == nil {
|
||||
t.Fatalf("应拒绝 unit=%d qty=%d", tc.unit, tc.qty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -942,7 +942,8 @@ type SybOrderView struct {
|
||||
CanCollect bool
|
||||
CanPurchase bool
|
||||
SelectionHint string
|
||||
DefaultMaxPrice string
|
||||
DefaultUnitPrice string
|
||||
DefaultTotalPrice string
|
||||
MappedPddChoice string
|
||||
MappingOptionKey string
|
||||
ContextVersion string
|
||||
@@ -1171,7 +1172,10 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
|
||||
if err == nil && choice != nil {
|
||||
v.MappedPddChoice = choice.Label
|
||||
if choice.HasPrice {
|
||||
v.DefaultMaxPrice = fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
|
||||
v.DefaultUnitPrice = fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
|
||||
if total, totalErr := CalculateOrderPriceLimitCent(choice.PriceCent, o.Quantity); totalErr == nil {
|
||||
v.DefaultTotalPrice = fmt.Sprintf("%d.%02d", total/100, total%100)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ func buildCollectTarget(r repository.TaskListRow) string {
|
||||
return target
|
||||
}
|
||||
|
||||
// buildPurchaseTarget 采购任务的目标:`<订单号> · <颜色/尺码> · <数量>件 · ≤<价格上限>`。
|
||||
// buildPurchaseTarget 采购任务的目标:`<订单号> · <颜色/尺码> · <数量>件 · ≤<订单总价上限>`。
|
||||
// 每一段都可能缺,缺了就显示占位符,不静默拼出一句不完整的话。
|
||||
func buildPurchaseTarget(r repository.TaskListRow) string {
|
||||
orderNo := r.OrderNo
|
||||
@@ -242,7 +242,7 @@ func quantityText(quantity int) string {
|
||||
return fmt.Sprintf("%d件", quantity)
|
||||
}
|
||||
|
||||
// priceLimitText 把价格上限翻成"≤¥42.00"。
|
||||
// priceLimitText 把订单总价上限翻成"≤¥42.00"。
|
||||
//
|
||||
// `[必须]` 复用 formatPriceCent,不要另写一份价格格式化逻辑(见 #19)。
|
||||
// cent <= 0 时(数据库约束下等价于 NULL)显示占位符,不显示 ≤¥0.00——
|
||||
|
||||
@@ -229,6 +229,32 @@
|
||||
var form = document.querySelector("[data-purchase-form]");
|
||||
if (!openButtons.length || !form) return;
|
||||
var submitButton = form.querySelector("[data-purchase-submit]");
|
||||
|
||||
function updateTotalPrice(row) {
|
||||
var input = row.querySelector("[data-unit-price-input]");
|
||||
var output = row.querySelector("[data-total-price-output]");
|
||||
var quantity = Number(row.getAttribute("data-purchase-quantity"));
|
||||
if (!input || !output || !Number.isInteger(quantity) || quantity <= 0) return;
|
||||
var raw = input.value.trim();
|
||||
var match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(raw);
|
||||
if (!match) {
|
||||
output.textContent = "—";
|
||||
return;
|
||||
}
|
||||
var fraction = (match[2] || "").padEnd(2, "0");
|
||||
var unitCent = Number(match[1]) * 100 + Number(fraction || "0");
|
||||
var totalCent = unitCent * quantity;
|
||||
if (!Number.isSafeInteger(totalCent) || totalCent <= 0) {
|
||||
output.textContent = "—";
|
||||
return;
|
||||
}
|
||||
output.textContent = "¥" + Math.floor(totalCent / 100) + "." + String(totalCent % 100).padStart(2, "0");
|
||||
}
|
||||
|
||||
form.querySelectorAll("[data-purchase-row]").forEach(function (row) {
|
||||
var input = row.querySelector("[data-unit-price-input]");
|
||||
if (input) input.addEventListener("input", function () { updateTotalPrice(row); });
|
||||
});
|
||||
openButtons.forEach(function (openButton) {
|
||||
openButton.addEventListener("click", function () {
|
||||
var selected = {};
|
||||
@@ -247,6 +273,7 @@
|
||||
row.querySelectorAll("input").forEach(function (input) {
|
||||
input.disabled = !enabled;
|
||||
});
|
||||
if (enabled) updateTotalPrice(row);
|
||||
if (enabled) shown += 1;
|
||||
});
|
||||
var empty = form.querySelector("[data-purchase-empty]");
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
|
||||
SybID: "SYB-READY", OrderNo: "ORDER-READY", Title: "可采购商品",
|
||||
ImageURL: "https://example.test/original.jpg", Stage: service.SybStagePurchaseReady,
|
||||
StageText: "可创建采购任务", StageHelp: "规格映射有效", ActionText: "创建采购任务",
|
||||
CanPurchase: true, DefaultMaxPrice: "39.90",
|
||||
CanPurchase: true, Quantity: 2, DefaultUnitPrice: "11.80", DefaultTotalPrice: "23.60",
|
||||
},
|
||||
{
|
||||
SybID: "SYB-TASK", OrderNo: "ORDER-TASK", Title: "已有任务商品",
|
||||
@@ -65,6 +65,11 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
|
||||
`aria-label="查看 可采购商品 的原图"`,
|
||||
`id="image-preview-modal"`,
|
||||
`data-purchase-id="SYB-READY"`,
|
||||
`data-purchase-quantity="2"`,
|
||||
`for="purchase-unit-price-SYB-READY"`,
|
||||
`name="unit_price_limit_yuan"`,
|
||||
`订单总价上限:`,
|
||||
`¥23.60`,
|
||||
`href="/tasks?type=purchase`,
|
||||
`q=ORDER-TASK"`,
|
||||
`q=ORDER-DONE"`,
|
||||
|
||||
@@ -230,18 +230,24 @@
|
||||
</select>
|
||||
{{if eq .PurchaseClientCount 0}}<small class="missing">当前账号没有可见的客户端,请先联系管理员绑定客户端。</small>{{else}}<small>所有当前账号可见的客户端均可指派;未就绪或离线客户端会等待其就绪后领取。</small>{{end}}
|
||||
</div>
|
||||
<p class="hint">价格上限单位是人民币元,默认取已映射 PDD 规格的采集价;不会使用顺运宝的台币售价。</p>
|
||||
<p class="hint">单价上限默认取已映射 PDD 规格的人民币采集价;订单总价上限按单价乘以顺运宝数量自动计算,不使用顺运宝的台币售价。</p>
|
||||
{{range .Rows}}
|
||||
<div class="purchase-confirm-row" data-purchase-row="{{.SybID}}" hidden>
|
||||
<div class="purchase-confirm-row" data-purchase-row="{{.SybID}}" data-purchase-quantity="{{.Quantity}}" hidden>
|
||||
<input type="hidden" name="ids" value="{{.SybID}}" disabled>
|
||||
<input type="hidden" name="mapping_option_key" value="{{.MappingOptionKey}}" disabled>
|
||||
<input type="hidden" name="context_version" value="{{.ContextVersion}}" disabled>
|
||||
<div><strong>{{.OrderNo}}</strong> · {{.Title}} × {{.Quantity}}</div>
|
||||
<div class="hint">顺运宝规格:{{.ProductSpec}}</div>
|
||||
<div><strong>已选 PDD 规格:</strong>{{.MappedPddChoice}}</div>
|
||||
<label>选定颜色的人民币价格上限(元)
|
||||
<input type="number" name="max_price_yuan" min="0.01" step="0.01" value="{{.DefaultMaxPrice}}" required disabled>
|
||||
</label>
|
||||
<label for="purchase-unit-price-{{.SybID}}">选定规格的人民币单价上限(元)</label>
|
||||
<input id="purchase-unit-price-{{.SybID}}" type="number" name="unit_price_limit_yuan"
|
||||
min="0.01" step="0.01" value="{{.DefaultUnitPrice}}" required disabled
|
||||
data-unit-price-input aria-describedby="purchase-price-help-{{.SybID}}">
|
||||
<p class="hint" id="purchase-price-help-{{.SybID}}">
|
||||
数量:{{.Quantity}} 件 · 订单总价上限:
|
||||
<output data-total-price-output aria-live="polite">{{if .DefaultTotalPrice}}¥{{.DefaultTotalPrice}}{{else}}—{{end}}</output>
|
||||
</p>
|
||||
<p class="hint">提交时后端会按最新数量重新计算订单总价上限。</p>
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="missing" data-purchase-empty hidden>没有选择可采购的明细,请关闭后重新勾选。</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
这是一个**片段**,不是整页——外面的弹窗壳子在 task/list.html 里。
|
||||
|
||||
`[必须]` 只读:本工单不做改派、重试、取消,弹窗里不放对应的表单和按钮,
|
||||
见 #19。采购专有字段(目标规格/数量/价格上限)在采集任务这里
|
||||
见 #19。采购专有字段(目标规格/数量/订单总价上限)在采集任务这里
|
||||
**整段隐藏**,不显示空行。 */}}
|
||||
{{with .D}}
|
||||
<div class="modal-head">
|
||||
@@ -33,7 +33,7 @@
|
||||
{{if .IsPurchase}}
|
||||
<dt>目标规格</dt><dd>{{.SpecText}}</dd>
|
||||
<dt>数量</dt><dd>{{.QuantityText}}</dd>
|
||||
<dt>价格上限</dt><dd>{{.PriceLimitText}}</dd>
|
||||
<dt>订单总价上限</dt><dd>{{.PriceLimitText}}</dd>
|
||||
{{end}}
|
||||
</dl>
|
||||
|
||||
|
||||
@@ -478,7 +478,7 @@ class HttpAdminGateway(AdminGateway):
|
||||
"ADMIN_INVALID_RESPONSE",
|
||||
(
|
||||
"Admin 采购任务缺少有效的 goods_id、动态 options、"
|
||||
"quantity 或 max_price_cent"
|
||||
"quantity 或订单总价上限 max_price_cent"
|
||||
),
|
||||
False,
|
||||
request_id,
|
||||
|
||||
@@ -19,6 +19,7 @@ class PurchasePageState:
|
||||
goods_id: str
|
||||
selected_options: Mapping[str, str] = field(default_factory=dict)
|
||||
quantity: int = 0
|
||||
# 当前页面显示的采购金额;最终确认页必须是订单总价,单位人民币分。
|
||||
price_cent: int = 0
|
||||
candidate_count: int = 1
|
||||
in_stock: bool = True
|
||||
|
||||
@@ -427,7 +427,11 @@ def _keyboard_is_shown(device: Any) -> Optional[bool]:
|
||||
|
||||
|
||||
def _price_cent(root: ET.Element) -> int:
|
||||
"""优先读取页面上半部的当前单价,不把划线价当成当前价。"""
|
||||
"""读取页面上半部的当前采购金额,不把划线价当成当前价。
|
||||
|
||||
商品/规格页可能显示单价或小计;最终确认页应显示订单总价。业务层只在
|
||||
最终确认页把该值与订单总价上限比较。
|
||||
"""
|
||||
|
||||
parsed_nodes: list[tuple[int, int, str]] = []
|
||||
screen_bottom = 0
|
||||
|
||||
@@ -37,7 +37,7 @@ class PurchaseTarget:
|
||||
goods_url: str
|
||||
options: Mapping[str, str]
|
||||
quantity: int
|
||||
max_price_cent: int
|
||||
max_price_cent: int # 人民币订单总价上限,单位分
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -386,7 +386,7 @@ class PurchaseTaskService:
|
||||
):
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_TASK_INVALID",
|
||||
"人民币价格上限必须是大于 0 的整数分",
|
||||
"人民币订单总价上限必须是大于 0 的整数分",
|
||||
step="purchase_prepare",
|
||||
)
|
||||
return PurchaseTarget(
|
||||
@@ -450,11 +450,16 @@ class PurchaseTaskService:
|
||||
"无法读取稳定的当前人民币价格,已停止演练",
|
||||
step="purchase_verify_quantity_price",
|
||||
)
|
||||
if state.price_cent > target.max_price_cent:
|
||||
# 商品页或规格面板可能显示单价,也可能随数量显示小计;只有最终确认页
|
||||
# 的金额具有稳定的订单总价语义,因此总价保护只在确认页执行。
|
||||
if (
|
||||
state.page_kind == "order_confirmation"
|
||||
and state.price_cent > target.max_price_cent
|
||||
):
|
||||
raise PddPurchaseError(
|
||||
"PURCHASE_PRICE_EXCEEDED",
|
||||
(
|
||||
f"当前价格 {state.price_cent} 分超过上限 "
|
||||
f"当前订单总价 {state.price_cent} 分超过订单总价上限 "
|
||||
f"{target.max_price_cent} 分,已停止演练"
|
||||
),
|
||||
step="purchase_verify_quantity_price",
|
||||
|
||||
@@ -89,7 +89,7 @@ def admin_task_to_new_claimed_task(task: AdminTask) -> NewClaimedTask:
|
||||
or price_cent <= 0
|
||||
):
|
||||
raise ValueError(
|
||||
"采购任务 payload.max_price_cent 必须是大于 0 的整数分"
|
||||
"采购任务 payload.max_price_cent 必须是大于 0 的人民币订单总价上限(整数分)"
|
||||
)
|
||||
target_color = normalized_options.get("color")
|
||||
target_size = normalized_options.get("size")
|
||||
|
||||
@@ -251,18 +251,19 @@ class PurchaseTaskServiceTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(observed, [("PUR-STARTED", TaskStatus.RUNNING)])
|
||||
|
||||
def test_price_above_limit_stops_before_confirmation(self):
|
||||
def test_order_total_above_limit_stops_on_confirmation(self):
|
||||
self._prepare_task()
|
||||
adapter = RecordingDryRunAdapter(price_cent=5001)
|
||||
|
||||
outcome = self._service(adapter).execute_one_local()
|
||||
|
||||
self.assertEqual(outcome.kind, "task_failed")
|
||||
self.assertNotIn(("enter_confirmation",), adapter.calls)
|
||||
self.assertIn(("enter_confirmation",), adapter.calls)
|
||||
detail = self.repository.get_task("PUR-001")
|
||||
assert detail is not None
|
||||
self.assertEqual(detail.status, TaskStatus.FAILED)
|
||||
self.assertEqual(detail.last_error_code, "PURCHASE_PRICE_EXCEEDED")
|
||||
self.assertIn("订单总价", detail.last_error_message)
|
||||
|
||||
def test_unavailable_goods_stops_before_specs_and_is_not_retryable(self):
|
||||
self._prepare_task()
|
||||
|
||||
@@ -375,7 +375,7 @@ Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin
|
||||
2. 当前 PDD 商品已采集成功(`skus_json` 非空);
|
||||
3. 该顺运宝规格非空,且已有当前 PDD 商品的规格映射;
|
||||
4. 数量大于 0;
|
||||
5. **价格上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改,但不允许为空;
|
||||
5. **人民币单价上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改但不允许为空;页面显示 `单价 × 数量` 的订单总价上限,后端按最新数量重新计算并把总价写入 `max_price_cent`;
|
||||
6. 已选择分配的客户端;
|
||||
7. 所选客户端存在且在当前账号可见范围内;其在线状态和 `purchase_mode` 只影响何时领取,不阻止提前指派;
|
||||
8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务;
|
||||
@@ -454,10 +454,10 @@ Admin 新建采购任务固定使用不可变的 `execution_mode=live`,不向
|
||||
| 场景 | 币种 | 字段示例 |
|
||||
|---|---|---|
|
||||
| 蝦皮售价 | 台币 | `price_twd_cent` |
|
||||
| PDD 采购价、价格上限 | 人民币 | `max_price_cent` |
|
||||
| PDD 采购单价、订单总价上限 | 人民币 | SKU `price_cent`、任务 `max_price_cent` |
|
||||
|
||||
`[必须]` **两种币种不得混用,不得互相换算后覆盖原值。**
|
||||
采购任务里的价格上限是**人民币**,默认来源是采集回来的 PDD 价格,并由采购员逐行确认,
|
||||
采购任务里的单价及订单总价上限是**人民币**:单价默认来源是采集回来的 PDD SKU 价格并由采购员逐行确认,订单总价上限由后端按 `单价上限 × 数量` 计算,
|
||||
和蝦皮的台币售价没有换算关系。
|
||||
|
||||
`[待定]` 是否需要展示汇率或利润,待业务确认。MVP 不做。
|
||||
|
||||
@@ -371,7 +371,7 @@ UPDATE pdd_products
|
||||
| 字段 | Admin 拿它干什么 | 不给会怎样 |
|
||||
|---|---|---|
|
||||
| `skus[].options` | 匹配弹窗列出规格供人选 | 没东西可选,匹配做不了 |
|
||||
| `skus[].price_cent` | 建采购任务时带出 `max_price_cent` | 价格保护填不了,Client 会拒绝执行 |
|
||||
| `skus[].price_cent` | 建采购任务时带出单价上限,再乘采购数量生成 `max_price_cent` | 订单总价保护填不了,Client 会拒绝执行 |
|
||||
| `skus[].available` | 不给缺货规格建任务 | 白跑一趟,Client 到手机上才发现卖光 |
|
||||
| `goods_id` / `title` | 核对"采的是不是要的那个商品" | 链接跳转、采错商品时静默存错 |
|
||||
| `shop_name` | 核对是否来自目标店铺 | 同标题商品无法区分来源;采不到时允许为空 |
|
||||
@@ -429,7 +429,7 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
|
||||
```
|
||||
|
||||
- `price_twd_cent` 是**台币分**,是蝦皮那边的售价,
|
||||
和采购任务的人民币价格上限**没有换算关系**,不要互相赋值。
|
||||
和采购任务的人民币订单总价上限**没有换算关系**,不要互相赋值。
|
||||
- `image_url` 存 URL。`[必须]` 不要把图片二进制存进数据库。
|
||||
- `shop_name` 是顺运宝货运单级事实,同一货运单下的多条商品明细会重复保存同一值;
|
||||
独立列用于列表展示和检索,完整上游值仍保留在 `syb_data.stock.shopName`。
|
||||
@@ -705,7 +705,7 @@ CREATE INDEX idx_tasks_order ON tasks(order_no);
|
||||
2. **采购任务的 `quantity` 和 `max_price_cent` 都必须有值**,
|
||||
这是价格保护,没有它 Client 会拒绝执行。
|
||||
|
||||
`max_price_cent` 是**人民币分**,默认从 `pdd_data` 里对应 SKU 的价格带出,操作员可改但不能清空。
|
||||
`max_price_cent` 是**人民币订单总价上限(整数分)**。页面默认从 `pdd_data` 对应 SKU 带出单价上限,操作员可改但不能清空;后端在事务内按顺运宝最新数量计算 `单价上限 × quantity` 后写入本字段,并检查整数溢出。历史任务不回写。
|
||||
|
||||
状态含义见 [01 需求](01-requirements.md) §6.2。
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ X-Client-Id: client-001
|
||||
—— **指定给本机的优先**;
|
||||
|
||||
网页创建的采购任务固定走第一种:创建前已经校验当前账号对客户端的可见范围,
|
||||
并写入 `pdd_options`、`quantity` 和人民币 `max_price_cent`。这不改变 Client 接口字段。
|
||||
并写入 `pdd_options`、`quantity` 和人民币订单总价上限 `max_price_cent`。总价上限由采购员确认的 SKU 单价上限乘以顺运宝最新数量得到;这不改变 Client 接口字段。
|
||||
3. 没有就返回 `204 No Content`;
|
||||
4. 有就把 `status` 改成 `claimed`、记 `claimed_at`,返回任务。
|
||||
|
||||
@@ -120,7 +120,7 @@ Client 上报的 `capabilities.purchase_mode` 过滤:`dry_run` Client 只能
|
||||
Admin Web 可以把 live 任务预先指派给当前账号可见的任意 Client,不在创建时按该字段
|
||||
拒绝;尚未就绪的指定 Client 不会领取,待其设备与真实采购执行器就绪并自动上报 live 后领取。
|
||||
|
||||
`[必须]` `payload.goods_url` 必须有值;采购任务的 `quantity` 和 `max_price_cent` 必须有值。
|
||||
`[必须]` `payload.goods_url` 必须有值;采购任务的 `quantity` 和 `max_price_cent` 必须有值。`max_price_cent` 固定表示人民币订单总价上限,不得下发单价。
|
||||
这些在建任务时就该校验住(见 [01 需求](01-requirements.md) §5),
|
||||
不要等到这里才发现发不出去。
|
||||
|
||||
@@ -315,7 +315,7 @@ Client 契约里散落的 Admin 侧硬要求,汇总在这里,**可以直接
|
||||
- [ ] `claim` 无任务时返回 `204`,不是 `200` 加空对象
|
||||
- [ ] 响应不含租约、不含 Admin 侧状态
|
||||
- [ ] `payload.goods_url` 必有值
|
||||
- [ ] 采购任务的 `quantity`、`max_price_cent` 必有值,且是人民币分整数
|
||||
- [ ] 采购任务的 `quantity`、`max_price_cent` 必有值,后者是人民币订单总价上限的分整数
|
||||
- [ ] 可以预先指派 live 任务给可见 Client,但领取时不向只声明 `dry_run` 的客户端返回 live 任务
|
||||
- [ ] `result` / `failure` 幂等:同键同内容返回同结果
|
||||
- [ ] 同键不同内容返回 `409`
|
||||
|
||||
@@ -599,7 +599,7 @@ PDD 采集中(超时)行可用于
|
||||
### 6.4 创建采购任务
|
||||
|
||||
只有“可创建采购任务”的行能参与采购批量动作。勾选后打开服务端渲染的确认弹窗:选择当前账号
|
||||
可见的任意客户端,并逐行确认人民币价格上限。离线或尚未就绪的 Client 也可提前
|
||||
可见的任意客户端,并逐行确认人民币单价上限和自动计算的订单总价上限。离线或尚未就绪的 Client 也可提前
|
||||
指派,待其就绪后领取;选项继续显示在线状态但不因能力状态禁用。默认价格来自映射规格
|
||||
的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行
|
||||
模式选择,也不再要求额外风险复选框、确认短语或浏览器二次确认。
|
||||
@@ -616,7 +616,7 @@ grid 样式不得覆盖 `hidden`,关闭后改变勾选再打开也不得残留
|
||||
- 同一明细已有 `pending`、`assigned`、`claimed`、`succeeded` 或 `manual_review` 的采购任务时
|
||||
拒绝重复创建;成功任务是采购终态,待人工核对任务可能已经下单。`failed/cancelled` 的现有
|
||||
人工判断后重新创建策略不变。
|
||||
- 顺运宝列表继续显示台币售价,但绝不把它带入人民币价格上限。
|
||||
- 顺运宝列表继续显示台币售价,但绝不把它带入人民币单价或订单总价上限。
|
||||
|
||||
```text
|
||||
成功创建 3 个任务。以下 2 条未创建:
|
||||
@@ -624,8 +624,8 @@ grid 样式不得覆盖 `hidden`,关闭后改变勾选再打开也不得残留
|
||||
· SO-...-009 规格未匹配
|
||||
```
|
||||
|
||||
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个当前账号可见的 Client**,并确认价格上限。
|
||||
价格上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。
|
||||
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个当前账号可见的 Client**,并确认人民币单价上限。
|
||||
单价上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。每行必须同时显示固定数量和即时计算的 `订单总价上限 = 单价上限 × 数量`;总价使用只读 `output` 和 `aria-live` 提供反馈。JavaScript 只负责展示,后端必须用数据库中的最新数量重新计算。
|
||||
|
||||
可采购行的“下一步”显示“创建采购任务”。点击后复用同一个确认弹窗并只带入当前明细,
|
||||
不改变表格复选框状态;顶部批量入口仍按已勾选明细打开。客户端选项显示名称和 Client
|
||||
@@ -634,7 +634,7 @@ ID 和在线状态;所有可见 Client 都可选择且只有一个时自动选
|
||||
“采集采购”页,不得再次创建;采购完成行显示
|
||||
“查看采购结果”,待人工核对行显示“查看采购任务”,两者同样进入按订单号筛选的采集采购页。
|
||||
服务端仍必须重新
|
||||
校验映射、数量、人民币价格上限、客户端权限、固定 live 模式、创建审计和重复任务,不能
|
||||
校验映射、数量、人民币单价与订单总价上限、客户端权限、固定 live 模式、创建审计和重复任务,不能
|
||||
信任列表页的旧状态。
|
||||
|
||||
|
||||
@@ -677,9 +677,9 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
|
||||
不能只靠前缀或颜色区分。
|
||||
|
||||
- `[必须]` **目标列**:采集显示 `PDD <pdd_goods_id>`(能 join 到未删除商品
|
||||
的标题时追加显示);采购显示 `<order_no> · <颜色/尺码> · <数量>件 · ≤<价格上限>`。
|
||||
的标题时追加显示);采购显示 `<order_no> · <颜色/尺码> · <数量>件 · ≤<订单总价上限>`。
|
||||
拼接逻辑在 service 层组装成一个字符串,模板只负责显示。
|
||||
- `[必须]` 价格上限显示成 `¥42.00`(分转元),底层存的是整数分。
|
||||
- `[必须]` 订单总价上限显示成 `¥42.00`(分转元),底层存的是整数分。
|
||||
- `[必须]` **PDD 店铺列**:按任务的 `pdd_goods_id` 关联当前未删除
|
||||
`pdd_products.shop_name`;没有商品、尚未采集、店铺为空或商品已软删除时显示 `—`。
|
||||
- `[必须]` **客户端列**:优先显示当前 `clients.name`;名称为空或客户端记录不存在时
|
||||
@@ -711,7 +711,7 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
|
||||
│ PDD 店铺 某某店铺 │
|
||||
│ 目标规格 黑色 / M (采购任务才有) │
|
||||
│ 数量 2 (采购任务才有) │
|
||||
│ 价格上限 ¥42.00 (采购任务才有) │
|
||||
│ 订单总价上限 ¥42.00 (采购任务才有) │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ PDD 核单结果 (采购任务才有) │
|
||||
│ PDD 订单编号 PDD-20260810-001 │
|
||||
@@ -727,7 +727,7 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `[必须]` 采购专有字段(数量、价格上限、目标规格)在采集任务的弹窗里
|
||||
- `[必须]` 采购专有字段(数量、订单总价上限、目标规格)在采集任务的弹窗里
|
||||
**整段隐藏**,不显示空行。
|
||||
- PDD 核单结果只在采购任务显示,订单编号使用可选择复制的普通文本;付款状态标签
|
||||
必须包含“上报时”,不能暗示为实时状态。
|
||||
|
||||
@@ -106,7 +106,7 @@ Idempotency-Key: <stable-key>
|
||||
}
|
||||
```
|
||||
|
||||
采集任务的 `payload` 不要求颜色、尺码、数量和价格字段。采购任务必须包含 `quantity` 以及 `max_price_cent` 或等价的明确价格保护规则。
|
||||
采集任务的 `payload` 不要求颜色、尺码、数量和价格字段。采购任务必须包含 `quantity` 以及 `max_price_cent` 或等价的明确价格保护规则。`max_price_cent` 是**人民币订单总价上限(整数分)**,不是商品单价;例如数量 2、单价上限 ¥11.80 时必须下发 `2360`。Client 只在最终订单确认页用页面订单总价与该上限比较,商品页或规格面板上含义不稳定的单价/小计不能直接用于总价超限判断。
|
||||
|
||||
任务对象里**不含 Admin 侧状态**。Client 不关心 Admin 那边把它标成什么,只管做完提交。
|
||||
|
||||
@@ -221,7 +221,7 @@ Client 的 `HttpAdminGateway.claim_next` 已实现本接口,并原样序列化
|
||||
本页 §6 或 §7 提交,完整请求会先进入本地 Outbox。
|
||||
|
||||
采购响应在写入本地前必须校验非空 `goods_url`、`goods_id`、动态 `options`、
|
||||
正整数 `quantity` 和正整数分 `max_price_cent`。字段不完整时停止新的领取并向
|
||||
正整数 `quantity` 和人民币订单总价上限(正整数分)`max_price_cent`。字段不完整时停止新的领取并向
|
||||
操作员显示协议错误;不得让不完整任务进入手机执行。
|
||||
|
||||
`[必须]` **`204` 不是错误。** Client 要把它当"暂时没活干"处理,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
必须覆盖:
|
||||
|
||||
- 任务状态转换和非法转换;
|
||||
- 金额、数量、价格上限和规格校验;
|
||||
- 最终确认页订单总价、数量、订单总价上限和规格校验;
|
||||
- Admin DTO 与 `pdd_data` JSON 验证;
|
||||
- SQLite Repository、迁移和默认排序;
|
||||
- Outbox 幂等、退避和崩溃恢复;
|
||||
|
||||
Reference in New Issue
Block a user