feat: 支持采购任务追溯并安全重新创建 (#186)

This commit is contained in:
chengma
2026-08-12 16:26:22 +08:00
parent 836aad5c26
commit 69bcf6116a
13 changed files with 298 additions and 57 deletions
+30 -1
View File
@@ -61,6 +61,31 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
purchaseRows := append([]service.SybOrderView(nil), result.Rows...)
openSybID := strings.TrimSpace(c.Query("open_syb_id"))
openPurchaseSybID := ""
if openSybID != "" && c.Query("purchase_intent") == "1" {
focused, err := service.GetSybOrderView(h.db, openSybID)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取原货运单采购信息失败,数据没有被改动。刷新页面重试。")
return
}
if focused != nil && focused.CanPurchase {
found := false
for _, row := range purchaseRows {
if row.SybID == focused.SybID {
found = true
break
}
}
if !found {
purchaseRows = append(purchaseRows, *focused)
}
openPurchaseSybID = focused.SybID
openSybID = ""
}
}
history, err := service.ListSybSyncHistory(h.db, service.ParsePage(c.Query("history_page")))
if err != nil {
fail(c, http.StatusInternalServerError,
@@ -167,6 +192,9 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"StageOptions": service.SybStageOptions(),
"CurrentPage": result.Page,
"Rows": result.Rows,
"PurchaseRows": purchaseRows,
"OpenSybID": openSybID,
"OpenPurchaseSybID": openPurchaseSybID,
"Status": status,
"HasAny": result.HasAny,
"IsFiltered": result.IsFiltered,
@@ -805,7 +833,8 @@ func (h *Handler) TaskList(c *gin.Context) {
// TaskDetail 渲染双击行弹出的只读详情弹窗内容(不是整页)。
//
// `[必须]` 只读。改派 / 重试 / 取消是后续工单的范围,这里不提供入口。
// `[必须]` 字段只读,不提供改派 / 重试 / 取消或直接创建入口。失败/取消采购
// 只生成返回原顺运宝明细的导航链接,真正创建仍走原确认表单和服务端门禁。
// 弹窗机制复用 #18 已有的那套(static/js/app.js 的 setupRowDetail),
// 不新造一套。
func (h *Handler) TaskDetail(c *gin.Context) {
+34 -1
View File
@@ -83,7 +83,10 @@ func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *te
t.Fatal("没有找到模块查询参数白名单")
}
whitelist := jsSource[whitelistStart:whitelistEnd]
for _, forbidden := range []string{"\"msg\"", "\"error\"", "\"history\"", "\"login\"", "\"change_password\""} {
for _, forbidden := range []string{
"\"msg\"", "\"error\"", "\"history\"", "\"login\"", "\"change_password\"",
"\"open_syb_id\"", "\"purchase_intent\"",
} {
if strings.Contains(whitelist, forbidden) {
t.Errorf("稳定状态白名单不应包含临时参数 %s", forbidden)
}
@@ -189,6 +192,36 @@ func TestTaskDetail_采购核单结果使用独立只读分组(t *testing.T) {
}
}
func TestTaskDetail_采购来源与安全重新创建入口(t *testing.T) {
files := map[string][]string{
"templates/task/detail_modal.html": {
"来源信息", "蝦皮订单号", "顺运宝明细 ID", "蝦皮商品 ID", "PDD 商品 ID",
`{{if .HasSybSource}}`, `href="{{.ViewSybURL}}"`, "查看原货运单",
`{{if .CanRecreatePurchase}}`, `href="{{.RecreatePurchaseURL}}"`, "重新创建采购任务",
},
"templates/syb/list.html": {
`data-auto-open-purchase-id="{{.OpenPurchaseSybID}}"`, `{{range .PurchaseRows}}`,
`data-auto-open-detail-id="{{.OpenSybID}}"`,
},
"static/js/app.js": {
`form.getAttribute("data-auto-open-purchase-id")`,
`modal.getAttribute("data-auto-open-detail-id")`,
`showPurchaseSelection(autoSelected)`, `loadDetail(autoDetailID)`,
},
}
for path, wants := range files {
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, want := range wants {
if !strings.Contains(string(content), want) {
t.Errorf("%s 缺少采购来源恢复约束 %q", path, want)
}
}
}
}
func TestTaskPage_创建人筛选列表列和详情字段齐全(t *testing.T) {
files := map[string][]string{
"templates/task/list.html": {
+21
View File
@@ -158,6 +158,27 @@ func seedPurchasableWorkflow(t *testing.T, db *sql.DB, sybID string) string {
return key
}
func TestGetSybOrderView_按明细ID准备单条采购确认(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-DEEP-LINK")
if err := SaveSybMapping(db, "SYB-DEEP-LINK", key, "USR-1"); err != nil {
t.Fatal(err)
}
view, err := GetSybOrderView(db, " SYB-DEEP-LINK ")
if err != nil || view == nil {
t.Fatalf("精确读取失败: view=%+v err=%v", view, err)
}
if !view.CanPurchase || view.SybID != "SYB-DEEP-LINK" ||
view.MappingOptionKey != key || view.DefaultUnitPrice != "39.90" {
t.Fatalf("深链接采购确认数据不完整: %+v", view)
}
missing, err := GetSybOrderView(db, "NOT-FOUND")
if err != nil || missing != nil {
t.Fatalf("不存在明细应返回 nil: view=%+v err=%v", missing, err)
}
}
func TestSybMapping_动态维度保存并复用(t *testing.T) {
db := newTestDB(t)
key := seedPurchasableWorkflow(t, db, "SYB-1")
+11
View File
@@ -1197,6 +1197,17 @@ func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
return v
}
// GetSybOrderView 按稳定明细 ID 读取一行完整视图,供任务来源深链接在分页之外
// 准备采购确认数据。查不到返回 (nil, nil)。
func GetSybOrderView(db *sql.DB, sybID string) (*SybOrderView, error) {
context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID))
if err != nil || context == nil {
return nil, err
}
view := sybOrderViewFor(*context)
return &view, nil
}
// SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。
type SybProcessingDetail struct {
SybID string
+37 -2
View File
@@ -12,6 +12,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
@@ -456,7 +457,8 @@ const resultDataLimit = 4000
// TaskDetailView 是双击弹窗要显示的全部内容。
//
// `[必须]` 只读——本工单不做改派、重试、取消,弹窗里不应该有对应的表单。
// `[必须]` 字段只读——弹窗不直接改派、重试、取消或创建任务;安全恢复只导航
// 回原顺运宝明细,真正创建仍走原确认表单和服务端门禁。
type TaskDetailView struct {
TaskID string
TypeText string
@@ -471,9 +473,17 @@ type TaskDetailView struct {
PddGoodsURL string
PddShopText string
// IsPurchase 为 false 时,模板要把下面四个采购专有字段整段隐藏,
// IsPurchase 为 false 时,模板要把下面的采购专有字段整段隐藏,
// 不能显示空行——见 #19。
IsPurchase bool
ShopeeOrderNo string
SybID string
ShopeeGoodsID string
PddGoodsID string
HasSybSource bool
ViewSybURL string
CanRecreatePurchase bool
RecreatePurchaseURL string
SpecText string
QuantityText string
PriceLimitText string
@@ -522,6 +532,18 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) {
IsPurchase: t.TaskType == model.TaskPurchase,
}
if v.IsPurchase {
v.ShopeeOrderNo = orPlaceholder(t.OrderNo)
v.SybID = orPlaceholder(t.SybID)
v.ShopeeGoodsID = orPlaceholder(t.GoodsID)
v.PddGoodsID = orPlaceholder(t.PddGoodsID)
v.HasSybSource = strings.TrimSpace(t.SybID) != ""
if v.HasSybSource {
v.ViewSybURL = taskSybURL(*t, false)
v.CanRecreatePurchase = t.Status == model.TaskFailed || t.Status == model.TaskCancelled
if v.CanRecreatePurchase {
v.RecreatePurchaseURL = taskSybURL(*t, true)
}
}
v.SpecText = specText(t.PddOptions)
v.QuantityText = quantityText(t.Quantity)
v.PriceLimitText = priceLimitText(t.MaxPriceCent)
@@ -541,6 +563,19 @@ func GetTaskDetail(db *sql.DB, taskID string) (*TaskDetailView, error) {
return v, nil
}
// taskSybURL 生成采购任务来源的稳定深链接。syb_id 用于精确定位;订单号只负责
// 让跳转后的列表保留有意义的上下文,不能拿它代替明细主键。
func taskSybURL(task model.Task, purchaseIntent bool) string {
values := url.Values{"open_syb_id": {task.SybID}}
if strings.TrimSpace(task.OrderNo) != "" {
values.Set("order_no", task.OrderNo)
}
if purchaseIntent {
values.Set("purchase_intent", "1")
}
return "/syb?" + values.Encode()
}
// GetTaskDetailForUser 对采购员隐藏其他人和历史任务,统一表现为不存在。
func GetTaskDetailForUser(db *sql.DB, actor *model.User, taskID string) (*TaskDetailView, error) {
visibleUserID, err := visibleClientUserID(actor)
+51 -5
View File
@@ -44,7 +44,9 @@ type testTaskParams struct {
taskType model.TaskType
status model.TaskStatus
assignedClient string
sybID string
orderNo string
goodsID string
pddGoodsID string
pddOptions string
quantity int
@@ -61,13 +63,19 @@ func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) {
t.Helper()
now := model.NowISO()
var assigned, orderNo, pddGoodsID, pddOptions, errCode, errMsg, resultData, claimedAt, finishedAt any
var assigned, sybID, orderNo, goodsID, pddGoodsID, pddOptions, errCode, errMsg, resultData, claimedAt, finishedAt any
if p.assignedClient != "" {
assigned = p.assignedClient
}
if p.orderNo != "" {
orderNo = p.orderNo
}
if p.sybID != "" {
sybID = p.sybID
}
if p.goodsID != "" {
goodsID = p.goodsID
}
if p.pddGoodsID != "" {
pddGoodsID = p.pddGoodsID
}
@@ -100,14 +108,14 @@ func insertTestTask(t *testing.T, db *sql.DB, p testTaskParams) {
_, err := db.Exec(`
INSERT INTO tasks (task_id, task_type, status, assigned_client, claimed_at,
order_no, pdd_goods_url, pdd_goods_id, pdd_options,
syb_id, order_no, goods_id, pdd_goods_url, pdd_goods_id, pdd_options,
quantity, max_price_cent,
error_code, error_message, result_data, finished_at, created_by_user_id,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'https://mobile.yangkeduo.com/goods.html?goods_id=1', ?, ?,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'https://mobile.yangkeduo.com/goods.html?goods_id=1', ?, ?,
?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.taskID, string(p.taskType), string(p.status), assigned, claimedAt,
orderNo, pddGoodsID, pddOptions,
sybID, orderNo, goodsID, pddGoodsID, pddOptions,
quantity, maxPrice,
errCode, errMsg, resultData, finishedAt, nullableString(p.createdByUserID),
now, now)
@@ -425,7 +433,8 @@ func TestGetTaskDetail_采购任务显示专有字段(t *testing.T) {
db := newTestDB(t)
insertTestTask(t, db, testTaskParams{
taskID: "PUR-1", taskType: model.TaskPurchase, status: model.TaskClaimed,
orderNo: "SO-001", pddOptions: `{"color":"黑色","size":"M"}`,
sybID: "SYB-001", orderNo: "SO-001", goodsID: "SHOPEE-001", pddGoodsID: "PDD-001",
pddOptions: `{"color":"黑色","size":"M"}`,
quantity: 2, maxPriceCent: 4200, assignedClient: "client-001",
claimedAt: model.NowISO(),
})
@@ -446,6 +455,40 @@ func TestGetTaskDetail_采购任务显示专有字段(t *testing.T) {
if d.ClientText != "client-001" {
t.Errorf("分配客户端 = %q, want client-001", d.ClientText)
}
if d.ShopeeOrderNo != "SO-001" || d.SybID != "SYB-001" ||
d.ShopeeGoodsID != "SHOPEE-001" || d.PddGoodsID != "PDD-001" || !d.HasSybSource {
t.Errorf("采购来源字段不完整: %+v", d)
}
if d.ViewSybURL != "/syb?open_syb_id=SYB-001&order_no=SO-001" || d.CanRecreatePurchase || d.RecreatePurchaseURL != "" {
t.Errorf("执行中的采购任务只应允许查看来源: view=%q recreate=%t url=%q",
d.ViewSybURL, d.CanRecreatePurchase, d.RecreatePurchaseURL)
}
}
func TestGetTaskDetail_只有失败和取消允许进入重新创建确认(t *testing.T) {
db := newTestDB(t)
statuses := []model.TaskStatus{
model.TaskPending, model.TaskAssigned, model.TaskClaimed, model.TaskSucceeded,
model.TaskManualReview, model.TaskFailed, model.TaskCancelled,
}
for _, status := range statuses {
taskID := "PUR-" + string(status)
insertTestTask(t, db, testTaskParams{
taskID: taskID, taskType: model.TaskPurchase, status: status,
sybID: "SYB A&B", orderNo: "ORDER/1", goodsID: "SHOPEE-1", pddGoodsID: "PDD-1",
})
detail, err := GetTaskDetail(db, taskID)
if err != nil {
t.Fatal(err)
}
want := status == model.TaskFailed || status == model.TaskCancelled
if detail.CanRecreatePurchase != want {
t.Errorf("状态 %s CanRecreatePurchase=%t,want %t", status, detail.CanRecreatePurchase, want)
}
if want && detail.RecreatePurchaseURL != "/syb?open_syb_id=SYB+A%26B&order_no=ORDER%2F1&purchase_intent=1" {
t.Errorf("状态 %s 的深链接编码错误: %q", status, detail.RecreatePurchaseURL)
}
}
}
func TestGetTaskDetail_采购成功独立显示PDD核单结果(t *testing.T) {
@@ -525,6 +568,9 @@ func TestGetTaskDetail_采集任务不带采购专有字段(t *testing.T) {
if d.HasPddOrder || d.PddOrderResultText != "" {
t.Error("采集任务不应生成 PDD 核单结果展示")
}
if d.HasSybSource || d.ShopeeOrderNo != "" || d.CanRecreatePurchase {
t.Error("采集任务不应生成采购来源和重新创建入口")
}
}
func TestGetTaskDetail_有错误信息时显示(t *testing.T) {
+6
View File
@@ -363,6 +363,12 @@ tr.empty small { color: #aaa; }
}
.button-link:hover { background: #f0f2f4; }
.button-link.disabled { color: #999; background: #f5f6f8; }
.button-link.primary {
background: #1f6feb;
border-color: #1f6feb;
color: #fff;
}
.button-link.primary:hover { background: #1a5fd0; }
.sync-history-table { max-height: 460px; }
.sync-history-table td { vertical-align: top; }
.sync-history-table .sync-error {
+33 -11
View File
@@ -255,17 +255,7 @@
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 = {};
var singleID = openButton.getAttribute("data-purchase-id");
if (singleID) {
selected[singleID] = true;
} else {
document.querySelectorAll("tbody input[name=ids]:checked:not(:disabled)").forEach(function (box) {
if (supportsSelectAction(box, "purchase")) selected[box.value] = true;
});
}
function showPurchaseSelection(selected) {
var shown = 0;
form.querySelectorAll("[data-purchase-row]").forEach(function (row) {
var enabled = !!selected[row.getAttribute("data-purchase-row")];
@@ -278,8 +268,37 @@
});
var empty = form.querySelector("[data-purchase-empty]");
if (empty) empty.hidden = shown > 0;
return shown;
}
openButtons.forEach(function (openButton) {
openButton.addEventListener("click", function () {
var selected = {};
var singleID = openButton.getAttribute("data-purchase-id");
if (singleID) {
selected[singleID] = true;
} else {
document.querySelectorAll("tbody input[name=ids]:checked:not(:disabled)").forEach(function (box) {
if (supportsSelectAction(box, "purchase")) selected[box.value] = true;
});
}
showPurchaseSelection(selected);
});
});
/* 任务详情的“重新创建”只把稳定 syb_id 带回当前确认流程。
找不到或当前已不可采购时服务端不会输出这个 ID,前端绝不自行放宽。 */
var autoPurchaseID = form.getAttribute("data-auto-open-purchase-id") || "";
var autoSelected = {};
if (autoPurchaseID) autoSelected[autoPurchaseID] = true;
if (autoPurchaseID && showPurchaseSelection(autoSelected) > 0) {
var purchaseModal = form.closest(".modal-backdrop");
if (purchaseModal) {
openModal(purchaseModal);
var clientSelect = form.querySelector("select[name=client_id]");
if (clientSelect) clientSelect.focus({ preventScroll: true });
}
}
form.addEventListener("submit", function (event) {
if (event.defaultPrevented || !submitButton) return;
submitButton.disabled = true;
@@ -438,6 +457,9 @@
loadDetail(button.getAttribute("data-detail-open-id"));
});
});
var autoDetailID = modal.getAttribute("data-auto-open-detail-id") || "";
if (autoDetailID) loadDetail(autoDetailID);
}
/* 顺运宝缩略图使用上游图片 URL。这里只在弹窗里放大展示,
+2 -1
View File
@@ -53,7 +53,8 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
var output bytes.Buffer
data := map[string]any{
"Title": "顺运宝数据", "Active": "syb", "CSRFToken": "test-csrf",
"Rows": rows, "DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(),
"Rows": rows, "PurchaseRows": rows,
"DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(),
}
if err := tmpl.ExecuteTemplate(&output, "syb/list", data); err != nil {
t.Fatalf("渲染顺运宝列表失败: %v", err)
+5 -3
View File
@@ -215,7 +215,8 @@
<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>
<form method="post" action="/syb/create-task" data-purchase-form
data-auto-open-purchase-id="{{.OpenPurchaseSybID}}">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="order_no" value="{{.Keyword}}">
<input type="hidden" name="shop" value="{{.ShopFilter}}">
@@ -231,7 +232,7 @@
{{if eq .PurchaseClientCount 0}}<small class="missing">当前账号没有可见的客户端,请先联系管理员绑定客户端。</small>{{else}}<small>所有当前账号可见的客户端均可指派;未就绪或离线客户端会等待其就绪后领取。</small>{{end}}
</div>
<p class="hint">单价上限默认取已映射 PDD 规格的人民币采集价;订单总价上限按单价乘以顺运宝数量自动计算,不使用顺运宝的台币售价。</p>
{{range .Rows}}
{{range .PurchaseRows}}
<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>
@@ -283,7 +284,8 @@
</div>
</div>
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}" hidden>
<div class="modal-backdrop" id="detail-modal" data-detail-url="{{.DetailURL}}"
data-auto-open-detail-id="{{.OpenSybID}}" hidden>
<div class="modal modal-wide" role="dialog" aria-modal="true" data-detail-slot>
<div class="modal-body">正在加载…</div>
</div>
+19 -2
View File
@@ -2,8 +2,8 @@
{{/* 双击一行时弹出的内容。
这是一个**片段**,不是整页——外面的弹窗壳子在 task/list.html 里。
`[必须]` 只读:本工单不做改派、重试、取消,弹窗里不放对应的表单和按钮,
见 #19。采购专有字段(目标规格/数量/订单总价上限)在采集任务这里
`[必须]` 字段只读:弹窗不直接改派、重试、取消或复制旧任务;失败/取消采购
只允许导航回原顺运宝明细重新确认,见 #186。采购专有字段在采集任务这里
**整段隐藏**,不显示空行。 */}}
{{with .D}}
<div class="modal-head">
@@ -23,6 +23,16 @@
<dt>完成时间</dt><dd>{{.FinishedAt}}</dd>
</dl>
{{if .IsPurchase}}
<h3>来源信息</h3>
<dl class="detail">
<dt>蝦皮订单号</dt><dd>{{.ShopeeOrderNo}}</dd>
<dt>顺运宝明细 ID</dt><dd>{{.SybID}}</dd>
<dt>蝦皮商品 ID</dt><dd>{{.ShopeeGoodsID}}</dd>
<dt>PDD 商品 ID</dt><dd>{{.PddGoodsID}}</dd>
</dl>
{{end}}
<h3>执行参数</h3>
<dl class="detail">
<dt>PDD 链接</dt>
@@ -70,6 +80,13 @@
</div>
<div class="modal-foot">
{{if .HasSybSource}}
<span class="grow"></span>
<a class="button-link" href="{{.ViewSybURL}}">查看原货运单</a>
{{if .CanRecreatePurchase}}
<a class="button-link primary" href="{{.RecreatePurchaseURL}}">重新创建采购任务</a>
{{end}}
{{end}}
<button type="button" data-modal-close>关闭</button>
</div>
{{end}}
+7 -1
View File
@@ -318,12 +318,18 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
**双击行打开详情弹窗:**
`[必须]` **只读**。改派 / 重试 / 取消是后续工单的范围,本页不提供入口。
`[必须]` 详情内容只读,不提供改派、重试、取消或直接复制旧任务的写操作。
采购任务允许导航回原顺运宝明细重新确认,但导航本身不得创建任务。
弹窗显示:类型、状态、创建人、分配客户端、领取时间、完成时间;执行参数
(PDD 链接,采购任务额外显示目标规格、数量、价格上限);错误信息
(错误码、错误说明,没有错误时不显示这一段)。
采购任务额外显示来源信息:蝦皮订单号、顺运宝明细 ID、蝦皮商品 ID 和 PDD 商品 ID。
“查看原货运单”使用 `syb_id` 精确打开原明细。只有 `failed`、`cancelled` 显示
“重新创建采购任务”,并进入原明细的采购确认流程;其他状态不得显示。重新创建仍须
按最新 PDD 关联、规格映射、数量、价格和客户端执行现有服务端校验。
采购任务额外显示独立的“PDD 核单结果”:PDD 订单编号、下单时间和
“上报时付款状态”。这些字段从现有 `result_data.purchase` 读取;下单时间转换为
Admin 本地时区,付款状态只是 Client 核单上报时的快照,Admin 不查询 PDD 实时状态。
+14 -2
View File
@@ -699,7 +699,8 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
### 7.3 详情弹窗(双击行打开)
`[必须]` **只读**。没有改派 / 重试 / 取消入口,那些是后续工单的范围。
`[必须]` 详情字段只读,没有改派 / 重试 / 取消或直接复制旧任务的入口。
采购任务可通过文字按钮导航回原顺运宝明细;导航不产生写操作。
弹窗内容:
@@ -716,6 +717,12 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
│ 领取时间 2026-08-07 15:20:31 │
│ 完成时间 — │
├──────────────────────────────────────────────┤
│ 来源信息 (采购任务才有) │
│ 蝦皮订单号 SO-001 │
│ 顺运宝明细 ID SYB-001 │
│ 蝦皮商品 ID 100001 │
│ PDD 商品 ID 737116531267 │
├──────────────────────────────────────────────┤
│ 执行参数 │
│ PDD 链接 https://mobile.yangkeduo.com/... │
│ PDD 店铺 某某店铺 │
@@ -733,12 +740,17 @@ placeholder 写「任务编号 / 订单号 / 商品 ID」,**不要写全「PDD
│ 错误码 PDD_PAGE_TIMEOUT │
│ 错误说明 商品页加载超时 │
├──────────────────────────────────────────────┤
│ [关闭] │
│ [查看原货运单] [重新创建采购任务] [关闭] │
└──────────────────────────────────────────────┘
```
- `[必须]` 采购专有字段(数量、订单总价上限、目标规格)在采集任务的弹窗里
**整段隐藏**,不显示空行。
- 来源信息只在采购任务显示;“查看原货运单”使用稳定 `syb_id` 深链接精确打开明细,
订单号只用于列表上下文,不能代替明细主键。
- “重新创建采购任务”只在 `failed`、`cancelled` 显示。按钮进入原明细的采购确认
弹窗,仍要求选择客户端并确认当前规格、数量和价格;不得在任务详情直接创建。
`pending`、`assigned`、`claimed`、`succeeded`、`manual_review` 均不显示该入口。
- PDD 核单结果只在采购任务显示,订单编号使用可选择复制的普通文本;付款状态标签
必须包含“上报时”,不能暗示为实时状态。
- 尚未上报显示“等待 Client 核单上报”;需人工时显示“订单结果不确定”;结果结构或