diff --git a/admin/handler/api/client_api.go b/admin/handler/api/client_api.go index d7f4b38..c4195ef 100644 --- a/admin/handler/api/client_api.go +++ b/admin/handler/api/client_api.go @@ -162,7 +162,7 @@ func taskPayload(t *model.Task) gin.H { payload["options"] = options } } - // 采购任务必须带数量和价格上限,这是 Client 的价格保护 + // 采购任务必须带数量和订单总价上限,这是 Client 的价格保护。 if t.Quantity > 0 { payload["quantity"] = t.Quantity } diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index edde1ab..9e9d4e1 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -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]), }) diff --git a/admin/main_test.go b/admin/main_test.go index 883710b..32da8dd 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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 { diff --git a/admin/model/model.go b/admin/model/model.go index 691a558..743c329 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -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 diff --git a/admin/service/purchase_workflow.go b/admin/service/purchase_workflow.go index db06d79..390f74d 100644 --- a/admin/service/purchase_workflow.go +++ b/admin/service/purchase_workflow.go @@ -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 } diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index fd8c8cc..f054766 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -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) + } + } +} diff --git a/admin/service/syb.go b/admin/service/syb.go index c28f818..ba5ee1c 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -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) + } } } } diff --git a/admin/service/task.go b/admin/service/task.go index 34a07da..13f7f49 100644 --- a/admin/service/task.go +++ b/admin/service/task.go @@ -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—— diff --git a/admin/static/js/app.js b/admin/static/js/app.js index ab8985a..ae704ac 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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]"); diff --git a/admin/syb_interaction_template_test.go b/admin/syb_interaction_template_test.go index 847f108..4b2e8ab 100644 --- a/admin/syb_interaction_template_test.go +++ b/admin/syb_interaction_template_test.go @@ -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"`, diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 8c64847..f3f72d0 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -230,18 +230,24 @@ {{if eq .PurchaseClientCount 0}}当前账号没有可见的客户端,请先联系管理员绑定客户端。{{else}}所有当前账号可见的客户端均可指派;未就绪或离线客户端会等待其就绪后领取。{{end}} -

价格上限单位是人民币元,默认取已映射 PDD 规格的采集价;不会使用顺运宝的台币售价。

+

单价上限默认取已映射 PDD 规格的人民币采集价;订单总价上限按单价乘以顺运宝数量自动计算,不使用顺运宝的台币售价。

{{range .Rows}} -