feat: 支持顺运宝多订单号搜索 (#217)
This commit is contained in:
@@ -57,10 +57,19 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
||||
stage := service.ParseSybStage(c.Query("stage"))
|
||||
result, err := service.ListSybOrdersView(h.db, keyword, shop, stage, pageNum)
|
||||
if err != nil {
|
||||
if !service.IsValidationError(err) || result == nil {
|
||||
fail(c, http.StatusInternalServerError,
|
||||
"读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||
return
|
||||
}
|
||||
}
|
||||
orderNoError := ""
|
||||
if err != nil {
|
||||
orderNoError = err.Error()
|
||||
} else {
|
||||
// 有效输入统一回显成逗号分隔,避免 Excel 换行进入隐藏表单和分页 URL。
|
||||
keyword = result.OrderNoQuery
|
||||
}
|
||||
purchaseRows := append([]service.SybOrderView(nil), result.Rows...)
|
||||
openSybID := strings.TrimSpace(c.Query("open_syb_id"))
|
||||
openPurchaseSybID := ""
|
||||
@@ -192,6 +201,8 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
|
||||
|
||||
c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{
|
||||
"Keyword": keyword,
|
||||
"OrderNoError": orderNoError,
|
||||
"OrderNoFeedback": sybOrderNoFeedback(result),
|
||||
"ShopFilter": shop,
|
||||
"StageFilter": stage,
|
||||
"StageOptions": service.SybStageOptions(),
|
||||
@@ -344,7 +355,10 @@ func sybHistoryPartialURL(pageNum int, keyword, shop, stage, dateFrom, dateTo st
|
||||
// `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。
|
||||
func sybStatusLine(result *service.SybListResult) string {
|
||||
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)
|
||||
} else if result.IsFiltered {
|
||||
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)
|
||||
}
|
||||
|
||||
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 点「同步」按钮的入口。
|
||||
//
|
||||
// 流程见工单 #46/#47:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -658,6 +660,41 @@ func TestMainPagesReturnOK(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
addAuth(detailRequest)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-5
@@ -530,7 +530,7 @@ func nullableText(s string) any {
|
||||
|
||||
// SybOrderFilter 是货运单列表页支持的筛选条件。
|
||||
type SybOrderFilter struct {
|
||||
Keyword string // 匹配订单号或商品标题
|
||||
OrderNos []string // 订单号精确匹配;多个值之间是 OR,空切片表示全部
|
||||
Shop string // 店铺名模糊匹配;空字符串表示全部
|
||||
Stage string // 处理阶段;空字符串表示全部
|
||||
}
|
||||
@@ -538,10 +538,13 @@ type SybOrderFilter struct {
|
||||
func sybOrderFilterClause(filter SybOrderFilter) (string, []any) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
if kw := filter.Keyword; kw != "" {
|
||||
like := "%" + escapeLike(kw) + "%"
|
||||
clauses = append(clauses, `(so.order_no LIKE ? ESCAPE '!' OR so.title LIKE ? ESCAPE '!')`)
|
||||
args = append(args, like, like)
|
||||
if len(filter.OrderNos) > 0 {
|
||||
placeholders := make([]string, len(filter.OrderNos))
|
||||
for i, orderNo := range filter.OrderNos {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, orderNo)
|
||||
}
|
||||
clauses = append(clauses, `so.order_no IN (`+strings.Join(placeholders, ",")+`)`)
|
||||
}
|
||||
if shop := strings.TrimSpace(filter.Shop); shop != "" {
|
||||
clauses = append(clauses, `so.shop_name LIKE ? ESCAPE '!'`)
|
||||
@@ -752,6 +755,30 @@ func CountSybOrders(q Execer, filter SybOrderFilter) (int, error) {
|
||||
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 统计全部货运单明细数量(不带筛选),
|
||||
// 供列表页判断"是否已经同步过任何数据"。
|
||||
func CountSybOrdersTotal(q Execer) (int, error) {
|
||||
|
||||
@@ -408,7 +408,7 @@ func TestInterruptRunningSybSyncRuns_只中断未完成记录(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSybOrders_订单标题店铺组合筛选(t *testing.T) {
|
||||
func TestListSybOrders_多订单精确与店铺组合筛选(t *testing.T) {
|
||||
db := newSybTestDB(t)
|
||||
mustUpsert := func(sybID, orderNo, shop, title string) {
|
||||
if _, err := UpsertSybOrder(db, model.SybOrder{
|
||||
@@ -420,28 +420,33 @@ func TestListSybOrders_订单标题店铺组合筛选(t *testing.T) {
|
||||
mustUpsert("SYB-A", "260728AAA", "台北服饰旗舰店", "纯棉上衣")
|
||||
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 {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].SybID != "SYB-A" {
|
||||
t.Fatalf("按订单号筛选应该只查到 SYB-A,实际 %+v", rows)
|
||||
if len(rows) != 2 {
|
||||
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 {
|
||||
t.Fatalf("查询失败: %v", err)
|
||||
}
|
||||
if len(rows2) != 1 || rows2[0].SybID != "SYB-B" {
|
||||
t.Fatalf("按标题筛选应该只查到 SYB-B,实际 %+v", rows2)
|
||||
if len(rows2) != 0 {
|
||||
t.Fatalf("订单号片段和商品标题都不应再命中,实际 %+v", rows2)
|
||||
}
|
||||
|
||||
rows3, err := ListSybOrders(db, SybOrderFilter{Shop: " 服饰 "}, 20, 0)
|
||||
if err != nil || len(rows3) != 1 || rows3[0].SybID != "SYB-A" || rows3[0].ShopName != "台北服饰旗舰店" {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
|
||||
if !detail.MappingValid {
|
||||
t.Fatal("保存后相同蝦皮商品规格 + 当前 PDD 商品应自动复用")
|
||||
}
|
||||
list, err := ListSybOrdersView(db, "", "", SybStagePurchaseReady, 1)
|
||||
list, err := ListSybOrdersView(db, "ORDER-SYB-1\nNOT-FOUND", "", SybStagePurchaseReady, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -207,6 +207,10 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) {
|
||||
list.Rows[0].DefaultTotalPrice != "39.90" {
|
||||
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]
|
||||
if row.MappedPddChoice != "颜色:黑色 / 尺码:M码 / 款式:常规" ||
|
||||
strings.Contains(row.MappedPddChoice, "白色") || row.MappingOptionKey != key || row.ContextVersion == "" {
|
||||
|
||||
+105
-6
@@ -23,6 +23,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/model"
|
||||
@@ -1178,6 +1180,57 @@ type SybListResult struct {
|
||||
Stage string
|
||||
Page 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 按筛选条件分页查货运单明细列表,翻成界面文字。
|
||||
@@ -1187,13 +1240,24 @@ type SybListResult struct {
|
||||
func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) {
|
||||
shop = strings.TrimSpace(shop)
|
||||
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 ||
|
||||
stage == SybStageMappingPending || stage == SybStagePurchaseReady ||
|
||||
stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted ||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1216,14 +1280,21 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
|
||||
Rows: make([]SybOrderView, 0, len(rows)),
|
||||
Total: total,
|
||||
HasAny: hasAny > 0,
|
||||
IsFiltered: strings.TrimSpace(keyword) != "" || shop != "" || stage != "",
|
||||
IsFiltered: len(orderNos) > 0 || shop != "" || stage != "",
|
||||
Stage: stage,
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
OrderNoQuery: strings.Join(orderNos, ","),
|
||||
OrderNoCount: len(orderNos),
|
||||
}
|
||||
for _, context := range rows {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1231,9 +1302,9 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
|
||||
// 这些阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
|
||||
// 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,
|
||||
repository.SybOrderFilter{Keyword: keyword, Shop: shop}, -1, 0)
|
||||
repository.SybOrderFilter{OrderNos: orderNos, Shop: shop}, -1, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1256,7 +1327,8 @@ func listAdvancedSybStage(db *sql.DB, keyword, shop, stage string, page int) (*S
|
||||
end = total
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
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] {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
o := context.Order
|
||||
v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, ShopName: o.ShopName, Title: o.Title,
|
||||
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
@@ -71,7 +73,7 @@ func TestListSybOrdersView_空规格阻断且普通规格直接进入PDD阶段(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSybOrdersView_店铺与标题和阶段组合筛选(t *testing.T) {
|
||||
func TestListSybOrdersView_店铺与订单号和阶段组合筛选(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
for _, order := range []model.SybOrder{
|
||||
{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)
|
||||
}
|
||||
}
|
||||
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 != "台北服饰旗舰店" {
|
||||
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 {
|
||||
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) {
|
||||
key, err := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"})
|
||||
if err != nil {
|
||||
|
||||
@@ -122,6 +122,11 @@ body.syb-page-body > .page {
|
||||
min-width: 138px;
|
||||
}
|
||||
.sync-range-message { margin: -4px 0 10px; }
|
||||
.filter-feedback {
|
||||
margin: -4px 2px 10px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 搜索框收窄到 30%。目前 PDD 商品页、顺运宝数据页、采集采购页在用;
|
||||
蝦皮数据页和客户端列表页仍是撑满的。
|
||||
这样搜索表单里就没有会伸展的元素了,多余空间留在按钮之后,
|
||||
@@ -134,6 +139,22 @@ body.syb-page-body > .page {
|
||||
flex: 0 0 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%。
|
||||
min-width 继续由通用输入框规则提供,窄窗口时交给工具条自然换行。 */
|
||||
.toolbar input[type="text"].shopee-search-field { flex: 0 1 20%; }
|
||||
|
||||
@@ -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 () {
|
||||
setupModuleNavigationState();
|
||||
document.querySelectorAll("table").forEach(setupCheckAll);
|
||||
@@ -631,6 +643,7 @@
|
||||
setupCaptchaRefresh();
|
||||
setupSybSyncFeedback();
|
||||
setupSybHistoryRefresh();
|
||||
setupSybOrderSearch();
|
||||
syncButtons();
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
|
||||
"Rows": rows, "PurchaseRows": rows,
|
||||
"DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(),
|
||||
"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 {
|
||||
t.Fatalf("渲染顺运宝列表失败: %v", err)
|
||||
@@ -87,6 +89,13 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
|
||||
`>创建采集任务</button>`,
|
||||
`<h2 id="syb-collect-modal-title">创建采集任务</h2>`,
|
||||
`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`,
|
||||
`date_to=2026-08-13">清除</a>`,
|
||||
`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) {
|
||||
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
|
||||
if err != nil {
|
||||
|
||||
@@ -45,9 +45,11 @@
|
||||
<label for="shop">店铺</label>
|
||||
<input id="shop" type="text" name="shop" value="{{.ShopFilter}}"
|
||||
class="syb-search-input" placeholder="店铺名">
|
||||
<label for="q">订单号</label>
|
||||
<input id="q" type="text" name="order_no" value="{{.Keyword}}"
|
||||
class="syb-search-input" placeholder="订单号">
|
||||
<label for="q">订单号(可多条)</label>
|
||||
<textarea id="q" name="order_no" rows="1" data-syb-order-search
|
||||
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>
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
@@ -62,6 +64,8 @@
|
||||
</form>
|
||||
</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 .RangeWarning}}<p class="hint sync-range-message">{{.RangeWarning}}</p>{{end}}
|
||||
|
||||
|
||||
@@ -211,6 +211,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
|
||||
**顶部工具条:** 日期范围、同步、同步记录、创建采集任务、创建采购任务、处理阶段、
|
||||
店铺和订单号筛选、清除、搜索、删除。店铺管理通过管理员主导航进入,不在工具条重复显示。
|
||||
|
||||
订单号筛选支持一次精确搜索多条订单号。采购员可以直接粘贴 Excel 单列数据,也可以使用
|
||||
中英文逗号、分号、空格、Tab 或换行分隔;系统去除首尾空白并按首次出现顺序去重。
|
||||
每次最多接收 100 个不同订单号,原始输入最多 4000 个字符,单个订单号最多 191 个字符。
|
||||
订单号只与完整订单号匹配,不再同时模糊匹配商品标题。搜索结果显示输入订单数、当前店铺和
|
||||
处理阶段组合条件下命中的订单数、商品明细数,以及未命中的订单号;输入不合法时在搜索框附近
|
||||
提示,不返回服务器错误。分页和列表写操作返回时保留规范化后的订单号条件。
|
||||
|
||||
- `[待定]` 同步按钮 MVP 阶段只做占位:点击后提示"同步功能待接入",不发请求。
|
||||
|
||||
**中间表格:**
|
||||
|
||||
@@ -535,9 +535,14 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
|
||||
记录时内容突然移动;F5 仍按浏览器默认行为刷新整个 SYB 页面。
|
||||
|
||||
`[必须]` 店铺和订单号搜索框桌面端固定约 140px,见 [§3.1](#31-搜索框宽度)。
|
||||
店铺是独立的模糊搜索条件,可与处理阶段、订单号/商品标题组合;输入会去除首尾空白。
|
||||
店铺是独立的模糊搜索条件,可与处理阶段和订单号组合;输入会去除首尾空白。
|
||||
订单号使用可接收多行粘贴的紧凑文本区,按完整订单号精确匹配,不再匹配商品标题。
|
||||
支持中英文逗号、分号、空格、Tab 和换行分隔,去重后最多 100 个订单号;原始输入最多
|
||||
4000 个字符,单个订单号最多 191 个字符。搜索后在工具条下方显示输入订单数、当前组合条件
|
||||
命中的订单数、商品明细数和未命中订单号;超限或无有效订单号时就地显示字段错误,不返回 500。
|
||||
“清除”位于“搜索”左侧,清空处理阶段、店铺、订单号和页码,但保留当前同步日期范围。
|
||||
翻页、同步、删除、详情内写操作、创建采集任务和创建采购任务返回列表时都要保留店铺条件。
|
||||
多订单号筛选只改变当前结果集,不增加跨页全选或“操作全部搜索结果”的能力。
|
||||
|
||||
### 6.2 表格列
|
||||
|
||||
|
||||
Reference in New Issue
Block a user