feat: 支持顺运宝多订单号搜索 (#217)
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
+118
-19
@@ -23,6 +23,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/model"
|
||||
@@ -1171,13 +1173,64 @@ func sybCollectingIsStale(c repository.SybOrderContext) bool {
|
||||
|
||||
// SybListResult 是列表页要的全部数据。
|
||||
type SybListResult struct {
|
||||
Rows []SybOrderView
|
||||
Total int
|
||||
HasAny bool
|
||||
IsFiltered bool
|
||||
Stage string
|
||||
Page int
|
||||
TotalPages int
|
||||
Rows []SybOrderView
|
||||
Total int
|
||||
HasAny bool
|
||||
IsFiltered bool
|
||||
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
|
||||
@@ -1213,17 +1277,24 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
|
||||
}
|
||||
|
||||
result := &SybListResult{
|
||||
Rows: make([]SybOrderView, 0, len(rows)),
|
||||
Total: total,
|
||||
HasAny: hasAny > 0,
|
||||
IsFiltered: strings.TrimSpace(keyword) != "" || shop != "" || stage != "",
|
||||
Stage: stage,
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
Rows: make([]SybOrderView, 0, len(rows)),
|
||||
Total: total,
|
||||
HasAny: hasAny > 0,
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user