fix: 简化真实采购创建并恢复客户端选择 (#112)

This commit is contained in:
chengma
2026-08-10 18:15:28 +08:00
parent 40cd8afefd
commit a26219091a
14 changed files with 96 additions and 129 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
需要突破其中任何一条时,**停下来问用户**,不要先给方案、不要先评估成本。
1. **Qt 绑定固定 PyQt5。** 不得改用 PyQt6 / PySide2 / PySide6,也不得安装其他绑定对应的 Fluent Widgets 包。
2. **Admin 新建采购任务固定为真实下单(不支付)。** 创建时必须通过明确的风险确认,且只能分配给已授权并声明 live 能力的 Client;Client 仍须通过设备绑定、商品/规格/数量/价格复核、不可逆标记和单次提交门禁。
2. **Admin 新建采购任务固定为真实下单(不支付)。** Admin 提交创建表单即表示创建真实采购任务,不再重复要求风险复选框或确认短语;任务只能分配给已授权并声明 live 能力的 Client。Client 仍须通过设备绑定、商品/规格/数量/价格复核、不可逆标记和单次提交门禁。
3. **不绕过拼多多的验证码、风控和安全机制**,不自动注册登录,不自动付款。
4. **凭据(token、Cookie、密码)不得写入** `data/`、日志、数据库、Gitea 工单和 `docs/task`。
5. **任务一旦进入不可逆阶段**(`task_runs.irreversible_action_at` 有值),**只准核对订单,绝不重新下单**。
+6 -8
View File
@@ -66,7 +66,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"读取顺运宝同步记录失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
assignableClients, err := service.ListLivePurchaseClients(h.db, currentUser(c), h.onlineThreshold)
purchaseClients, err := service.ListPurchaseClientOptions(h.db, currentUser(c), h.onlineThreshold)
if err != nil {
fail(c, http.StatusInternalServerError, "读取可分配客户端失败,数据没有被改动。")
return
@@ -183,7 +183,8 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
keyword, dateFrom, dateTo),
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
"DetailURL": "/syb/detail?" + detailValues.Encode(),
"AssignableClients": assignableClients,
"AssignableClients": purchaseClients.Rows,
"LivePurchaseClientCount": purchaseClients.SelectableCount,
}))
}
@@ -643,7 +644,7 @@ func (h *Handler) SybCreateTask(c *gin.Context) {
}
result, err := service.CreatePurchaseTasksWithOptions(
h.db, currentUser(c), requests,
purchaseTaskOptionsFromForm(c, time.Now().UTC().Add(-h.onlineThreshold).Format(model.TimeLayout)),
purchaseTaskOptionsFromForm(c),
)
if err != nil {
h.sybRedirect(c, "采购任务没有创建:"+err.Error())
@@ -653,15 +654,12 @@ func (h *Handler) SybCreateTask(c *gin.Context) {
h.sybRedirect(c, purchaseTaskResultMessage(result))
}
func purchaseTaskOptionsFromForm(c *gin.Context, clientSeenAfter string) service.PurchaseTaskOptions {
func purchaseTaskOptionsFromForm(c *gin.Context) service.PurchaseTaskOptions {
return service.PurchaseTaskOptions{
ClientID: c.PostForm("client_id"),
// Admin 新建采购任务固定为 live。不能相信浏览器提交的 execution_mode,
// 否则篡改表单就能绕过真实采购确认和审计。
// 否则篡改表单就能改变任务的不可变执行模式。
ExecutionMode: model.TaskExecutionLive,
LiveAcknowledged: c.PostForm("live_acknowledged") == "1",
LiveConfirmation: c.PostForm("live_confirmation"),
ClientSeenAfter: clientSeenAfter,
}
}
+3 -8
View File
@@ -154,18 +154,13 @@ func TestPurchaseTaskOptionsFromForm_忽略篡改模式并固定Live(t *testing.
context, _ := sybPostContext(t, url.Values{
"client_id": {"CLIENT-LIVE"},
"execution_mode": {"dry_run"},
"live_acknowledged": {"1"},
"live_confirmation": {"创建未付款订单"},
})
options := purchaseTaskOptionsFromForm(context, "2026-08-10T09:00:00Z")
options := purchaseTaskOptionsFromForm(context)
if options.ExecutionMode != model.TaskExecutionLive {
t.Fatalf("篡改 execution_mode 不得创建演练任务,实际 %q", options.ExecutionMode)
}
if options.ClientID != "CLIENT-LIVE" || !options.LiveAcknowledged || options.LiveConfirmation != "创建未付款订单" {
t.Fatalf("真实采购确认字段读取错误: %+v", options)
}
if options.ClientSeenAfter != "2026-08-10T09:00:00Z" {
t.Fatalf("在线截止时间未传给服务层: %+v", options)
if options.ClientID != "CLIENT-LIVE" {
t.Fatalf("采购客户端读取错误: %+v", options)
}
}
+25 -3
View File
@@ -128,6 +128,18 @@ func TestMainPagesReturnOK(t *testing.T) {
if usersResponse.Code != http.StatusForbidden {
t.Fatalf("采购员 GET /users = %d,期望 403", usersResponse.Code)
}
for _, client := range []model.Client{
{ClientID: "CLIENT-LIVE", Name: "真实采购机", Capabilities: `{"purchase_mode":"live"}`},
{ClientID: "CLIENT-DRY", Name: "未授权机", Capabilities: `{"purchase_mode":"dry_run"}`},
} {
if err := service.RegisterClient(db, client, true); err != nil {
t.Fatalf("准备采购客户端失败: %v", err)
}
}
if _, err := db.Exec(`UPDATE clients SET last_seen_at=? WHERE client_id=?`,
time.Now().UTC().Add(-time.Hour).Format(model.TimeLayout), "CLIENT-LIVE"); err != nil {
t.Fatalf("准备离线 live 客户端失败: %v", err)
}
sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
addAuth(sybRequest)
@@ -139,17 +151,27 @@ func TestMainPagesReturnOK(t *testing.T) {
`name="date_from"`, `name="date_to"`,
`name="stage"`, "处理阶段", "下一步", `data-detail-url=`,
"按顺运宝货运单创建日期(UTC+8)同步",
"真实下单安全确认", "确认创建真实采购任务",
`name="live_acknowledged"`, `name="live_confirmation"`,
"创建真实采购任务", "未启用真实采购",
} {
if !strings.Contains(sybResponse.Body.String(), want) {
t.Errorf("顺运宝页面缺少 %q", want)
}
}
for _, want := range []string{
`value="CLIENT-LIVE" selected`,
`value="CLIENT-DRY" disabled`,
"未授权机(CLIENT-DRY) — 未启用真实采购",
"离线客户端也可以提前指派",
} {
if !strings.Contains(sybResponse.Body.String(), want) {
t.Errorf("采购客户端候选缺少 %q", want)
}
}
if strings.Contains(sybResponse.Body.String(), "指定日期同步") {
t.Error("顺运宝页面不应再显示旧的指定日期同步入口")
}
for _, removed := range []string{`name="execution_mode"`, "采购演练", "仅演练", "支持真实下单"} {
for _, removed := range []string{`name="execution_mode"`, "采购演练", "仅演练", "支持真实下单",
"真实下单安全确认", `name="live_acknowledged"`, `name="live_confirmation"`, `data-confirm-submit=`} {
if strings.Contains(sybResponse.Body.String(), removed) {
t.Errorf("固定真实采购弹窗不应显示 %q", removed)
}
-12
View File
@@ -22,18 +22,6 @@ func ClientPurchaseMode(q Execer, clientID string) (string, error) {
return ParseClientPurchaseMode(raw.String), nil
}
// ClientSeenAfter 判断客户端是否在指定 UTC 时间之后上报过活动。
func ClientSeenAfter(q Execer, clientID, cutoff string) (bool, error) {
var online bool
if err := q.QueryRow(
`SELECT EXISTS(SELECT 1 FROM clients WHERE client_id = ? AND last_seen_at >= ?)`,
clientID, cutoff,
).Scan(&online); err != nil {
return false, fmt.Errorf("检查客户端 %s 在线状态失败: %w", clientID, err)
}
return online, nil
}
// ParseClientPurchaseMode 把登记能力转成安全的固定值,供列表和创建校验共用。
func ParseClientPurchaseMode(raw string) string {
var capabilities struct {
+8 -5
View File
@@ -325,7 +325,7 @@ func TestClientAssignment_一人多客户端并按采购员隔离列表(t *testi
}
}
func TestListLivePurchaseClients_只返回可见在线且声明Live的客户端(t *testing.T) {
func TestListPurchaseClientOptions_返回可见客户端且离线Live仍可选(t *testing.T) {
db := newTestDB(t)
admin, buyerA, _ := prepareClientAssignmentUsers(t, db)
now := time.Now().UTC()
@@ -346,12 +346,15 @@ func TestListLivePurchaseClients_只返回可见在线且声明Live的客户端(
t.Fatal(err)
}
rows, err := ListLivePurchaseClients(db, buyerA, time.Minute)
options, err := ListPurchaseClientOptions(db, buyerA, time.Minute)
if err != nil {
t.Fatalf("读取真实采购客户端失败: %v", err)
t.Fatalf("读取采购客户端候选失败: %v", err)
}
if len(rows) != 1 || rows[0].ClientID != "live-online" {
t.Fatalf("只应返回在线 live 客户端,实际 %+v", rows)
if len(options.Rows) != 3 || options.SelectableCount != 2 {
t.Fatalf("应显示全部可见客户端且两个 live 可选,实际 %+v", options)
}
if options.Rows[2].ClientID != "live-offline" || options.Rows[2].Status != "离线" {
t.Fatalf("离线 live 客户端必须保留在候选中,实际 %+v", options.Rows)
}
}
-17
View File
@@ -163,16 +163,11 @@ type PurchaseTaskRequest struct {
ContextVersion string
}
const LivePurchaseConfirmation = "创建未付款订单"
// PurchaseTaskOptions 是一次批量创建共用的执行门禁。
// ExecutionMode 留空表示 dry_run,确保旧调用和普通操作都保持安全默认值。
type PurchaseTaskOptions struct {
ClientID string
ExecutionMode model.TaskExecutionMode
LiveAcknowledged bool
LiveConfirmation string
ClientSeenAfter string
}
// PurchaseTaskResult 同时返回已创建数量和每条无法创建的原因。
@@ -215,15 +210,6 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu
if !visible {
return result, fmt.Errorf("所选客户端不存在或不在当前账号可见范围")
}
if options.ClientSeenAfter != "" {
online, err := repository.ClientSeenAfter(tx, clientID, options.ClientSeenAfter)
if err != nil {
return result, err
}
if !online {
return result, fmt.Errorf("所选客户端已离线,请刷新页面后选择在线客户端")
}
}
executionMode := options.ExecutionMode
if executionMode == "" {
executionMode = model.TaskExecutionDryRun
@@ -233,9 +219,6 @@ func CreatePurchaseTasksWithOptions(db *sql.DB, actor *model.User, requests []Pu
}
confirmedBy, confirmedAt := "", ""
if executionMode == model.TaskExecutionLive {
if !options.LiveAcknowledged || strings.TrimSpace(options.LiveConfirmation) != LivePurchaseConfirmation {
return result, fmt.Errorf("真实下单必须勾选风险确认并输入“%s”", LivePurchaseConfirmation)
}
purchaseMode, err := repository.ClientPurchaseMode(tx, clientID)
if err != nil {
return result, err
+8 -19
View File
@@ -389,8 +389,7 @@ func TestCreatePurchaseTasksWithOptions_正常采购员可创建真实任务(t *
result, err := CreatePurchaseTasksWithOptions(db, buyer,
[]PurchaseTaskRequest{{SybID: "SYB-LIVE", MaxPriceCent: 4200}},
PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive,
LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation})
PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive})
if err != nil || result.Created != 1 {
t.Fatalf("正常采购员创建真实任务失败: result=%+v err=%v", result, err)
}
@@ -404,7 +403,7 @@ func TestCreatePurchaseTasksWithOptions_正常采购员可创建真实任务(t *
}
}
func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) {
func TestCreatePurchaseTasksWithOptions_真实模式保留账号和Client能力门禁(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-GATE")
SaveSybMapping(db, "SYB-GATE", key, "USR-1")
@@ -414,16 +413,9 @@ func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) {
if _, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{
ClientID: "CLIENT-DRY", ExecutionMode: model.TaskExecutionLive,
LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation,
}); err == nil || !strings.Contains(err.Error(), "未声明 live") {
t.Fatalf("dry_run 客户端必须被拒绝: %v", err)
}
if _, err := CreatePurchaseTasksWithOptions(db, admin, request, PurchaseTaskOptions{
ClientID: "CLIENT-DRY", ExecutionMode: model.TaskExecutionLive,
LiveAcknowledged: true, LiveConfirmation: "错误短语",
}); err == nil || !strings.Contains(err.Error(), "必须勾选") {
t.Fatalf("错误确认短语必须被拒绝: %v", err)
}
disabled := *admin
disabled.Status = model.UserDisabled
if _, err := CreatePurchaseTasks(db, &disabled, request, "CLIENT-DRY"); err == nil || !strings.Contains(err.Error(), "不是正常状态") {
@@ -431,7 +423,7 @@ func TestCreatePurchaseTasksWithOptions_真实模式安全门禁(t *testing.T) {
}
}
func TestCreatePurchaseTasksWithOptions_页面打开后客户端离线会被拒绝(t *testing.T) {
func TestCreatePurchaseTasksWithOptions_离线Live客户端可提前指派(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-OFFLINE")
SaveSybMapping(db, "SYB-OFFLINE", key, "USR-1")
@@ -441,13 +433,11 @@ func TestCreatePurchaseTasksWithOptions_页面打开后客户端离线会被拒
t.Fatal(err)
}
_, err := CreatePurchaseTasksWithOptions(db, admin,
result, err := CreatePurchaseTasksWithOptions(db, admin,
[]PurchaseTaskRequest{{SybID: "SYB-OFFLINE", MaxPriceCent: 4200}},
PurchaseTaskOptions{ClientID: "CLIENT-OFFLINE", ExecutionMode: model.TaskExecutionLive,
LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation,
ClientSeenAfter: "2026-08-10T09:00:00Z"})
if err == nil || !strings.Contains(err.Error(), "已离线") {
t.Fatalf("离线客户端必须在创建事务内被拒绝: %v", err)
PurchaseTaskOptions{ClientID: "CLIENT-OFFLINE", ExecutionMode: model.TaskExecutionLive})
if err != nil || result.Created != 1 {
t.Fatalf("离线 live 客户端应可提前指派: result=%+v err=%v", result, err)
}
}
@@ -468,8 +458,7 @@ func TestCreatePurchaseTasksWithOptions_拒绝弹窗打开后变化的规格映
result, err := CreatePurchaseTasksWithOptions(db, admin,
[]PurchaseTaskRequest{{SybID: "SYB-STALE", MaxPriceCent: 3990,
MappingOptionKey: blackKey, ContextVersion: oldVersion}},
PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive,
LiveAcknowledged: true, LiveConfirmation: LivePurchaseConfirmation})
PurchaseTaskOptions{ClientID: "CLIENT-LIVE", ExecutionMode: model.TaskExecutionLive})
if err != nil || result.Created != 0 || len(result.Failures) != 1 ||
!strings.Contains(result.Failures[0].Reason, "已变化") {
t.Fatalf("旧弹窗必须因映射变化被拒绝: result=%+v err=%v", result, err)
+13 -8
View File
@@ -205,20 +205,25 @@ func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duratio
return ListClientViewsForUser(db, actor, "", threshold)
}
// ListLivePurchaseClients 返回当前用户能分配真实采购任务的在线客户端。
//
// 页面过滤只用于减少误选;创建任务时仍会在事务内重新校验可见范围和 live 能力。
func ListLivePurchaseClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) {
// PurchaseClientOptions 是采购弹窗的客户端候选和可选数量。
// 非 live Client 也返回给页面显示禁用原因;创建时仍会在事务内复核能力。
type PurchaseClientOptions struct {
Rows []ClientView
SelectableCount int
}
// ListPurchaseClientOptions 返回当前用户可见的采购客户端。
// live Client 即使暂时离线也可提前指派,等它上线后再领取任务。
func ListPurchaseClientOptions(db *sql.DB, actor *model.User, threshold time.Duration) (*PurchaseClientOptions, error) {
clients, err := ListClientViewsForUser(db, actor, "", threshold)
if err != nil {
return nil, err
}
result := make([]ClientView, 0, len(clients))
result := &PurchaseClientOptions{Rows: clients}
for _, client := range clients {
if client.Status != "在线" || client.PurchaseMode != string(model.TaskExecutionLive) {
continue
if client.PurchaseMode == string(model.TaskExecutionLive) {
result.SelectableCount++
}
result = append(result, client)
}
return result, nil
}
-6
View File
@@ -140,8 +140,6 @@
var openButtons = document.querySelectorAll("[data-purchase-open]");
var form = document.querySelector("[data-purchase-form]");
if (!openButtons.length || !form) return;
var liveAcknowledged = form.querySelector("[data-live-acknowledged]");
var liveConfirmation = form.querySelector("[data-live-confirmation]");
var submitButton = form.querySelector("[data-purchase-submit]");
openButtons.forEach(function (openButton) {
openButton.addEventListener("click", function () {
@@ -165,10 +163,6 @@
});
var empty = form.querySelector("[data-purchase-empty]");
if (empty) empty.hidden = shown > 0;
/* 每次打开都清空真实采购确认,不能沿用上一次未提交的确认。 */
if (liveAcknowledged) liveAcknowledged.checked = false;
if (liveConfirmation) liveConfirmation.value = "";
});
});
form.addEventListener("submit", function (event) {
+4 -16
View File
@@ -162,8 +162,7 @@
<h2 id="purchase-modal-title">确认创建采购任务</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<form method="post" action="/syb/create-task" data-purchase-form
data-confirm-submit="确认创建真实的未付款订单?系统不会自动支付。">
<form method="post" action="/syb/create-task" data-purchase-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="stage" value="{{.StageFilter}}">
@@ -173,20 +172,9 @@
<label for="purchase-client">执行客户端</label>
<select id="purchase-client" name="client_id" required autofocus>
<option value="">请选择可执行真实采购的客户端</option>
{{range .AssignableClients}}<option value="{{.ClientID}}">{{.Name}}({{.ClientID}})</option>{{end}}
{{range .AssignableClients}}<option value="{{.ClientID}}" {{if ne .PurchaseMode "live"}}disabled{{else if eq $.LivePurchaseClientCount 1}}selected{{end}}>{{.Name}}({{.ClientID}}){{if ne .PurchaseMode "live"}} — 未启用真实采购{{end}}</option>{{end}}
</select>
{{if not .AssignableClients}}<small class="missing">当前没有可执行真实采购的在线客户端。请检查客户端绑定、在线状态和真实采购授权。</small>{{end}}
</div>
<div class="live-confirm-panel" data-live-confirm-panel>
<strong>真实下单安全确认</strong>
<p>这会产生真实未付款订单。必须选择声明 live 能力的客户端;系统不会自动支付。</p>
<label class="choice-row">
<input type="checkbox" name="live_acknowledged" value="1" data-live-acknowledged required>
<span>我确认本次操作会创建真实未付款订单</span>
</label>
<label for="live-confirmation">输入“创建未付款订单”继续</label>
<input id="live-confirmation" name="live_confirmation" type="text"
autocomplete="off" data-live-confirmation required>
{{if eq .LivePurchaseClientCount 0}}<small class="missing">当前没有可执行真实采购的客户端。请先在 Client 设置页启用真实采购并重新登记。</small>{{else}}<small>离线客户端也可以提前指派,待其上线后领取。</small>{{end}}
</div>
<p class="hint">价格上限单位是人民币元,默认取已映射 PDD 规格的采集价;不会使用顺运宝的台币售价。</p>
{{range .Rows}}
@@ -206,7 +194,7 @@
</div>
<div class="modal-foot">
<button type="button" data-modal-close>取消</button>
<button type="submit" class="primary" data-purchase-submit {{if not .AssignableClients}}disabled{{end}}>确认创建真实采购任务</button>
<button type="submit" class="primary" data-purchase-submit {{if eq .LivePurchaseClientCount 0}}disabled{{end}}>创建真实采购任务</button>
</div>
</form>
</div>
+9 -9
View File
@@ -234,9 +234,10 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
- 规则引擎只对可购买候选做 A/B/C/冲突分层和稳定排序;只有唯一、无额外维度歧义的 A 级才预选。
- 预选不会自动保存或创建采购任务。页面必须显示推荐理由,采购员点击保存后才写映射和只追加的决策审计。
**创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗只列出
当前账号可见、在线且声明 live 能力的客户端,并逐行确认人民币价格上限。新任务固定
为真实下单(不支付),弹窗始终要求风险复选和确认短语;服务端仍按 §5 全量复核。
**创建采购任务:** 只有映射仍有效且没有进行中采购任务的行可勾选。确认弹窗列出
当前账号可见的客户端:声明 live 的客户端可选择,暂时离线也可提前指派;非 live
客户端显示但禁用。新任务固定为真实下单(不支付),提交表单即表示创建真实采购任务,
不再额外要求风险复选框或确认短语;服务端仍按 §5 全量复核。
**底部状态条:** 最近同步时间、待匹配条数。
@@ -352,15 +353,14 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
4. 数量大于 0;
5. **价格上限已填且大于 0** —— 默认从当前映射的 PDD 规格价格带出,操作员可改,但不允许为空;
6. 已选择分配的客户端;
7. 所选客户端存在、在线、在当前账号可见范围内,且声明 `purchase_mode=live`;
8. 已勾选真实下单风险确认并输入“创建未付款订单”;
9. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。
7. 所选客户端存在、在当前账号可见范围内,且声明 `purchase_mode=live`;暂时离线只影响领取时间,不阻止提前指派;
8. 同一顺运宝明细没有 `pending` / `assigned` / `claimed` 的采购任务。
Admin 新建采购任务固定使用不可变的 `execution_mode=live`,不向采购员提供模式选择:
- `live` 会创建真实未付款订单,管理员和其他正常状态用户均可创建,但必须勾选风险确认并输入“创建未付款订单”;
- 必须显式指定当前账号可见、在线且最后登记能力为 `purchase_mode=live` 的 Client;
- 创建时记录确认用户和确认时间。取消、重派和结果回传均不得修改执行模式。
- `live` 会创建真实未付款订单,管理员和其他正常状态用户均可创建;点击明确标注的创建按钮并提交表单即表示创建,不再要求额外复选框或确认短语;
- 必须显式指定当前账号可见且最后登记能力为 `purchase_mode=live` 的 Client;离线 live Client 可提前指派并在上线后领取;
- 创建时记录操作用户和操作时间。取消、重派和结果回传均不得修改执行模式。
- 数据库和 API 继续保留 `dry_run/live`,历史任务和缺省兼容值仍按 `dry_run`,不得批量升级历史任务。
第 5 条是硬要求:Client 契约规定采购任务必须带明确的价格保护
+4 -3
View File
@@ -686,11 +686,12 @@ CREATE INDEX idx_tasks_order ON tasks(order_no);
| 字段 | 含义 |
|---|---|
| `execution_mode` | `dry_run` / `live`,非空且默认 `dry_run`;创建后不可修改 |
| `live_confirmed_by` | 创建真实任务的正常状态 Admin 用户;演练任务必须为空 |
| `live_confirmed_at` | 真实任务阻断确认时间;演练任务必须为空 |
| `live_confirmed_by` | 提交创建真实任务的正常状态 Admin 用户;演练任务必须为空 |
| `live_confirmed_at` | 真实任务创建操作时间;演练任务必须为空 |
`chk_tasks_execution_mode` 和 `chk_tasks_live_confirmation` 在数据库层阻止非法模式及
缺少审计信息的真实任务;`idx_tasks_claim_mode` 支持按客户端能力领取。
缺少创建审计信息的真实任务;`idx_tasks_claim_mode` 支持按客户端能力领取。字段名保留
历史命名以兼容已发布的 v5 migration。
`[必须]` 两条硬约束,来自 [Client 契约](../client/04-admin-api-contract.md) §4:
+8 -7
View File
@@ -555,9 +555,10 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
### 6.4 创建采购任务
只有“可创建采购任务”的行可勾选。勾选后打开服务端渲染的确认弹窗:选择当前账号
可见、在线且声明 live 能力的客户端,并逐行确认人民币价格上限。默认值来自映射规格
可见且声明 live 能力的客户端,并逐行确认人民币价格上限。离线 live Client 也可提前
指派,待其上线后领取;非 live Client 显示为禁用项并说明原因。默认价格来自映射规格
的 PDD 采集价;没有价格时留空强制填写。新任务固定为真实下单(不支付),不显示执行
模式选择;风险说明、复选确认和“创建未付款订单”确认短语始终显示。
模式选择,也不再要求额外风险复选框、确认短语或浏览器二次确认。
每条确认行同时显示顺运宝规格原文和当前映射的唯一 PDD 规格组合。价格输入只对应该
组合中的选定颜色,不显示同一 PDD 商品的其他颜色或价格。批量创建时每条顺运宝明细
@@ -576,15 +577,15 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
· SO-...-009 规格未匹配
```
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个 live 在线客户端**,并确认价格上限。
`[必须]` 创建前弹出确认框,让操作员选**分配给哪个 live Client**,并确认价格上限。
价格上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。
可采购行的“下一步”显示“创建采购任务”。点击后复用同一个确认弹窗并只带入当前明细,
不改变表格复选框状态;顶部批量入口仍按已勾选明细打开。每次打开确认弹窗都清空
上一次未提交的风险复选和确认短语。客户端选项只显示名称和 Client ID,不显示“在线、
仅演练/支持真实下单”等模式文字;没有符合条件的 Client 时禁用提交并说明原因。已有未
不改变表格复选框状态;顶部批量入口仍按已勾选明细打开。客户端选项显示名称和 Client
ID;live Client 可选择且只有一个时自动选中,非 live Client 显示“未启用真实采购”并
禁用。没有符合条件的 Client 时禁用提交并说明如何在 Client 设置页启用。已有未
结束任务的行显示“查看采购任务”并进入“采集采购”页,不得再次创建。服务端仍必须重新
校验映射、数量、人民币价格上限、客户端权限、固定 live 模式、确认信息和重复任务,不能
校验映射、数量、人民币价格上限、客户端权限、固定 live 模式、创建审计和重复任务,不能
信任列表页的旧状态。