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
+33 -13
View File
@@ -156,9 +156,10 @@ func mappingIsValid(context repository.SybOrderContext) bool {
}
// PurchaseTaskRequest 是一条采购任务的人工确认输入。价格单位固定为人民币分。
// UnitPriceLimitCent 是采购员确认的单价上限;订单总价上限必须在事务内按最新数量计算。
type PurchaseTaskRequest struct {
SybID string
MaxPriceCent int64
UnitPriceLimitCent int64
MappingOptionKey string
ContextVersion string
}
@@ -240,6 +241,11 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu
result.Failures = append(result.Failures, TaskCreateError{SybID: request.SybID, Reason: reason})
continue
}
totalPriceLimitCent, err := CalculateOrderPriceLimitCent(request.UnitPriceLimitCent, context.Order.Quantity)
if err != nil {
result.Failures = append(result.Failures, TaskCreateError{SybID: request.SybID, Reason: err.Error()})
continue
}
taskID, err := repository.NextTaskID(tx, model.TaskPurchase)
if err != nil {
return PurchaseTaskResult{}, err
@@ -252,7 +258,7 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu
GoodsID: context.Order.ShopeeGoodsID,
PddGoodsURL: context.PddGoodsURL, PddGoodsID: context.PddGoodsID,
PddOptions: optionsJSON, Quantity: context.Order.Quantity,
MaxPriceCent: request.MaxPriceCent,
MaxPriceCent: totalPriceLimitCent,
}); err != nil {
return PurchaseTaskResult{}, err
}
@@ -306,8 +312,8 @@ func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (
if context.Order.Quantity <= 0 {
return "采购数量必须大于 0", context, "", nil
}
if request.MaxPriceCent <= 0 {
return "人民币价格上限必须大于 0", context, "", nil
if request.UnitPriceLimitCent <= 0 {
return "人民币单价上限必须大于 0", context, "", nil
}
active, err := repository.HasActivePurchaseTask(q, request.SybID)
if err != nil {
@@ -319,28 +325,42 @@ func validatePurchaseRequest(q repository.Execer, request PurchaseTaskRequest) (
return "", context, choice.OptionsJSON, nil
}
// parsePriceYuanToCent 把网页输入的人民币元精确转成分,不使用浮点数。
// CalculateOrderPriceLimitCent 用整数分计算订单总价上限,避免浮点误差和乘法溢出。
func CalculateOrderPriceLimitCent(unitPriceLimitCent int64, quantity int) (int64, error) {
if unitPriceLimitCent <= 0 {
return 0, fmt.Errorf("人民币单价上限必须大于 0")
}
if quantity <= 0 {
return 0, fmt.Errorf("采购数量必须大于 0")
}
if unitPriceLimitCent > math.MaxInt64/int64(quantity) {
return 0, fmt.Errorf("订单总价上限过大")
}
return unitPriceLimitCent * int64(quantity), nil
}
// ParsePriceYuanToCent 把网页输入的人民币单价上限精确转成分,不使用浮点数。
func ParsePriceYuanToCent(raw string) (int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("价格上限不能为空")
return 0, fmt.Errorf("人民币单价上限不能为空")
}
var whole, fraction string
parts := strings.Split(raw, ".")
if len(parts) > 2 {
return 0, fmt.Errorf("价格格式不正确")
return 0, fmt.Errorf("单价格式不正确")
}
whole = parts[0]
if len(parts) == 2 {
fraction = parts[1]
}
if whole == "" || len(fraction) > 2 {
return 0, fmt.Errorf("价格最多保留两位小数")
return 0, fmt.Errorf("单价最多保留两位小数")
}
for _, value := range []string{whole, fraction} {
for _, r := range value {
if r < '0' || r > '9' {
return 0, fmt.Errorf("价格只能填写数字")
return 0, fmt.Errorf("单价只能填写数字")
}
}
}
@@ -351,17 +371,17 @@ func ParsePriceYuanToCent(raw string) (int64, error) {
}
var yuan, cent int64
if _, err := fmt.Sscan(whole, &yuan); err != nil {
return 0, fmt.Errorf("价格格式不正确")
return 0, fmt.Errorf("单价格式不正确")
}
if _, err := fmt.Sscan(fraction, &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)
}
}
}
+6 -2
View File
@@ -942,7 +942,8 @@ type SybOrderView struct {
CanCollect bool
CanPurchase bool
SelectionHint string
DefaultMaxPrice string
DefaultUnitPrice string
DefaultTotalPrice string
MappedPddChoice string
MappingOptionKey string
ContextVersion string
@@ -1171,7 +1172,10 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
if err == nil && choice != nil {
v.MappedPddChoice = choice.Label
if choice.HasPrice {
v.DefaultMaxPrice = fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
v.DefaultUnitPrice = fmt.Sprintf("%d.%02d", choice.PriceCent/100, choice.PriceCent%100)
if total, totalErr := CalculateOrderPriceLimitCent(choice.PriceCent, o.Quantity); totalErr == nil {
v.DefaultTotalPrice = fmt.Sprintf("%d.%02d", total/100, total%100)
}
}
}
}
+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 幂等、退避和崩溃恢复;