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,11 +156,12 @@ func mappingIsValid(context repository.SybOrderContext) bool {
|
||||
}
|
||||
|
||||
// PurchaseTaskRequest 是一条采购任务的人工确认输入。价格单位固定为人民币分。
|
||||
// UnitPriceLimitCent 是采购员确认的单价上限;订单总价上限必须在事务内按最新数量计算。
|
||||
type PurchaseTaskRequest struct {
|
||||
SybID string
|
||||
MaxPriceCent int64
|
||||
MappingOptionKey string
|
||||
ContextVersion string
|
||||
SybID string
|
||||
UnitPriceLimitCent int64
|
||||
MappingOptionKey string
|
||||
ContextVersion string
|
||||
}
|
||||
|
||||
// PurchaseTaskOptions 是一次批量创建共用的执行门禁。
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-23
@@ -925,28 +925,29 @@ func EnsureSybSession(db *sql.DB, client *syb.Client, username string, now time.
|
||||
// SybOrderView 是列表页一行要显示的全部内容,已经格式化成字符串,
|
||||
// 模板里不做判断和格式化,和其余四个模块的做法一致。
|
||||
type SybOrderView struct {
|
||||
SybID string
|
||||
OrderNo string
|
||||
ShopName string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
Quantity int
|
||||
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
||||
ImageURL string
|
||||
Stage string
|
||||
StageText string
|
||||
StageHelp string
|
||||
ActionText string
|
||||
NeedsAttention bool
|
||||
CanCollect bool
|
||||
CanPurchase bool
|
||||
SelectionHint string
|
||||
DefaultMaxPrice string
|
||||
MappedPddChoice string
|
||||
MappingOptionKey string
|
||||
ContextVersion string
|
||||
UpdatedAt string
|
||||
SybID string
|
||||
OrderNo string
|
||||
ShopName string
|
||||
Title string
|
||||
ProductSpec string
|
||||
ShopeeGoodsID string
|
||||
Quantity int
|
||||
PriceText string // "NT$239.00",和人民币价格一眼分得清
|
||||
ImageURL string
|
||||
Stage string
|
||||
StageText string
|
||||
StageHelp string
|
||||
ActionText string
|
||||
NeedsAttention bool
|
||||
CanCollect bool
|
||||
CanPurchase bool
|
||||
SelectionHint string
|
||||
DefaultUnitPrice string
|
||||
DefaultTotalPrice string
|
||||
MappedPddChoice string
|
||||
MappingOptionKey string
|
||||
ContextVersion string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user