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>
+1 -1
View File
@@ -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,
+1
View File
@@ -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
+5 -1
View File
@@ -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
+9 -4
View File
@@ -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",
+1 -1
View File
@@ -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")
+3 -2
View File
@@ -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()
+3 -3
View File
@@ -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 不做。
+3 -3
View File
@@ -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。
+3 -3
View File
@@ -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`
+9 -9
View File
@@ -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 核单结果只在采购任务显示,订单编号使用可选择复制的普通文本;付款状态标签
必须包含“上报时”,不能暗示为实时状态。
+2 -2
View File
@@ -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 要把它当"暂时没活干"处理,
+1 -1
View File
@@ -20,7 +20,7 @@
必须覆盖:
- 任务状态转换和非法转换;
- 金额、数量、价格上限和规格校验;
- 最终确认页订单总价、数量、订单总价上限和规格校验;
- Admin DTO 与 `pdd_data` JSON 验证;
- SQLite Repository、迁移和默认排序;
- Outbox 幂等、退避和崩溃恢复;