From 9d4d6fc1e53c8dfc432aa4966857bfb12c46e36b Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 14:39:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E9=A1=BA=E8=BF=90?= =?UTF-8?q?=E5=AE=9D=E5=BA=97=E9=93=BA=E7=AD=9B=E9=80=89=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/others.go | 60 ++++++++++++++----- admin/handler/web/others_test.go | 12 ++-- admin/handler/web/pagination_test.go | 4 +- admin/main_test.go | 2 +- admin/model/model.go | 1 + admin/repository/db.go | 39 +++++++++++- admin/repository/migrate_test.go | 37 +++++++++++- admin/repository/mysql_db.go | 47 ++++++++++++++- admin/repository/mysql_db_integration_test.go | 35 +++++++++++ admin/repository/sqlite_to_mysql.go | 2 +- admin/repository/syb.go | 28 +++++---- admin/repository/syb_test.go | 19 ++++-- admin/service/purchase_workflow_test.go | 2 +- admin/service/syb.go | 24 +++++--- admin/service/syb_test.go | 5 ++ admin/service/syb_workflow_test.go | 21 ++++++- admin/templates/syb/detail_modal.html | 3 + admin/templates/syb/list.html | 13 +++- docs/admin/03-data-model.md | 8 +++ docs/admin/05-ui-specification.md | 7 ++- 20 files changed, 313 insertions(+), 56 deletions(-) diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 0e64e26..10b3ea8 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -53,8 +53,9 @@ func sybLoginHint(ocrURL string) string { // 是配置错了还是服务挂了,不能弹一个空的手工输入框了事。 func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, msg, loginReason string) { pageNum := service.ParsePage(pageRaw) + shop := strings.TrimSpace(c.Query("shop")) stage := service.ParseSybStage(c.Query("stage")) - result, err := service.ListSybOrdersView(h.db, keyword, stage, pageNum) + result, err := service.ListSybOrdersView(h.db, keyword, shop, stage, pageNum) if err != nil { fail(c, http.StatusInternalServerError, "读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") @@ -88,6 +89,9 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, if stage != "" { values.Set("stage", stage) } + if shop != "" { + values.Set("shop", shop) + } detailValues := url.Values{} if keyword != "" { detailValues.Set("order_no", keyword) @@ -95,6 +99,9 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, if stage != "" { detailValues.Set("stage", stage) } + if shop != "" { + detailValues.Set("shop", shop) + } detailValues.Set("page", strconv.Itoa(result.Page)) // `[必须]` 密码不读出来显示、也不回显到 HTML,见工单 #46。 @@ -155,6 +162,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{ "Keyword": keyword, + "ShopFilter": shop, "StageFilter": stage, "StageOptions": service.SybStageOptions(), "CurrentPage": result.Page, @@ -176,11 +184,11 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, "SyncHistory": history, "NeedSyncHistory": c.Query("history") == "1", "HistoryFetchURL": sybHistoryPartialURL(history.Page, - keyword, dateFrom, dateTo), + keyword, shop, stage, dateFrom, dateTo), "HistoryPagination": sybHistoryPagination(history.Page, history.TotalPages, - keyword, dateFrom, dateTo), + keyword, shop, stage, dateFrom, dateTo), "HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages, - keyword, dateFrom, dateTo), + keyword, shop, stage, dateFrom, dateTo), "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), "DetailURL": "/syb/detail?" + detailValues.Encode(), "AssignableClients": purchaseClients.Rows, @@ -201,7 +209,7 @@ func (h *Handler) SybDetail(c *gin.Context) { } c.HTML(http.StatusOK, "syb/detail_modal", gin.H{ "D": detail, "CSRFToken": csrfToken(c), - "Keyword": c.Query("order_no"), "StageFilter": c.Query("stage"), + "Keyword": c.Query("order_no"), "ShopFilter": c.Query("shop"), "StageFilter": c.Query("stage"), "CurrentPage": service.ParsePage(c.Query("page")), }) } @@ -211,6 +219,8 @@ func (h *Handler) SybDetail(c *gin.Context) { func (h *Handler) SybSyncHistory(c *gin.Context) { c.Header("Cache-Control", "no-store") keyword := c.Query("order_no") + shop := strings.TrimSpace(c.Query("shop")) + stage := service.ParseSybStage(c.Query("stage")) dateFrom := c.Query("date_from") dateTo := c.Query("date_to") history, err := service.ListSybSyncHistory(h.db, service.ParsePage(c.Query("history_page"))) @@ -221,16 +231,16 @@ func (h *Handler) SybSyncHistory(c *gin.Context) { c.HTML(http.StatusOK, "syb/history_partial", gin.H{ "SyncHistory": history, "HistoryFetchURL": sybHistoryPartialURL(history.Page, - keyword, dateFrom, dateTo), + keyword, shop, stage, dateFrom, dateTo), "HistoryPagination": sybHistoryPagination(history.Page, history.TotalPages, - keyword, dateFrom, dateTo), + keyword, shop, stage, dateFrom, dateTo), "HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages, - keyword, dateFrom, dateTo), + keyword, shop, stage, dateFrom, dateTo), }) } -func sybHistoryPagination(pageNum, totalPages int, keyword, dateFrom, dateTo string) service.PaginationView { - link := func(target int) string { return sybHistoryURL(target, keyword, dateFrom, dateTo) } +func sybHistoryPagination(pageNum, totalPages int, keyword, shop, stage, dateFrom, dateTo string) service.PaginationView { + link := func(target int) string { return sybHistoryURL(target, keyword, shop, stage, dateFrom, dateTo) } view := service.PaginationView{ Page: pageNum, TotalPages: totalPages, HasPrev: pageNum > 1, HasNext: pageNum < totalPages, FirstURL: link(1), LastURL: link(totalPages), @@ -244,18 +254,24 @@ func sybHistoryPagination(pageNum, totalPages int, keyword, dateFrom, dateTo str return view } -func sybHistoryURL(pageNum int, keyword, dateFrom, dateTo string) string { +func sybHistoryURL(pageNum int, keyword, shop, stage, dateFrom, dateTo string) string { values := url.Values{"history": {"1"}, "history_page": {strconv.Itoa(pageNum)}} if keyword != "" { values.Set("order_no", keyword) } + if shop != "" { + values.Set("shop", shop) + } + if stage != "" { + values.Set("stage", stage) + } values.Set("date_from", dateFrom) values.Set("date_to", dateTo) return "/syb?" + values.Encode() } -func sybHistoryPartialPagination(pageNum, totalPages int, keyword, dateFrom, dateTo string) service.PaginationView { - link := func(target int) string { return sybHistoryPartialURL(target, keyword, dateFrom, dateTo) } +func sybHistoryPartialPagination(pageNum, totalPages int, keyword, shop, stage, dateFrom, dateTo string) service.PaginationView { + link := func(target int) string { return sybHistoryPartialURL(target, keyword, shop, stage, dateFrom, dateTo) } view := service.PaginationView{ Page: pageNum, TotalPages: totalPages, HasPrev: pageNum > 1, HasNext: pageNum < totalPages, FirstURL: link(1), LastURL: link(totalPages), @@ -269,11 +285,17 @@ func sybHistoryPartialPagination(pageNum, totalPages int, keyword, dateFrom, dat return view } -func sybHistoryPartialURL(pageNum int, keyword, dateFrom, dateTo string) string { +func sybHistoryPartialURL(pageNum int, keyword, shop, stage, dateFrom, dateTo string) string { values := url.Values{"history_page": {strconv.Itoa(pageNum)}} if keyword != "" { values.Set("order_no", keyword) } + if shop != "" { + values.Set("shop", shop) + } + if stage != "" { + values.Set("stage", stage) + } values.Set("date_from", dateFrom) values.Set("date_to", dateTo) return "/syb/sync-history?" + values.Encode() @@ -285,6 +307,8 @@ func sybStatusLine(result *service.SybListResult) string { prefix := fmt.Sprintf("共 %d 条货运单明细", result.Total) 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) } if !result.HasAny { return "还没有货运单明细。点上方「同步」从顺运宝拉取。" @@ -521,6 +545,12 @@ func (h *Handler) sybRedirectAfterStart(c *gin.Context, msg string) { if keyword := strings.TrimSpace(c.PostForm("order_no")); keyword != "" { params.Set("order_no", keyword) } + if shop := strings.TrimSpace(c.PostForm("shop")); shop != "" { + params.Set("shop", shop) + } + if stage := strings.TrimSpace(c.PostForm("stage")); stage != "" { + params.Set("stage", stage) + } c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode()) } @@ -553,7 +583,7 @@ func (h *Handler) sybRedirectRange(c *gin.Context, message string) { // appendSybFormState 保存同步/登录跳转后仍需要的页面状态。 func appendSybFormState(params url.Values, c *gin.Context) { - for _, name := range []string{"order_no", "stage", "page", "date_from", "date_to"} { + for _, name := range []string{"order_no", "shop", "stage", "page", "date_from", "date_to"} { if value := strings.TrimSpace(c.PostForm(name)); value != "" { params.Set(name, value) } diff --git a/admin/handler/web/others_test.go b/admin/handler/web/others_test.go index 4197261..ac2a3f8 100644 --- a/admin/handler/web/others_test.go +++ b/admin/handler/web/others_test.go @@ -88,14 +88,14 @@ func TestSybRedirectAfterStart_清除本次日期范围(t *testing.T) { } func TestSybHistoryPagination_保留筛选并重开弹窗(t *testing.T) { - view := sybHistoryPagination(2, 3, "ORDER-1", "2026-08-01", "2026-08-09") + view := sybHistoryPagination(2, 3, "ORDER-1", "店铺A", "pdd_missing", "2026-08-01", "2026-08-09") for _, raw := range []string{view.FirstURL, view.PrevURL, view.NextURL, view.LastURL} { location, err := url.Parse(raw) if err != nil { t.Fatal(err) } query := location.Query() - if query.Get("history") != "1" || query.Get("order_no") != "ORDER-1" || + if query.Get("history") != "1" || query.Get("order_no") != "ORDER-1" || query.Get("shop") != "店铺A" || query.Get("stage") != "pdd_missing" || query.Get("date_from") != "2026-08-01" || query.Get("date_to") != "2026-08-09" { t.Fatalf("同步记录翻页链接丢失页面状态: %s", raw) } @@ -103,13 +103,14 @@ func TestSybHistoryPagination_保留筛选并重开弹窗(t *testing.T) { } func TestSybHistoryURL_刷新时保留当前页和筛选(t *testing.T) { - location, err := url.Parse(sybHistoryURL(3, "ORDER-1", "2026-08-01", "2026-08-09")) + location, err := url.Parse(sybHistoryURL(3, "ORDER-1", "店铺A", "pdd_missing", "2026-08-01", "2026-08-09")) if err != nil { t.Fatal(err) } query := location.Query() for name, want := range map[string]string{ "history": "1", "history_page": "3", "order_no": "ORDER-1", + "shop": "店铺A", "stage": "pdd_missing", "date_from": "2026-08-01", "date_to": "2026-08-09", } { if got := query.Get(name); got != want { @@ -119,7 +120,7 @@ func TestSybHistoryURL_刷新时保留当前页和筛选(t *testing.T) { } func TestSybHistoryPartialURL_只读片段保留当前页和筛选(t *testing.T) { - location, err := url.Parse(sybHistoryPartialURL(3, "ORDER-1", "2026-08-01", "2026-08-09")) + location, err := url.Parse(sybHistoryPartialURL(3, "ORDER-1", "店铺A", "pdd_missing", "2026-08-01", "2026-08-09")) if err != nil { t.Fatal(err) } @@ -129,6 +130,7 @@ func TestSybHistoryPartialURL_只读片段保留当前页和筛选(t *testing.T) query := location.Query() for name, want := range map[string]string{ "history_page": "3", "order_no": "ORDER-1", + "shop": "店铺A", "stage": "pdd_missing", "date_from": "2026-08-01", "date_to": "2026-08-09", } { if got := query.Get(name); got != want { @@ -138,7 +140,7 @@ func TestSybHistoryPartialURL_只读片段保留当前页和筛选(t *testing.T) } func TestSybHistoryPartialPagination_翻页继续使用片段路由(t *testing.T) { - view := sybHistoryPartialPagination(2, 3, "ORDER-1", "2026-08-01", "2026-08-09") + view := sybHistoryPartialPagination(2, 3, "ORDER-1", "店铺A", "pdd_missing", "2026-08-01", "2026-08-09") for _, raw := range []string{view.FirstURL, view.PrevURL, view.NextURL, view.LastURL} { location, err := url.Parse(raw) if err != nil { diff --git a/admin/handler/web/pagination_test.go b/admin/handler/web/pagination_test.go index c537c52..94189b8 100644 --- a/admin/handler/web/pagination_test.go +++ b/admin/handler/web/pagination_test.go @@ -52,11 +52,11 @@ func Test列表写操作跳转保留筛选和页码(t *testing.T) { }) t.Run("顺运宝", func(t *testing.T) { context, recorder := listPostContext(t, "/syb/associate-pdd", url.Values{ - "order_no": {"SYB-1"}, "stage": {"pdd_missing"}, "page": {"3"}, + "order_no": {"SYB-1"}, "shop": {"测试店铺"}, "stage": {"pdd_missing"}, "page": {"3"}, }) (&Handler{}).sybRedirect(context, "完成") assertRedirectQuery(t, recorder, "/syb", map[string]string{ - "order_no": "SYB-1", "stage": "pdd_missing", "page": "3", "msg": "完成", + "order_no": "SYB-1", "shop": "测试店铺", "stage": "pdd_missing", "page": "3", "msg": "完成", }) }) t.Run("PDD", func(t *testing.T) { diff --git a/admin/main_test.go b/admin/main_test.go index c0acc5e..817831e 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -340,7 +340,7 @@ func TestMainPagesReturnOK(t *testing.T) { "同步记录", "刷新同步记录", `data-syb-history-slot`, `data-history-fetch-url=`, `name="date_from"`, `name="date_to"`, - `name="stage"`, "处理阶段", "下一步", `data-detail-url=`, + `name="stage"`, "处理阶段", `name="shop"`, "店铺", "下一步", `data-detail-url=`, "按顺运宝货运单创建日期(UTC+8)同步", "创建真实采购任务", "所有当前账号可见的客户端均可指派", } { diff --git a/admin/model/model.go b/admin/model/model.go index 1f46e59..691a558 100644 --- a/admin/model/model.go +++ b/admin/model/model.go @@ -214,6 +214,7 @@ type ShopeeSKU struct { type SybOrder struct { SybID string OrderNo string + ShopName string // 顺运宝货运单级 shopName;同一货运单的商品明细相同 Title string ProductSpec string // 规格原文,顺运宝 productSpec,原样保留,对应 shopee_skus.spec_raw SpecKey string // ProductSpec 的稳定身份键;空规格保持 SQL NULL,读出时为空字符串 diff --git a/admin/repository/db.go b/admin/repository/db.go index 35060ee..c9edd93 100644 --- a/admin/repository/db.go +++ b/admin/repository/db.go @@ -265,7 +265,7 @@ var migrations = [][]string{ // 背景见 #20:v1 曾经被原地改写而不是新增版本,导致已经建过库的机器 // (user_version 已经越过 v1)永远不会重跑改写后的语句,程序拿着一个 // 和代码对不上的库静默启动。 -const schemaVersion = 8 +const schemaVersion = 9 // migrationV4 给 PDD 商品增加店铺名。 // @@ -380,6 +380,18 @@ var migrationV8 = []string{ ON syb_sync_runs(started_at DESC, run_id DESC);`, } +// migrationV9 把顺运宝原始 JSON 里的货运单店铺名提升为可展示、可筛选的独立列。 +// 原始 syb_data 永久保留;无效 JSON、缺字段和纯空白值都安全地保持 NULL。 +var migrationV9 = []string{ + `ALTER TABLE syb_orders ADD COLUMN shop_name TEXT;`, + `UPDATE syb_orders + SET shop_name = CASE + WHEN json_valid(syb_data) THEN NULLIF(TRIM(json_extract(syb_data, '$.stock.shopName')), '') + ELSE NULL + END + WHERE shop_name IS NULL;`, +} + // Migrate 把数据库升到最新版本。 // 已经是最新的就什么都不做,可以重复调用。 func Migrate(db *sql.DB) error { @@ -466,6 +478,14 @@ func Migrate(db *sql.DB) error { if err := runSQLMigration(db, 8, migrationV8); err != nil { return err } + reached = 8 + } + + // v9 增加顺运宝店铺列并从仍保留的原始 JSON 回填。 + if reached < 9 { + if err := runSQLMigration(db, 9, migrationV9); err != nil { + return err + } } return nil @@ -940,6 +960,12 @@ var requiredColumns = map[string][]string{ "syb_sync_runs": {"user_id", "date_from", "date_to", "status", "cursor_advanced", "started_at", "finished_at"}, } +// SQLite 当前测试库比 MySQL v1 多走一版迁移;这里单独检查,避免 MySQL +// 在执行 v1 自检时提前要求尚未由 v11 创建的列。 +var sqliteOnlyRequiredColumns = map[string][]string{ + "syb_orders": {"shop_name"}, +} + // CheckSchema 在 Migrate 成功后调用,确认代码依赖的表都在。 // // [必须] 缺表就返回错误,调用方要**拒绝启动**,不是打个警告继续跑。 @@ -996,5 +1022,16 @@ func CheckSchema(db *sql.DB) error { } } } + for table, columns := range sqliteOnlyRequiredColumns { + existingColumns, err := tableColumnSet(db, table) + if err != nil { + return fmt.Errorf("检查数据表 %s 的列失败: %w", table, err) + } + for _, column := range columns { + if !existingColumns[column] { + return fmt.Errorf("数据库结构与本程序不匹配:数据表 %s 缺少列 %s", table, column) + } + } + } return nil } diff --git a/admin/repository/migrate_test.go b/admin/repository/migrate_test.go index 0bb2722..97a3e50 100644 --- a/admin/repository/migrate_test.go +++ b/admin/repository/migrate_test.go @@ -657,7 +657,7 @@ func TestMigrate_v8新增顺运宝同步记录表(t *testing.T) { t.Fatal("v8 应该新增 syb_sync_runs 表") } var version int - if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil || version != 8 { + if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil || version != schemaVersion { t.Fatalf("迁移版本错误: version=%d err=%v", version, err) } user := model.User{ @@ -683,6 +683,41 @@ func TestMigrate_v8新增顺运宝同步记录表(t *testing.T) { } } +func TestMigrate_v9回填顺运宝店铺名(t *testing.T) { + db := newPublishedVersionDB(t, 7) + if err := runSQLMigration(db, 8, migrationV8); err != nil { + t.Fatal(err) + } + now := model.NowISO() + for _, row := range []struct{ id, raw string }{ + {"shop", `{"stock":{"shopName":" 测试店铺 "}}`}, + {"blank", `{"stock":{"shopName":" "}}`}, + {"missing", `{"stock":{}}`}, + {"invalid", `{not-json`}, + } { + if _, err := db.Exec(`INSERT INTO syb_orders + (syb_id,order_no,title,product_spec,quantity,syb_data,created_at,updated_at) + VALUES (?,?,'商品','黑色,M',1,?,?,?)`, row.id, row.id, row.raw, now, now); err != nil { + t.Fatal(err) + } + } + if err := Migrate(db); err != nil { + t.Fatalf("v8 升级 v9 失败: %v", err) + } + var shop sql.NullString + if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id='shop'`).Scan(&shop); err != nil || !shop.Valid || shop.String != "测试店铺" { + t.Fatalf("店铺回填错误: %+v err=%v", shop, err) + } + for _, id := range []string{"blank", "missing", "invalid"} { + if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id=?`, id).Scan(&shop); err != nil || shop.Valid { + t.Fatalf("%s 应保持 NULL: %+v err=%v", id, shop, err) + } + } + if err := Migrate(db); err != nil { + t.Fatalf("v9 重复迁移失败: %v", err) + } +} + func newPublishedVersionDB(t *testing.T, version int) *sql.DB { t.Helper() db, err := Open(t.TempDir()) diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index 8fb3ce5..df50e61 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -19,7 +19,7 @@ import ( "cmautobuy/admin/spec" ) -const mysqlSchemaVersion = 10 +const mysqlSchemaVersion = 11 // OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。 func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) { @@ -543,10 +543,50 @@ func MigrateMySQL(db *sql.DB) error { if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 10, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { return fmt.Errorf("记录 MySQL schema v10 失败: %w", err) } + current = 10 + } + if current < 11 { + if err := migrateMySQLV11(db); err != nil { + return fmt.Errorf("执行 MySQL schema v11 失败: %w", err) + } + if err := checkMySQLV11Shape(db); err != nil { + return fmt.Errorf("MySQL schema v11 自检失败,未记录版本: %w", err) + } + if _, err := db.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, 11, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("记录 MySQL schema v11 失败: %w", err) + } } return CheckMySQLSchema(db) } +// migrateMySQLV11 增加顺运宝店铺列,并从保留的原始 JSON 回填历史数据。 +// DDL 和 UPDATE 都可重放:中断后再次启动只补缺列和仍为空的记录。 +func migrateMySQLV11(db *sql.DB) error { + exists, err := mysqlColumnExists(db, "syb_orders", "shop_name") + if err != nil { + return err + } + if !exists { + if _, err := db.Exec(`ALTER TABLE syb_orders ADD COLUMN shop_name VARCHAR(500) NULL AFTER order_no`); err != nil { + return fmt.Errorf("增加 syb_orders.shop_name 失败: %w", err) + } + } + if _, err := db.Exec(`UPDATE syb_orders + SET shop_name=NULLIF(TRIM(JSON_UNQUOTE(JSON_EXTRACT( + IF(JSON_VALID(syb_data),syb_data,'{}'),'$.stock.shopName'))),'') + WHERE shop_name IS NULL`); err != nil { + return fmt.Errorf("回填顺运宝店铺名失败: %w", err) + } + return nil +} + +func checkMySQLV11Shape(db *sql.DB) error { + if err := checkMySQLVarcharColumn(db, "syb_orders", "shop_name", 500, true, "utf8mb4_0900_ai_ci", ""); err != nil { + return err + } + return checkMySQLNullDefault(db, "syb_orders", "shop_name") +} + // migrateMySQLV10 修复曾在 v8/v9 开发中间状态记录过版本的数据库。 // 两版迁移均逐项检查结构,v8 回填只处理空值,因此可安全重放; // 历史 v8/v9 版本记录保持不变,完整自检通过后再追加 v10。 @@ -564,7 +604,10 @@ func checkMySQLV10Shape(db *sql.DB) error { if err := checkMySQLV8Shape(db); err != nil { return err } - return checkMySQLV9Shape(db) + if err := checkMySQLV9Shape(db); err != nil { + return err + } + return nil } func migrateMySQLV9(db *sql.DB) error { diff --git a/admin/repository/mysql_db_integration_test.go b/admin/repository/mysql_db_integration_test.go index 15743f9..e30dcb2 100644 --- a/admin/repository/mysql_db_integration_test.go +++ b/admin/repository/mysql_db_integration_test.go @@ -8,6 +8,7 @@ import ( "testing" "cmautobuy/admin/config" + "cmautobuy/admin/model" "cmautobuy/admin/spec" ) @@ -550,6 +551,40 @@ func TestMySQLMigrate_V10执行后未记版本可继续收敛(t *testing.T) { } } +func TestMySQLMigrate_V11回填顺运宝店铺且可重放(t *testing.T) { + db := openMySQLMigrationTestDB(t) + defer db.Close() + cleanMySQLTestSchema(t, db) + defer cleanMySQLTestSchema(t, db) + if err := MigrateMySQL(db); err != nil { + t.Fatal(err) + } + mustExec(t, db, `DELETE FROM schema_migrations WHERE version=11`) + mustExec(t, db, `ALTER TABLE syb_orders DROP COLUMN shop_name`) + now := model.NowISO() + mustExec(t, db, `INSERT INTO syb_orders + (syb_id,order_no,title,product_spec,quantity,syb_data,created_at,updated_at) VALUES + ('S1','O1','商品','黑色,M',1,'{"stock":{"shopName":" 测试店铺 "}}',?,?), + ('S2','O2','商品','白色,L',1,'{not-json',?,?)`, now, now, now, now) + if err := MigrateMySQL(db); err != nil { + t.Fatalf("v10 升级 v11 失败: %v", err) + } + var shop sql.NullString + if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id='S1'`).Scan(&shop); err != nil || !shop.Valid || shop.String != "测试店铺" { + t.Fatalf("MySQL 店铺回填错误: %+v err=%v", shop, err) + } + if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id='S2'`).Scan(&shop); err != nil || shop.Valid { + t.Fatalf("无效 JSON 应保持 NULL: %+v err=%v", shop, err) + } + if err := MigrateMySQL(db); err != nil { + t.Fatalf("v11 重放失败: %v", err) + } + var count int + if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version=11`).Scan(&count); err != nil || count != 1 { + t.Fatalf("v11 版本记录错误: count=%d err=%v", count, err) + } +} + func openMySQLMigrationTestDB(t *testing.T) *sql.DB { t.Helper() if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" { diff --git a/admin/repository/sqlite_to_mysql.go b/admin/repository/sqlite_to_mysql.go index 9d7270f..308ab22 100644 --- a/admin/repository/sqlite_to_mysql.go +++ b/admin/repository/sqlite_to_mysql.go @@ -56,7 +56,7 @@ var sqliteMigrationTables = []sqliteMigrationTableSpec{ {name: "shopee_products", columns: []string{"goods_id", "title", "shopee_status", "main_sku_code", "pdd_goods_url", "pdd_goods_id", "created_at", "updated_at"}, primaryKey: []string{"goods_id"}}, {name: "pdd_products", columns: []string{"id", "goods_id", "url", "title", "shop_name", "skus_json", "collect_status", "collect_msg", "artifact_ref", "collected_at", "deleted_at", "created_at", "updated_at"}, primaryKey: []string{"id"}}, {name: "shopee_skus", columns: []string{"sku_id", "goods_id", "spec_raw", "color", "size", "advice", "parse_ok", "sku_code", "is_manual", "created_at", "updated_at"}, primaryKey: []string{"sku_id"}}, - {name: "syb_orders", columns: []string{"syb_id", "order_no", "title", "shopee_goods_id", "shopee_sku_id", "product_spec", "quantity", "price_twd_cent", "image_url", "syb_data", "created_at", "updated_at"}, primaryKey: []string{"syb_id"}}, + {name: "syb_orders", columns: []string{"syb_id", "order_no", "shop_name", "title", "shopee_goods_id", "shopee_sku_id", "product_spec", "quantity", "price_twd_cent", "image_url", "syb_data", "created_at", "updated_at"}, primaryKey: []string{"syb_id"}}, {name: "sku_mappings", columns: []string{"shopee_sku_id", "pdd_goods_id", "pdd_option_key", "pdd_options", "goods_id", "mapped_at", "mapped_by"}, primaryKey: []string{"shopee_sku_id", "pdd_goods_id"}}, {name: "tasks", columns: []string{"task_id", "task_type", "status", "version", "priority", "assigned_client", "claimed_at", "syb_id", "order_no", "goods_id", "shopee_sku_id", "pdd_goods_url", "pdd_goods_id", "pdd_options", "quantity", "max_price_cent", "result_data", "error_code", "error_message", "finished_at", "created_at", "updated_at"}, primaryKey: []string{"task_id"}}, {name: "task_claims", columns: []string{"task_id", "client_id", "claimed_at"}, primaryKey: []string{"task_id", "client_id"}}, diff --git a/admin/repository/syb.go b/admin/repository/syb.go index e42ebc7..62f6687 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -274,11 +274,12 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) { } _, err = q.Exec(` INSERT INTO syb_orders - (syb_id, order_no, title, product_spec, spec_key, shopee_goods_id, + (syb_id, order_no, shop_name, title, product_spec, spec_key, shopee_goods_id, quantity, price_twd_cent, image_url, syb_data, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE order_no = VALUES(order_no), + shop_name = VALUES(shop_name), title = VALUES(title), product_spec = VALUES(product_spec), spec_key = VALUES(spec_key), @@ -288,7 +289,7 @@ func UpsertSybOrder(q Execer, o model.SybOrder) (created bool, err error) { image_url = VALUES(image_url), syb_data = VALUES(syb_data), updated_at = VALUES(updated_at)`, - o.SybID, o.OrderNo, o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID), + o.SybID, o.OrderNo, nullableText(o.ShopName), o.Title, nullableText(o.ProductSpec), specKey, nullableText(o.ShopeeGoodsID), o.Quantity, o.PriceTwdCent, nullableText(o.ImageURL), o.SybData, now, now) if err != nil { @@ -353,6 +354,7 @@ func nullableText(s string) any { // SybOrderFilter 是货运单列表页支持的筛选条件。 type SybOrderFilter struct { Keyword string // 匹配订单号或商品标题 + Shop string // 店铺名模糊匹配;空字符串表示全部 Stage string // 处理阶段;空字符串表示全部 } @@ -364,6 +366,10 @@ func sybOrderFilterClause(filter SybOrderFilter) (string, []any) { clauses = append(clauses, `(so.order_no LIKE ? ESCAPE '!' OR so.title LIKE ? ESCAPE '!')`) args = append(args, like, like) } + if shop := strings.TrimSpace(filter.Shop); shop != "" { + clauses = append(clauses, `so.shop_name LIKE ? ESCAPE '!'`) + args = append(args, "%"+escapeLike(shop)+"%") + } switch filter.Stage { case "spec_missing": clauses = append(clauses, `so.spec_key IS NULL`) @@ -405,19 +411,20 @@ type SybOrderContext struct { func scanSybOrderContext(s rowScanner) (SybOrderContext, error) { var c SybOrderContext - var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString + var shopName, title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString var priceCent sql.NullInt64 var shopeeExists int var pddGoodsID, pddGoodsURL, collectStatus, collectMsg, skusJSON, pddUpdatedAt sql.NullString var mappingKey, mappingOptions sql.NullString var hasActiveTask int err := s.Scan( - &c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID, + &c.Order.SybID, &c.Order.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID, &c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData, &c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists, &pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, &skusJSON, &pddUpdatedAt, &mappingKey, &mappingOptions, &hasActiveTask, ) + c.Order.ShopName = shopName.String c.Order.Title = title.String c.Order.ProductSpec = productSpec.String c.Order.SpecKey = specKey.String @@ -441,7 +448,7 @@ func scanSybOrderContext(s rowScanner) (SybOrderContext, error) { func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([]SybOrderContext, error) { where, args := sybOrderFilterClause(filter) query := ` - SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key, + SELECT so.syb_id, so.order_no, so.shop_name, so.title, so.product_spec, so.spec_key, so.shopee_goods_id, so.quantity, so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at, CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END, @@ -475,7 +482,7 @@ func ListSybOrderContexts(q Execer, filter SybOrderFilter, limit, offset int) ([ // GetSybOrderContext 按明细 ID 读取一条处理上下文。 func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) { row, err := scanSybOrderContext(q.QueryRow(` - SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key, + SELECT so.syb_id, so.order_no, so.shop_name, so.title, so.product_spec, so.spec_key, so.shopee_goods_id, so.quantity, so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at, CASE WHEN sp.goods_id IS NULL THEN 0 ELSE 1 END, @@ -498,7 +505,7 @@ func GetSybOrderContext(q Execer, sybID string) (*SybOrderContext, error) { func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) { where, args := sybOrderFilterClause(filter) sqlText := ` - SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.spec_key, so.shopee_goods_id, + SELECT so.syb_id, so.order_no, so.shop_name, so.title, so.product_spec, so.spec_key, so.shopee_goods_id, so.quantity, so.price_twd_cent, so.image_url, so.syb_data, so.created_at, so.updated_at` + sybOrderContextFrom + where + ` ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?` @@ -513,14 +520,15 @@ func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model. var list []model.SybOrder for rows.Next() { var o model.SybOrder - var title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString + var shopName, title, productSpec, specKey, shopeeGoodsID, imageURL sql.NullString var priceCent sql.NullInt64 if err := rows.Scan( - &o.SybID, &o.OrderNo, &title, &productSpec, &specKey, &shopeeGoodsID, + &o.SybID, &o.OrderNo, &shopName, &title, &productSpec, &specKey, &shopeeGoodsID, &o.Quantity, &priceCent, &imageURL, &o.SybData, &o.CreatedAt, &o.UpdatedAt, ); err != nil { return nil, fmt.Errorf("读取顺运宝货运单列表失败: %w", err) } + o.ShopName = shopName.String o.Title = title.String o.ProductSpec = productSpec.String o.SpecKey = specKey.String diff --git a/admin/repository/syb_test.go b/admin/repository/syb_test.go index 704f19f..60669dd 100644 --- a/admin/repository/syb_test.go +++ b/admin/repository/syb_test.go @@ -346,17 +346,17 @@ func TestInterruptRunningSybSyncRuns_只中断未完成记录(t *testing.T) { } } -func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) { +func TestListSybOrders_订单标题店铺组合筛选(t *testing.T) { db := newSybTestDB(t) - mustUpsert := func(sybID, orderNo, title string) { + mustUpsert := func(sybID, orderNo, shop, title string) { if _, err := UpsertSybOrder(db, model.SybOrder{ - SybID: sybID, OrderNo: orderNo, Title: title, Quantity: 1, SybData: "{}", + SybID: sybID, OrderNo: orderNo, ShopName: shop, Title: title, Quantity: 1, SybData: "{}", }); err != nil { t.Fatalf("写入 %s 失败: %v", sybID, err) } } - mustUpsert("SYB-A", "260728AAA", "纯棉上衣") - mustUpsert("SYB-B", "260728BBB", "牛仔裤") + mustUpsert("SYB-A", "260728AAA", "台北服饰旗舰店", "纯棉上衣") + mustUpsert("SYB-B", "260728BBB", "台南生活馆", "牛仔裤") rows, err := ListSybOrders(db, SybOrderFilter{Keyword: "AAA"}, 20, 0) if err != nil { @@ -373,4 +373,13 @@ func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) { if len(rows2) != 1 || rows2[0].SybID != "SYB-B" { t.Fatalf("按标题筛选应该只查到 SYB-B,实际 %+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: "台南"}) + if err != nil || count != 1 { + t.Fatalf("标题与店铺组合 COUNT 错误:count=%d err=%v", count, err) + } } diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index 916a991..3c3438c 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -139,7 +139,7 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) { if !detail.MappingValid { t.Fatal("保存后相同蝦皮商品规格 + 当前 PDD 商品应自动复用") } - list, err := ListSybOrdersView(db, "", SybStagePurchaseReady, 1) + list, err := ListSybOrdersView(db, "", "", SybStagePurchaseReady, 1) if err != nil { t.Fatal(err) } diff --git a/admin/service/syb.go b/admin/service/syb.go index 295b3bd..19cfad6 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -775,6 +775,7 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail order := model.SybOrder{ SybID: sybID, OrderNo: detail.Code, + ShopName: trimmedStringField(stockRaw, "shopName"), Title: item.ProductTitle, ProductSpec: item.ProductSpec, ShopeeGoodsID: strconv.FormatInt(item.ProductID, 10), @@ -799,6 +800,13 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail return tx.Commit() } +// trimmedStringField 读取顺运宝原始响应中的文本字段。 +// 类型不对、缺字段或只有空白时都按空值处理,不猜测或格式化上游数据。 +func trimmedStringField(raw map[string]any, name string) string { + value, _ := raw[name].(string) + return strings.TrimSpace(value) +} + // mergeRaw 合并"货运单列表"和"货运单明细"两次响应里同一张货运单的 // 外层字段(不含 details),后者字段优先覆盖前者——明细响应更贴近 // "拉这批数据当下"的状态,见工单 #46 字段映射表 syb_data 的说明。 @@ -919,6 +927,7 @@ func EnsureSybSession(db *sql.DB, client *syb.Client, username string, now time. type SybOrderView struct { SybID string OrderNo string + ShopName string Title string ProductSpec string ShopeeGoodsID string @@ -1031,13 +1040,14 @@ type SybListResult struct { // // `[必须]` 处理阶段由顺运宝规格键、PDD 关联、采集状态和规格映射实时推导, // 不额外保存一份容易过期的状态字段。 -func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) { +func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) { + shop = strings.TrimSpace(shop) stage = ParseSybStage(stage) if stage == SybStageMappingPending || stage == SybStagePurchaseReady || stage == SybStageTaskCreated || stage == SybStagePurchaseBlocked { - return listAdvancedSybStage(db, keyword, stage, page) + return listAdvancedSybStage(db, keyword, shop, stage, page) } - filter := repository.SybOrderFilter{Keyword: keyword, Stage: stage} + filter := repository.SybOrderFilter{Keyword: keyword, Shop: shop, Stage: stage} total, err := repository.CountSybOrders(db, filter) if err != nil { return nil, err @@ -1060,7 +1070,7 @@ func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListRes Rows: make([]SybOrderView, 0, len(rows)), Total: total, HasAny: hasAny > 0, - IsFiltered: strings.TrimSpace(keyword) != "" || stage != "", + IsFiltered: strings.TrimSpace(keyword) != "" || shop != "" || stage != "", Stage: stage, Page: page, TotalPages: totalPages, @@ -1075,9 +1085,9 @@ func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListRes // 这四个阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的 // OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表 // 和其余阶段仍在数据库分页。 -func listAdvancedSybStage(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) { +func listAdvancedSybStage(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) { contexts, err := repository.ListSybOrderContexts(db, - repository.SybOrderFilter{Keyword: keyword}, -1, 0) + repository.SybOrderFilter{Keyword: keyword, Shop: shop}, -1, 0) if err != nil { return nil, err } @@ -1114,7 +1124,7 @@ func listAdvancedSybStage(db *sql.DB, keyword, stage string, page int) (*SybList func sybOrderViewFor(context repository.SybOrderContext) SybOrderView { o := context.Order - v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title, + v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, ShopName: o.ShopName, Title: o.Title, ProductSpec: o.ProductSpec, ShopeeGoodsID: o.ShopeeGoodsID, Quantity: o.Quantity, ImageURL: o.ImageURL, UpdatedAt: formatLocalTime(o.UpdatedAt)} diff --git a/admin/service/syb_test.go b/admin/service/syb_test.go index ca519ca..457a73e 100644 --- a/admin/service/syb_test.go +++ b/admin/service/syb_test.go @@ -419,6 +419,11 @@ func TestRunSybSync_已有的ShopeeSKUID同步后仍在(t *testing.T) { t.Fatalf("同步后 shopee_sku_id 应该还是 MANUAL-MATCHED-SKU,实际 %q"+ "——人工匹配成果被顺运宝同步洗掉了", skuID) } + var shopName string + if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id = ?`, + "145306175").Scan(&shopName); err != nil || shopName != "测试店铺" { + t.Fatalf("同步应写入货运单店铺名,实际 %q err=%v", shopName, err) + } // 收件人信息不应该出现在 syb_data 里。 var sybData string diff --git a/admin/service/syb_workflow_test.go b/admin/service/syb_workflow_test.go index 4e117bc..344a5a0 100644 --- a/admin/service/syb_workflow_test.go +++ b/admin/service/syb_workflow_test.go @@ -58,7 +58,7 @@ func TestListSybOrdersView_空规格阻断且普通规格直接进入PDD阶段(t {SybStagePddMissing, "SYB-PDD"}, } for _, tc := range cases { - result, err := ListSybOrdersView(db, "", tc.stage, 1) + result, err := ListSybOrdersView(db, "", "", tc.stage, 1) if err != nil { t.Fatal(err) } @@ -71,6 +71,25 @@ func TestListSybOrdersView_空规格阻断且普通规格直接进入PDD阶段(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: `{}`}, + {SybID: "SHOP-B", OrderNo: "ORDER-B", ShopName: "台南生活馆", Title: "牛仔裤", ProductSpec: "", ShopeeGoodsID: "SP-B", Quantity: 1, SybData: `{}`}, + } { + if _, err := repository.UpsertSybOrder(db, order); err != nil { + t.Fatal(err) + } + } + result, err := ListSybOrdersView(db, "上衣", " 服饰 ", 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.IsFiltered { + t.Fatal("店铺条件应标记为筛选结果") + } +} + func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) { key, err := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"}) if err != nil { diff --git a/admin/templates/syb/detail_modal.html b/admin/templates/syb/detail_modal.html index 8728ac9..40bf58d 100644 --- a/admin/templates/syb/detail_modal.html +++ b/admin/templates/syb/detail_modal.html @@ -24,6 +24,7 @@ +
@@ -42,6 +43,7 @@ + {{if .CanCollect}} @@ -59,6 +61,7 @@ +
diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index fb8264c..8a81a8f 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -5,6 +5,7 @@
+ @@ -30,6 +31,9 @@ {{end}} + + {{/* search-narrow 把搜索框收到 30%,让「搜索」按钮紧挨着它。 用类名而不是改 .toolbar input[type="text"]——那条被五个页面共用。 */}} @@ -41,6 +45,7 @@ + @@ -88,6 +93,7 @@ 货运单 ID 订单号 + 店铺 商品标题 规格 蝦皮商品 ID @@ -108,6 +114,7 @@ {{.SybID}} {{.OrderNo}} + {{if .ShopName}}{{.ShopName}}{{else}}—{{end}} @@ -141,10 +148,10 @@ {{else}} - + {{if .IsFiltered}} 当前筛选条件下没有货运单明细。
- 换个订单号或清空搜索词再试。查看全部 + 换个店铺、订单号或清空筛选条件再试。查看全部 {{else}} 还没有货运单明细。
点上方「同步」从顺运宝拉取,第一次同步需要先登录(验证码需要手工输入)。 @@ -165,6 +172,7 @@ +