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
+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
+41 -6
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,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)
+52 -6
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,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) {