feat: 支持顺运宝多订单号搜索 (#217)

This commit is contained in:
chengma
2026-08-13 14:58:19 +08:00
parent 2ebe6bf695
commit 8f2b3f725d
13 changed files with 372 additions and 46 deletions
+30 -4
View File
@@ -57,9 +57,18 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
stage := service.ParseSybStage(c.Query("stage")) stage := service.ParseSybStage(c.Query("stage"))
result, err := service.ListSybOrdersView(h.db, keyword, shop, stage, pageNum) result, err := service.ListSybOrdersView(h.db, keyword, shop, stage, pageNum)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, if !service.IsValidationError(err) || result == nil {
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") fail(c, http.StatusInternalServerError,
return "读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
}
orderNoError := ""
if err != nil {
orderNoError = err.Error()
} else {
// 有效输入统一回显成逗号分隔,避免 Excel 换行进入隐藏表单和分页 URL。
keyword = result.OrderNoQuery
} }
purchaseRows := append([]service.SybOrderView(nil), result.Rows...) purchaseRows := append([]service.SybOrderView(nil), result.Rows...)
openSybID := strings.TrimSpace(c.Query("open_syb_id")) openSybID := strings.TrimSpace(c.Query("open_syb_id"))
@@ -192,6 +201,8 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{ c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{
"Keyword": keyword, "Keyword": keyword,
"OrderNoError": orderNoError,
"OrderNoFeedback": sybOrderNoFeedback(result),
"ShopFilter": shop, "ShopFilter": shop,
"StageFilter": stage, "StageFilter": stage,
"StageOptions": service.SybStageOptions(), "StageOptions": service.SybStageOptions(),
@@ -344,7 +355,10 @@ func sybHistoryPartialURL(pageNum int, keyword, shop, stage, dateFrom, dateTo st
// `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。 // `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。
func sybStatusLine(result *service.SybListResult) string { func sybStatusLine(result *service.SybListResult) string {
prefix := fmt.Sprintf("共 %d 条货运单明细", result.Total) prefix := fmt.Sprintf("共 %d 条货运单明细", result.Total)
if label := service.SybStageLabel(result.Stage); label != "" { if result.OrderNoCount > 0 {
prefix = fmt.Sprintf("输入 %d 个订单号,当前筛选命中 %d 个订单、%d 条商品明细",
result.OrderNoCount, result.MatchedOrderCount, result.Total)
} else if label := service.SybStageLabel(result.Stage); label != "" {
prefix = fmt.Sprintf("%s:%d 条", label, result.Total) prefix = fmt.Sprintf("%s:%d 条", label, result.Total)
} else if result.IsFiltered { } else if result.IsFiltered {
prefix = fmt.Sprintf("筛选结果:%d 条货运单明细", result.Total) prefix = fmt.Sprintf("筛选结果:%d 条货运单明细", result.Total)
@@ -355,6 +369,18 @@ func sybStatusLine(result *service.SybListResult) string {
return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages) return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages)
} }
func sybOrderNoFeedback(result *service.SybListResult) string {
if result == nil || result.OrderNoCount == 0 {
return ""
}
feedback := fmt.Sprintf("已输入 %d 个订单号;当前筛选命中 %d 个订单、%d 条商品明细。",
result.OrderNoCount, result.MatchedOrderCount, result.Total)
if len(result.UnmatchedOrderNos) > 0 {
feedback += " 当前筛选未命中:" + strings.Join(result.UnmatchedOrderNos, "、")
}
return feedback
}
// SybSync 点「同步」按钮的入口。 // SybSync 点「同步」按钮的入口。
// //
// 流程见工单 #46/#47: // 流程见工单 #46/#47:
+55
View File
@@ -1,8 +1,10 @@
package main package main
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -658,6 +660,41 @@ func TestMainPagesReturnOK(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("准备顺运宝详情测试数据失败: %v", err) t.Fatalf("准备顺运宝详情测试数据失败: %v", err)
} }
multiOrderRequest := httptest.NewRequest(http.MethodGet,
"/syb?order_no="+url.QueryEscape("ORDER-PDD-ID-INPUT\nNOT-FOUND"), nil)
addAuth(multiOrderRequest)
multiOrderResponse := httptest.NewRecorder()
router.ServeHTTP(multiOrderResponse, multiOrderRequest)
multiOrderBody := multiOrderResponse.Body.String()
if multiOrderResponse.Code != http.StatusOK {
t.Fatalf("多订单号搜索应返回页面,实际 %d:%s", multiOrderResponse.Code, multiOrderBody)
}
for _, want := range []string{
`>ORDER-PDD-ID-INPUT,NOT-FOUND</textarea>`,
"已输入 2 个订单号", "当前筛选命中 1 个订单、1 条商品明细", "当前筛选未命中:NOT-FOUND",
} {
if !strings.Contains(multiOrderBody, want) {
t.Errorf("多订单号搜索响应缺少 %q", want)
}
}
tooManyOrderNos := make([]string, 101)
for i := range tooManyOrderNos {
tooManyOrderNos[i] = fmt.Sprintf("ORDER-%03d", i)
}
invalidOrderRequest := httptest.NewRequest(http.MethodGet,
"/syb?order_no="+url.QueryEscape(strings.Join(tooManyOrderNos, ",")), nil)
addAuth(invalidOrderRequest)
invalidOrderResponse := httptest.NewRecorder()
router.ServeHTTP(invalidOrderResponse, invalidOrderRequest)
invalidOrderBody := invalidOrderResponse.Body.String()
if invalidOrderResponse.Code != http.StatusOK ||
!strings.Contains(invalidOrderBody, "一次最多搜索 100 个不同订单号") ||
!strings.Contains(invalidOrderBody, `aria-invalid="true"`) {
t.Fatalf("超量订单号应在页面就地提示且不返回 500,实际 %d:%s",
invalidOrderResponse.Code, invalidOrderBody)
}
detailRequest := httptest.NewRequest(http.MethodGet, "/syb/detail?id=SYB-PDD-ID-INPUT", nil) detailRequest := httptest.NewRequest(http.MethodGet, "/syb/detail?id=SYB-PDD-ID-INPUT", nil)
addAuth(detailRequest) addAuth(detailRequest)
detailResponse := httptest.NewRecorder() detailResponse := httptest.NewRecorder()
@@ -778,3 +815,21 @@ func TestModalBackdropRequiresPointerStart(t *testing.T) {
} }
} }
} }
func TestSybOrderSearch_回车搜索且Shift回车保留换行(t *testing.T) {
js, err := staticFS.ReadFile("static/js/app.js")
if err != nil {
t.Fatalf("读取公共 JavaScript 失败: %v", err)
}
source := string(js)
for _, want := range []string{
`document.querySelector("[data-syb-order-search]")`,
`event.key !== "Enter" || event.shiftKey || event.isComposing`,
`input.form.requestSubmit()`,
`setupSybOrderSearch();`,
} {
if !strings.Contains(source, want) {
t.Errorf("多订单号搜索缺少键盘守卫 %q", want)
}
}
}
+34 -7
View File
@@ -530,18 +530,21 @@ func nullableText(s string) any {
// SybOrderFilter 是货运单列表页支持的筛选条件。 // SybOrderFilter 是货运单列表页支持的筛选条件。
type SybOrderFilter struct { type SybOrderFilter struct {
Keyword string // 匹配订单号或商品标题 OrderNos []string // 订单号精确匹配;多个值之间是 OR,空切片表示全部
Shop string // 店铺名模糊匹配;空字符串表示全部 Shop string // 店铺名模糊匹配;空字符串表示全部
Stage string // 处理阶段;空字符串表示全部 Stage string // 处理阶段;空字符串表示全部
} }
func sybOrderFilterClause(filter SybOrderFilter) (string, []any) { func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
var clauses []string var clauses []string
var args []any var args []any
if kw := filter.Keyword; kw != "" { if len(filter.OrderNos) > 0 {
like := "%" + escapeLike(kw) + "%" placeholders := make([]string, len(filter.OrderNos))
clauses = append(clauses, `(so.order_no LIKE ? ESCAPE '!' OR so.title LIKE ? ESCAPE '!')`) for i, orderNo := range filter.OrderNos {
args = append(args, like, like) placeholders[i] = "?"
args = append(args, orderNo)
}
clauses = append(clauses, `so.order_no IN (`+strings.Join(placeholders, ",")+`)`)
} }
if shop := strings.TrimSpace(filter.Shop); shop != "" { if shop := strings.TrimSpace(filter.Shop); shop != "" {
clauses = append(clauses, `so.shop_name LIKE ? ESCAPE '!'`) clauses = append(clauses, `so.shop_name LIKE ? ESCAPE '!'`)
@@ -752,6 +755,30 @@ func CountSybOrders(q Execer, filter SybOrderFilter) (int, error) {
return n, nil return n, nil
} }
// ListMatchedSybOrderNos 返回当前可由 SQL 表达的组合筛选实际命中的订单号。
// 页面用它区分“输入了多少订单”和“命中了多少商品明细”;查询继续复用
// idx_syb_orders_order,所有订单号都通过占位符传入。
func ListMatchedSybOrderNos(q Execer, filter SybOrderFilter) ([]string, error) {
if len(filter.OrderNos) == 0 {
return nil, nil
}
where, args := sybOrderFilterClause(filter)
rows, err := q.Query(`SELECT DISTINCT so.order_no`+sybOrderContextFrom+where+` ORDER BY so.order_no`, args...)
if err != nil {
return nil, fmt.Errorf("查询顺运宝命中订单号失败: %w", err)
}
defer rows.Close()
matched := make([]string, 0, len(filter.OrderNos))
for rows.Next() {
var orderNo string
if err := rows.Scan(&orderNo); err != nil {
return nil, fmt.Errorf("读取顺运宝命中订单号失败: %w", err)
}
matched = append(matched, orderNo)
}
return matched, rows.Err()
}
// CountSybOrdersTotal 统计全部货运单明细数量(不带筛选), // CountSybOrdersTotal 统计全部货运单明细数量(不带筛选),
// 供列表页判断"是否已经同步过任何数据"。 // 供列表页判断"是否已经同步过任何数据"。
func CountSybOrdersTotal(q Execer) (int, error) { func CountSybOrdersTotal(q Execer) (int, error) {
+14 -9
View File
@@ -408,7 +408,7 @@ func TestInterruptRunningSybSyncRuns_只中断未完成记录(t *testing.T) {
} }
} }
func TestListSybOrders_订单标题店铺组合筛选(t *testing.T) { func TestListSybOrders_多订单精确与店铺组合筛选(t *testing.T) {
db := newSybTestDB(t) db := newSybTestDB(t)
mustUpsert := func(sybID, orderNo, shop, title string) { mustUpsert := func(sybID, orderNo, shop, title string) {
if _, err := UpsertSybOrder(db, model.SybOrder{ if _, err := UpsertSybOrder(db, model.SybOrder{
@@ -420,28 +420,33 @@ func TestListSybOrders_订单标题店铺组合筛选(t *testing.T) {
mustUpsert("SYB-A", "260728AAA", "台北服饰旗舰店", "纯棉上衣") mustUpsert("SYB-A", "260728AAA", "台北服饰旗舰店", "纯棉上衣")
mustUpsert("SYB-B", "260728BBB", "台南生活馆", "牛仔裤") mustUpsert("SYB-B", "260728BBB", "台南生活馆", "牛仔裤")
rows, err := ListSybOrders(db, SybOrderFilter{Keyword: "AAA"}, 20, 0) rows, err := ListSybOrders(db, SybOrderFilter{OrderNos: []string{"260728AAA", "260728BBB"}}, 20, 0)
if err != nil { if err != nil {
t.Fatalf("查询失败: %v", err) t.Fatalf("查询失败: %v", err)
} }
if len(rows) != 1 || rows[0].SybID != "SYB-A" { if len(rows) != 2 {
t.Fatalf("按订单号筛选应该只查到 SYB-A,实际 %+v", rows) t.Fatalf("两个精确订单号应该查到两条,实际 %+v", rows)
} }
rows2, err := ListSybOrders(db, SybOrderFilter{Keyword: "牛仔"}, 20, 0) rows2, err := ListSybOrders(db, SybOrderFilter{OrderNos: []string{"AAA", "牛仔"}}, 20, 0)
if err != nil { if err != nil {
t.Fatalf("查询失败: %v", err) t.Fatalf("查询失败: %v", err)
} }
if len(rows2) != 1 || rows2[0].SybID != "SYB-B" { if len(rows2) != 0 {
t.Fatalf("按标题筛选应该只查到 SYB-B,实际 %+v", rows2) t.Fatalf("订单号片段和商品标题都不应再命中,实际 %+v", rows2)
} }
rows3, err := ListSybOrders(db, SybOrderFilter{Shop: " 服饰 "}, 20, 0) rows3, err := ListSybOrders(db, SybOrderFilter{Shop: " 服饰 "}, 20, 0)
if err != nil || len(rows3) != 1 || rows3[0].SybID != "SYB-A" || rows3[0].ShopName != "台北服饰旗舰店" { if err != nil || len(rows3) != 1 || rows3[0].SybID != "SYB-A" || rows3[0].ShopName != "台北服饰旗舰店" {
t.Fatalf("按店铺模糊筛选错误:rows=%+v err=%v", rows3, err) t.Fatalf("按店铺模糊筛选错误:rows=%+v err=%v", rows3, err)
} }
count, err := CountSybOrders(db, SybOrderFilter{Keyword: "牛仔", Shop: "台南"}) filter := SybOrderFilter{OrderNos: []string{"260728AAA", "260728BBB", "NOT-FOUND"}, Shop: "台南"}
count, err := CountSybOrders(db, filter)
if err != nil || count != 1 { if err != nil || count != 1 {
t.Fatalf("标题与店铺组合 COUNT 错误:count=%d err=%v", count, err) t.Fatalf("订单号与店铺组合 COUNT 错误:count=%d err=%v", count, err)
}
matched, err := ListMatchedSybOrderNos(db, filter)
if err != nil || len(matched) != 1 || matched[0] != "260728BBB" {
t.Fatalf("当前筛选命中订单号错误:matched=%v err=%v", matched, err)
} }
} }
+5 -1
View File
@@ -199,7 +199,7 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
if !detail.MappingValid { if !detail.MappingValid {
t.Fatal("保存后相同蝦皮商品规格 + 当前 PDD 商品应自动复用") t.Fatal("保存后相同蝦皮商品规格 + 当前 PDD 商品应自动复用")
} }
list, err := ListSybOrdersView(db, "", "", SybStagePurchaseReady, 1) list, err := ListSybOrdersView(db, "ORDER-SYB-1\nNOT-FOUND", "", SybStagePurchaseReady, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -207,6 +207,10 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
list.Rows[0].DefaultTotalPrice != "39.90" { list.Rows[0].DefaultTotalPrice != "39.90" {
t.Fatalf("可采购阶段不对: %+v", list) t.Fatalf("可采购阶段不对: %+v", list)
} }
if list.OrderNoCount != 2 || list.MatchedOrderCount != 1 ||
len(list.UnmatchedOrderNos) != 1 || list.UnmatchedOrderNos[0] != "NOT-FOUND" {
t.Fatalf("高级阶段多订单号命中统计错误: %+v", list)
}
row := list.Rows[0] row := list.Rows[0]
if row.MappedPddChoice != "颜色:黑色 / 尺码:M码 / 款式:常规" || if row.MappedPddChoice != "颜色:黑色 / 尺码:M码 / 款式:常规" ||
strings.Contains(row.MappedPddChoice, "白色") || row.MappingOptionKey != key || row.ContextVersion == "" { strings.Contains(row.MappedPddChoice, "白色") || row.MappingOptionKey != key || row.ContextVersion == "" {
+118 -19
View File
@@ -23,6 +23,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"unicode/utf8"
"cmautobuy/admin/config" "cmautobuy/admin/config"
"cmautobuy/admin/model" "cmautobuy/admin/model"
@@ -1171,13 +1173,64 @@ func sybCollectingIsStale(c repository.SybOrderContext) bool {
// SybListResult 是列表页要的全部数据。 // SybListResult 是列表页要的全部数据。
type SybListResult struct { type SybListResult struct {
Rows []SybOrderView Rows []SybOrderView
Total int Total int
HasAny bool HasAny bool
IsFiltered bool IsFiltered bool
Stage string Stage string
Page int Page int
TotalPages int TotalPages int
OrderNoQuery string
OrderNoCount int
MatchedOrderCount int
UnmatchedOrderNos []string
}
const (
maxSybOrderNoQueryLen = 4000
maxSybOrderNoCount = 100
maxSybOrderNoLen = 191
)
// ParseSybOrderNos 把采购员粘贴的订单号列变成去重后的精确查询值。
// 支持 Excel 换行/Tab,以及中英文逗号和分号;订单号本身不允许包含空白。
func ParseSybOrderNos(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if utf8.RuneCountInString(raw) > maxSybOrderNoQueryLen {
return nil, invalidFieldInput("order_no", "订单号输入不能超过 %d 个字符", maxSybOrderNoQueryLen)
}
parts := strings.FieldsFunc(raw, func(r rune) bool {
return unicode.IsSpace(r) || r == ',' || r == ',' || r == ';' || r == ';'
})
if len(parts) == 0 {
return nil, invalidFieldInput("order_no", "请输入至少一个有效订单号")
}
seen := make(map[string]struct{}, len(parts))
orderNos := make([]string, 0, len(parts))
for _, part := range parts {
orderNo := strings.TrimSpace(part)
if orderNo == "" {
continue
}
if utf8.RuneCountInString(orderNo) > maxSybOrderNoLen {
return nil, invalidFieldInput("order_no", "订单号 %q 超过 %d 个字符", orderNo, maxSybOrderNoLen)
}
if _, exists := seen[orderNo]; exists {
continue
}
seen[orderNo] = struct{}{}
orderNos = append(orderNos, orderNo)
if len(orderNos) > maxSybOrderNoCount {
return nil, invalidFieldInput("order_no", "一次最多搜索 %d 个不同订单号", maxSybOrderNoCount)
}
}
if len(orderNos) == 0 {
return nil, invalidFieldInput("order_no", "请输入至少一个有效订单号")
}
return orderNos, nil
} }
// ListSybOrdersView 按筛选条件分页查货运单明细列表,翻成界面文字。 // ListSybOrdersView 按筛选条件分页查货运单明细列表,翻成界面文字。
@@ -1187,13 +1240,24 @@ type SybListResult struct {
func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) { func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) {
shop = strings.TrimSpace(shop) shop = strings.TrimSpace(shop)
stage = ParseSybStage(stage) stage = ParseSybStage(stage)
orderNos, err := ParseSybOrderNos(keyword)
if err != nil {
hasAny, countErr := repository.CountSybOrdersTotal(db)
if countErr != nil {
return nil, countErr
}
return &SybListResult{
Rows: make([]SybOrderView, 0), HasAny: hasAny > 0, IsFiltered: true,
Stage: stage, Page: 1, TotalPages: 1, OrderNoQuery: keyword,
}, err
}
if stage == SybStagePddCollecting || stage == SybStagePddCollectingStale || if stage == SybStagePddCollecting || stage == SybStagePddCollectingStale ||
stage == SybStageMappingPending || stage == SybStagePurchaseReady || stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted || stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted ||
stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked { stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked {
return listAdvancedSybStage(db, keyword, shop, stage, page) return listAdvancedSybStage(db, orderNos, shop, stage, page)
} }
filter := repository.SybOrderFilter{Keyword: keyword, Shop: shop, Stage: stage} filter := repository.SybOrderFilter{OrderNos: orderNos, Shop: shop, Stage: stage}
total, err := repository.CountSybOrders(db, filter) total, err := repository.CountSybOrders(db, filter)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1213,17 +1277,24 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
} }
result := &SybListResult{ result := &SybListResult{
Rows: make([]SybOrderView, 0, len(rows)), Rows: make([]SybOrderView, 0, len(rows)),
Total: total, Total: total,
HasAny: hasAny > 0, HasAny: hasAny > 0,
IsFiltered: strings.TrimSpace(keyword) != "" || shop != "" || stage != "", IsFiltered: len(orderNos) > 0 || shop != "" || stage != "",
Stage: stage, Stage: stage,
Page: page, Page: page,
TotalPages: totalPages, TotalPages: totalPages,
OrderNoQuery: strings.Join(orderNos, ","),
OrderNoCount: len(orderNos),
} }
for _, context := range rows { for _, context := range rows {
result.Rows = append(result.Rows, sybOrderViewFor(context)) result.Rows = append(result.Rows, sybOrderViewFor(context))
} }
matchedOrderNos, err := repository.ListMatchedSybOrderNos(db, filter)
if err != nil {
return nil, err
}
setSybOrderMatchStats(result, orderNos, matchedOrderNos)
return result, nil return result, nil
} }
@@ -1231,9 +1302,9 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
// 这些阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的 // 这些阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表 // OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表
// 和其余阶段仍在数据库分页。 // 和其余阶段仍在数据库分页。
func listAdvancedSybStage(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) { func listAdvancedSybStage(db *sql.DB, orderNos []string, shop, stage string, page int) (*SybListResult, error) {
contexts, err := repository.ListSybOrderContexts(db, contexts, err := repository.ListSybOrderContexts(db,
repository.SybOrderFilter{Keyword: keyword, Shop: shop}, -1, 0) repository.SybOrderFilter{OrderNos: orderNos, Shop: shop}, -1, 0)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1256,7 +1327,8 @@ func listAdvancedSybStage(db *sql.DB, keyword, shop, stage string, page int) (*S
end = total end = total
} }
result := &SybListResult{Total: total, Stage: stage, Page: page, TotalPages: totalPages, result := &SybListResult{Total: total, Stage: stage, Page: page, TotalPages: totalPages,
IsFiltered: true, Rows: make([]SybOrderView, 0, end-start)} IsFiltered: true, Rows: make([]SybOrderView, 0, end-start),
OrderNoQuery: strings.Join(orderNos, ","), OrderNoCount: len(orderNos)}
hasAny, err := repository.CountSybOrdersTotal(db) hasAny, err := repository.CountSybOrdersTotal(db)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1265,9 +1337,36 @@ func listAdvancedSybStage(db *sql.DB, keyword, shop, stage string, page int) (*S
for _, context := range filtered[start:end] { for _, context := range filtered[start:end] {
result.Rows = append(result.Rows, sybOrderViewFor(context)) result.Rows = append(result.Rows, sybOrderViewFor(context))
} }
matchedOrderNos := make([]string, 0, len(orderNos))
matchedSet := make(map[string]struct{}, len(orderNos))
for _, context := range filtered {
if _, exists := matchedSet[context.Order.OrderNo]; exists {
continue
}
matchedSet[context.Order.OrderNo] = struct{}{}
matchedOrderNos = append(matchedOrderNos, context.Order.OrderNo)
}
setSybOrderMatchStats(result, orderNos, matchedOrderNos)
return result, nil return result, nil
} }
func setSybOrderMatchStats(result *SybListResult, orderNos, matchedOrderNos []string) {
if len(orderNos) == 0 {
return
}
matched := make(map[string]struct{}, len(matchedOrderNos))
for _, orderNo := range matchedOrderNos {
matched[orderNo] = struct{}{}
}
result.MatchedOrderCount = len(matched)
result.UnmatchedOrderNos = make([]string, 0, len(orderNos)-len(matched))
for _, orderNo := range orderNos {
if _, exists := matched[orderNo]; !exists {
result.UnmatchedOrderNos = append(result.UnmatchedOrderNos, orderNo)
}
}
}
func sybOrderViewFor(context repository.SybOrderContext) SybOrderView { func sybOrderViewFor(context repository.SybOrderContext) SybOrderView {
o := context.Order o := context.Order
v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, ShopName: o.ShopName, Title: o.Title, v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, ShopName: o.ShopName, Title: o.Title,
+28 -2
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"database/sql" "database/sql"
"fmt"
"strings"
"testing" "testing"
"cmautobuy/admin/model" "cmautobuy/admin/model"
@@ -71,7 +73,7 @@ func TestListSybOrdersView_空规格阻断且普通规格直接进入PDD阶段(t
} }
} }
func TestListSybOrdersView_店铺与标题和阶段组合筛选(t *testing.T) { func TestListSybOrdersView_店铺与订单号和阶段组合筛选(t *testing.T) {
db := newSyncTestDB(t) db := newSyncTestDB(t)
for _, order := range []model.SybOrder{ for _, order := range []model.SybOrder{
{SybID: "SHOP-A", OrderNo: "ORDER-A", ShopName: "台北服饰旗舰店", Title: "纯棉上衣", ProductSpec: "黑色,M", ShopeeGoodsID: "SP-A", Quantity: 1, SybData: `{}`}, {SybID: "SHOP-A", OrderNo: "ORDER-A", ShopName: "台北服饰旗舰店", Title: "纯棉上衣", ProductSpec: "黑色,M", ShopeeGoodsID: "SP-A", Quantity: 1, SybData: `{}`},
@@ -81,15 +83,39 @@ func TestListSybOrdersView_店铺与标题和阶段组合筛选(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
result, err := ListSybOrdersView(db, "上衣", " 服饰 ", SybStagePddMissing, 1) result, err := ListSybOrdersView(db, "ORDER-A,NOT-FOUND", " 服饰 ", SybStagePddMissing, 1)
if err != nil || result.Total != 1 || len(result.Rows) != 1 || result.Rows[0].SybID != "SHOP-A" || result.Rows[0].ShopName != "台北服饰旗舰店" { if err != nil || result.Total != 1 || len(result.Rows) != 1 || result.Rows[0].SybID != "SHOP-A" || result.Rows[0].ShopName != "台北服饰旗舰店" {
t.Fatalf("组合筛选错误: result=%+v err=%v", result, err) t.Fatalf("组合筛选错误: result=%+v err=%v", result, err)
} }
if result.OrderNoQuery != "ORDER-A,NOT-FOUND" || result.OrderNoCount != 2 ||
result.MatchedOrderCount != 1 || len(result.UnmatchedOrderNos) != 1 || result.UnmatchedOrderNos[0] != "NOT-FOUND" {
t.Fatalf("订单号命中统计错误: %+v", result)
}
if !result.IsFiltered { if !result.IsFiltered {
t.Fatal("店铺条件应标记为筛选结果") t.Fatal("店铺条件应标记为筛选结果")
} }
} }
func TestParseSybOrderNos_多分隔符去重与限制(t *testing.T) {
got, err := ParseSybOrderNos(" A,B;C;D\tE\nA ")
if err != nil || strings.Join(got, ",") != "A,B,C,D,E" {
t.Fatalf("多分隔符解析错误: got=%v err=%v", got, err)
}
tooMany := make([]string, 101)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("ORDER-%03d", i)
}
if _, err := ParseSybOrderNos(strings.Join(tooMany, ",")); !IsValidationError(err) || ValidationField(err) != "order_no" {
t.Fatalf("超过 100 个订单号应返回字段校验错误: %v", err)
}
if _, err := ParseSybOrderNos(strings.Repeat("A", 192)); !IsValidationError(err) {
t.Fatalf("超长单号应返回校验错误: %v", err)
}
if _, err := ParseSybOrderNos(strings.Repeat("A,", 2001)); !IsValidationError(err) {
t.Fatalf("超长原始输入应返回校验错误: %v", err)
}
}
func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) { func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) {
key, err := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"}) key, err := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"})
if err != nil { if err != nil {
+21
View File
@@ -122,6 +122,11 @@ body.syb-page-body > .page {
min-width: 138px; min-width: 138px;
} }
.sync-range-message { margin: -4px 0 10px; } .sync-range-message { margin: -4px 0 10px; }
.filter-feedback {
margin: -4px 2px 10px;
white-space: normal;
overflow-wrap: anywhere;
}
/* 搜索框收窄到 30%。目前 PDD 商品页、顺运宝数据页、采集采购页在用; /* 搜索框收窄到 30%。目前 PDD 商品页、顺运宝数据页、采集采购页在用;
蝦皮数据页和客户端列表页仍是撑满的。 蝦皮数据页和客户端列表页仍是撑满的。
这样搜索表单里就没有会伸展的元素了,多余空间留在按钮之后, 这样搜索表单里就没有会伸展的元素了,多余空间留在按钮之后,
@@ -134,6 +139,22 @@ body.syb-page-body > .page {
flex: 0 0 140px; flex: 0 0 140px;
min-width: 140px; min-width: 140px;
} }
.toolbar textarea.syb-search-input {
flex: 0 0 140px;
width: 140px;
min-width: 140px;
}
.syb-order-search-input {
height: 30px;
min-height: 30px;
padding: 5px 8px;
border: 1px solid #ccd1d6;
border-radius: 3px;
font: inherit;
line-height: 18px;
resize: vertical;
}
.syb-order-search-input[aria-invalid="true"] { border-color: #b42318; }
/* 蝦皮工具条同时放商品和店铺两个搜索框,各占剩余工具条的约 20%。 /* 蝦皮工具条同时放商品和店铺两个搜索框,各占剩余工具条的约 20%。
min-width 继续由通用输入框规则提供,窄窗口时交给工具条自然换行。 */ min-width 继续由通用输入框规则提供,窄窗口时交给工具条自然换行。 */
.toolbar input[type="text"].shopee-search-field { flex: 0 1 20%; } .toolbar input[type="text"].shopee-search-field { flex: 0 1 20%; }
+13
View File
@@ -614,6 +614,18 @@
}); });
} }
/* 订单号搜索使用 textarea 才能可靠接收 Excel 单列粘贴。
保留搜索框原有的 Enter 提交习惯;Shift+Enter 仍可手工换行。 */
function setupSybOrderSearch() {
var input = document.querySelector("[data-syb-order-search]");
if (!input || !input.form) return;
input.addEventListener("keydown", function (event) {
if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
event.preventDefault();
input.form.requestSubmit();
});
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
setupModuleNavigationState(); setupModuleNavigationState();
document.querySelectorAll("table").forEach(setupCheckAll); document.querySelectorAll("table").forEach(setupCheckAll);
@@ -631,6 +643,7 @@
setupCaptchaRefresh(); setupCaptchaRefresh();
setupSybSyncFeedback(); setupSybSyncFeedback();
setupSybHistoryRefresh(); setupSybHistoryRefresh();
setupSybOrderSearch();
syncButtons(); syncButtons();
}); });
+34
View File
@@ -56,6 +56,8 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
"Rows": rows, "PurchaseRows": rows, "Rows": rows, "PurchaseRows": rows,
"DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(), "DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(),
"SyncDateFrom": "2026-08-12", "SyncDateTo": "2026-08-13", "SyncDateFrom": "2026-08-12", "SyncDateTo": "2026-08-13",
"Keyword": "ORDER-READY,ORDER-MISSING",
"OrderNoFeedback": "已输入 2 个订单号;当前筛选命中 1 个订单、1 条商品明细。 当前筛选未命中:ORDER-MISSING",
} }
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)
@@ -87,6 +89,13 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
`>创建采集任务</button>`, `>创建采集任务</button>`,
`<h2 id="syb-collect-modal-title">创建采集任务</h2>`, `<h2 id="syb-collect-modal-title">创建采集任务</h2>`,
`class="syb-search-input"`, `class="syb-search-input"`,
`订单号(可多条)`,
`textarea id="q" name="order_no" rows="1"`,
`data-syb-order-search`,
`maxlength="4000"`,
`支持中英文逗号、分号、空格、Tab 或换行分隔,最多 100 个订单号`,
`role="status">已输入 2 个订单号`,
`当前筛选未命中:ORDER-MISSING`,
`href="/syb?date_from=2026-08-12`, `href="/syb?date_from=2026-08-12`,
`date_to=2026-08-13">清除</a>`, `date_to=2026-08-13">清除</a>`,
`class="table-wrap syb-table-wrap"`, `class="table-wrap syb-table-wrap"`,
@@ -121,6 +130,31 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
} }
} }
func TestSybListTemplate_订单号错误就地显示(t *testing.T) {
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
data := map[string]any{
"Title": "顺运宝数据", "Active": "syb", "CSRFToken": "test-csrf",
"OrderNoError": "一次最多搜索 100 个不同订单号",
"Keyword": "ORDER-001",
}
if err := tmpl.ExecuteTemplate(&output, "syb/list", data); err != nil {
t.Fatalf("渲染顺运宝订单号错误失败: %v", err)
}
body := output.String()
for _, want := range []string{
`aria-invalid="true"`, `aria-describedby="order-no-error"`,
`id="order-no-error"`, `role="alert">一次最多搜索 100 个不同订单号`,
} {
if !strings.Contains(body, want) {
t.Errorf("订单号错误提示缺少 %q", want)
}
}
}
func TestSybDetailTemplate_单条采集可选执行客户端(t *testing.T) { func TestSybDetailTemplate_单条采集可选执行客户端(t *testing.T) {
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html") tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
if err != nil { if err != nil {
+7 -3
View File
@@ -45,9 +45,11 @@
<label for="shop">店铺</label> <label for="shop">店铺</label>
<input id="shop" type="text" name="shop" value="{{.ShopFilter}}" <input id="shop" type="text" name="shop" value="{{.ShopFilter}}"
class="syb-search-input" placeholder="店铺名"> class="syb-search-input" placeholder="店铺名">
<label for="q">订单号</label> <label for="q">订单号(可多条)</label>
<input id="q" type="text" name="order_no" value="{{.Keyword}}" <textarea id="q" name="order_no" rows="1" data-syb-order-search
class="syb-search-input" placeholder="订单号"> class="syb-search-input syb-order-search-input" placeholder="多个可直接粘贴" maxlength="4000"
title="支持中英文逗号、分号、空格、Tab 或换行分隔,最多 100 个订单号"
{{if .OrderNoError}}aria-invalid="true" aria-describedby="order-no-error"{{end}}>{{.Keyword}}</textarea>
<a class="button-link" href="/syb?date_from={{urlquery .SyncDateFrom}}&date_to={{urlquery .SyncDateTo}}">清除</a> <a class="button-link" href="/syb?date_from={{urlquery .SyncDateFrom}}&date_to={{urlquery .SyncDateTo}}">清除</a>
<button type="submit">搜索</button> <button type="submit">搜索</button>
</form> </form>
@@ -62,6 +64,8 @@
</form> </form>
</div> </div>
{{if .OrderNoError}}<p id="order-no-error" class="missing filter-feedback" role="alert">{{.OrderNoError}}</p>{{end}}
{{if .OrderNoFeedback}}<p class="hint filter-feedback" role="status">{{.OrderNoFeedback}}</p>{{end}}
{{if .RangeError}}<p class="missing sync-range-message" role="alert">{{.RangeError}}</p>{{end}} {{if .RangeError}}<p class="missing sync-range-message" role="alert">{{.RangeError}}</p>{{end}}
{{if .RangeWarning}}<p class="hint sync-range-message">{{.RangeWarning}}</p>{{end}} {{if .RangeWarning}}<p class="hint sync-range-message">{{.RangeWarning}}</p>{{end}}
+7
View File
@@ -211,6 +211,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
**顶部工具条:** 日期范围、同步、同步记录、创建采集任务、创建采购任务、处理阶段、 **顶部工具条:** 日期范围、同步、同步记录、创建采集任务、创建采购任务、处理阶段、
店铺和订单号筛选、清除、搜索、删除。店铺管理通过管理员主导航进入,不在工具条重复显示。 店铺和订单号筛选、清除、搜索、删除。店铺管理通过管理员主导航进入,不在工具条重复显示。
订单号筛选支持一次精确搜索多条订单号。采购员可以直接粘贴 Excel 单列数据,也可以使用
中英文逗号、分号、空格、Tab 或换行分隔;系统去除首尾空白并按首次出现顺序去重。
每次最多接收 100 个不同订单号,原始输入最多 4000 个字符,单个订单号最多 191 个字符。
订单号只与完整订单号匹配,不再同时模糊匹配商品标题。搜索结果显示输入订单数、当前店铺和
处理阶段组合条件下命中的订单数、商品明细数,以及未命中的订单号;输入不合法时在搜索框附近
提示,不返回服务器错误。分页和列表写操作返回时保留规范化后的订单号条件。
- `[待定]` 同步按钮 MVP 阶段只做占位:点击后提示"同步功能待接入",不发请求。 - `[待定]` 同步按钮 MVP 阶段只做占位:点击后提示"同步功能待接入",不发请求。
**中间表格:** **中间表格:**
+6 -1
View File
@@ -535,9 +535,14 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
记录时内容突然移动;F5 仍按浏览器默认行为刷新整个 SYB 页面。 记录时内容突然移动;F5 仍按浏览器默认行为刷新整个 SYB 页面。
`[必须]` 店铺和订单号搜索框桌面端固定约 140px,见 [§3.1](#31-搜索框宽度)。 `[必须]` 店铺和订单号搜索框桌面端固定约 140px,见 [§3.1](#31-搜索框宽度)。
店铺是独立的模糊搜索条件,可与处理阶段、订单号/商品标题组合;输入会去除首尾空白。 店铺是独立的模糊搜索条件,可与处理阶段和订单号组合;输入会去除首尾空白。
订单号使用可接收多行粘贴的紧凑文本区,按完整订单号精确匹配,不再匹配商品标题。
支持中英文逗号、分号、空格、Tab 和换行分隔,去重后最多 100 个订单号;原始输入最多
4000 个字符,单个订单号最多 191 个字符。搜索后在工具条下方显示输入订单数、当前组合条件
命中的订单数、商品明细数和未命中订单号;超限或无有效订单号时就地显示字段错误,不返回 500。
“清除”位于“搜索”左侧,清空处理阶段、店铺、订单号和页码,但保留当前同步日期范围。 “清除”位于“搜索”左侧,清空处理阶段、店铺、订单号和页码,但保留当前同步日期范围。
翻页、同步、删除、详情内写操作、创建采集任务和创建采购任务返回列表时都要保留店铺条件。 翻页、同步、删除、详情内写操作、创建采集任务和创建采购任务返回列表时都要保留店铺条件。
多订单号筛选只改变当前结果集,不增加跨页全选或“操作全部搜索结果”的能力。
### 6.2 表格列 ### 6.2 表格列