feat: 支持采购任务追溯并安全重新创建 (#186)
This commit is contained in:
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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。这里只在弹窗里放大展示,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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 实时状态。
|
||||||
|
|||||||
@@ -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 核单上报”;需人工时显示“订单结果不确定”;结果结构或
|
||||||
|
|||||||
Reference in New Issue
Block a user