diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 9e9d4e1..c653d45 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -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, @@ -161,28 +186,31 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, } c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{ - "Keyword": keyword, - "ShopFilter": shop, - "StageFilter": stage, - "StageOptions": service.SybStageOptions(), - "CurrentPage": result.Page, - "Rows": result.Rows, - "Status": status, - "HasAny": result.HasAny, - "IsFiltered": result.IsFiltered, - "NeedLogin": needLogin, - "NeedLoginReason": loginReason, - "LoginHint": loginHint, - "Username": username, - "ConfigProblem": configProblem, - "Today": today, - "SyncDateFrom": dateFrom, - "SyncDateTo": dateTo, - "RangeError": c.Query("range_error"), - "RangeWarning": rangeWarning, - "SyncRunning": syncStatus.Running, - "SyncHistory": history, - "NeedSyncHistory": c.Query("history") == "1", + "Keyword": keyword, + "ShopFilter": shop, + "StageFilter": stage, + "StageOptions": service.SybStageOptions(), + "CurrentPage": result.Page, + "Rows": result.Rows, + "PurchaseRows": purchaseRows, + "OpenSybID": openSybID, + "OpenPurchaseSybID": openPurchaseSybID, + "Status": status, + "HasAny": result.HasAny, + "IsFiltered": result.IsFiltered, + "NeedLogin": needLogin, + "NeedLoginReason": loginReason, + "LoginHint": loginHint, + "Username": username, + "ConfigProblem": configProblem, + "Today": today, + "SyncDateFrom": dateFrom, + "SyncDateTo": dateTo, + "RangeError": c.Query("range_error"), + "RangeWarning": rangeWarning, + "SyncRunning": syncStatus.Running, + "SyncHistory": history, + "NeedSyncHistory": c.Query("history") == "1", "HistoryFetchURL": sybHistoryPartialURL(history.Page, keyword, shop, stage, dateFrom, dateTo), "HistoryPagination": sybHistoryPagination(history.Page, history.TotalPages, @@ -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) { diff --git a/admin/main_test.go b/admin/main_test.go index 35a2a2f..bd50286 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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": { diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index a9be8bc..aa9e2e5 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -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") diff --git a/admin/service/syb.go b/admin/service/syb.go index fb21b8d..8cadb37 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -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 diff --git a/admin/service/task.go b/admin/service/task.go index 13f7f49..db84b5d 100644 --- a/admin/service/task.go +++ b/admin/service/task.go @@ -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,12 +473,20 @@ type TaskDetailView struct { PddGoodsURL string PddShopText string - // IsPurchase 为 false 时,模板要把下面四个采购专有字段整段隐藏, + // IsPurchase 为 false 时,模板要把下面的采购专有字段整段隐藏, // 不能显示空行——见 #19。 - IsPurchase bool - SpecText string - QuantityText string - PriceLimitText string + IsPurchase bool + ShopeeOrderNo string + SybID string + ShopeeGoodsID string + PddGoodsID string + HasSybSource bool + ViewSybURL string + CanRecreatePurchase bool + RecreatePurchaseURL string + SpecText string + QuantityText string + PriceLimitText string // PDD 核单结果只在采购任务详情显示。付款状态是 Client 核单上报时的快照, // 不是 Admin 对 PDD 的实时查询结果。 @@ -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) diff --git a/admin/service/task_test.go b/admin/service/task_test.go index ec4b970..c6b28a3 100644 --- a/admin/service/task_test.go +++ b/admin/service/task_test.go @@ -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,8 +433,9 @@ 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"}`, - quantity: 2, maxPriceCent: 4200, assignedClient: "client-001", + 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) { diff --git a/admin/static/css/app.css b/admin/static/css/app.css index c6cc24d..037927b 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -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 { diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 0ade5af..1ec7623 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -255,6 +255,22 @@ var input = row.querySelector("[data-unit-price-input]"); if (input) input.addEventListener("input", function () { updateTotalPrice(row); }); }); + function showPurchaseSelection(selected) { + var shown = 0; + form.querySelectorAll("[data-purchase-row]").forEach(function (row) { + var enabled = !!selected[row.getAttribute("data-purchase-row")]; + row.hidden = !enabled; + row.querySelectorAll("input").forEach(function (input) { + input.disabled = !enabled; + }); + if (enabled) updateTotalPrice(row); + if (enabled) shown += 1; + }); + 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 = {}; @@ -266,20 +282,23 @@ if (supportsSelectAction(box, "purchase")) selected[box.value] = true; }); } - var shown = 0; - form.querySelectorAll("[data-purchase-row]").forEach(function (row) { - var enabled = !!selected[row.getAttribute("data-purchase-row")]; - row.hidden = !enabled; - row.querySelectorAll("input").forEach(function (input) { - input.disabled = !enabled; - }); - if (enabled) updateTotalPrice(row); - if (enabled) shown += 1; - }); - var empty = form.querySelector("[data-purchase-empty]"); - if (empty) empty.hidden = shown > 0; + 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。这里只在弹窗里放大展示, diff --git a/admin/syb_interaction_template_test.go b/admin/syb_interaction_template_test.go index 4b2e8ab..579ee94 100644 --- a/admin/syb_interaction_template_test.go +++ b/admin/syb_interaction_template_test.go @@ -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) diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 1af399f..bb0d48d 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -215,7 +215,8 @@

确认创建采购任务

-
+ @@ -231,7 +232,7 @@ {{if eq .PurchaseClientCount 0}}当前账号没有可见的客户端,请先联系管理员绑定客户端。{{else}}所有当前账号可见的客户端均可指派;未就绪或离线客户端会等待其就绪后领取。{{end}}

单价上限默认取已映射 PDD 规格的人民币采集价;订单总价上限按单价乘以顺运宝数量自动计算,不使用顺运宝的台币售价。

- {{range .Rows}} + {{range .PurchaseRows}} -