fix: 采购价格上限按数量计算总价 (#181)

This commit is contained in:
chengma
2026-08-12 14:43:18 +08:00
parent 8c7be7bd07
commit 253be7cb61
24 changed files with 203 additions and 106 deletions
+1 -1
View File
@@ -162,7 +162,7 @@ func taskPayload(t *model.Task) gin.H {
payload["options"] = options
}
}
// 采购任务必须带数量和价格上限,这是 Client 的价格保护
// 采购任务必须带数量和订单总价上限,这是 Client 的价格保护。
if t.Quantity > 0 {
payload["quantity"] = t.Quantity
}
+6 -6
View File
@@ -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
View File
@@ -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 {
+2 -2
View File
@@ -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
+36 -16
View File
@@ -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, &cent); 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
}
+36 -16
View File
@@ -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
View File
@@ -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)
}
}
}
}
+2 -2
View File
@@ -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——
+27
View File
@@ -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]");
+6 -1
View File
@@ -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"`,
+11 -5
View File
@@ -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>
+2 -2
View File
@@ -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>