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