feat: 增加顺运宝店铺筛选 (#151)
This commit is contained in:
+45
-15
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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)同步",
|
||||
"创建真实采购任务", "所有当前账号可见的客户端均可指派",
|
||||
} {
|
||||
|
||||
@@ -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,读出时为空字符串
|
||||
|
||||
+38
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
+18
-10
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+17
-7
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="syb_id" value="{{.SybID}}">
|
||||
<input type="hidden" name="order_no" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="shop" value="{{$.ShopFilter}}">
|
||||
<input type="hidden" name="stage" value="{{$.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
<div class="field">
|
||||
@@ -42,6 +43,7 @@
|
||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||
<input type="hidden" name="syb_id" value="{{.SybID}}">
|
||||
<input type="hidden" name="order_no" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="shop" value="{{$.ShopFilter}}">
|
||||
<input type="hidden" name="stage" value="{{$.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
{{if .CanCollect}}
|
||||
@@ -59,6 +61,7 @@
|
||||
<input type="hidden" name="syb_id" value="{{.SybID}}">
|
||||
<input type="hidden" name="context_version" value="{{.ContextVersion}}">
|
||||
<input type="hidden" name="order_no" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="shop" value="{{$.ShopFilter}}">
|
||||
<input type="hidden" name="stage" value="{{$.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
<div class="field">
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<form class="inline sync-range-form" method="post" action="/syb/sync" data-syb-sync-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
<input type="hidden" name="shop" value="{{.ShopFilter}}">
|
||||
<input type="hidden" name="stage" value="{{.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||
<label for="sync-date-from">开始日期</label>
|
||||
@@ -30,6 +31,9 @@
|
||||
<option value="{{.Value}}" {{if eq .Value $.StageFilter}}selected{{end}}>{{.Text}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<label for="shop">店铺</label>
|
||||
<input id="shop" type="text" name="shop" value="{{.ShopFilter}}"
|
||||
class="search-narrow" placeholder="店铺名">
|
||||
<label for="q">订单号</label>
|
||||
{{/* search-narrow 把搜索框收到 30%,让「搜索」按钮紧挨着它。
|
||||
用类名而不是改 .toolbar input[type="text"]——那条被五个页面共用。 */}}
|
||||
@@ -41,6 +45,7 @@
|
||||
<form class="inline" method="post" action="/syb/delete" data-confirm-delete>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
<input type="hidden" name="shop" value="{{.ShopFilter}}">
|
||||
<input type="hidden" name="stage" value="{{.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||
<button type="submit" class="danger" data-need-checked>删除</button>
|
||||
@@ -88,6 +93,7 @@
|
||||
<th class="col-check"><input type="checkbox" data-check-all></th>
|
||||
<th>货运单 ID</th>
|
||||
<th>订单号</th>
|
||||
<th>店铺</th>
|
||||
<th>商品标题</th>
|
||||
<th>规格</th>
|
||||
<th>蝦皮商品 ID</th>
|
||||
@@ -108,6 +114,7 @@
|
||||
</td>
|
||||
<td>{{.SybID}}</td>
|
||||
<td>{{.OrderNo}}</td>
|
||||
<td class="truncate" title="{{.ShopName}}">{{if .ShopName}}{{.ShopName}}{{else}}—{{end}}</td>
|
||||
<td class="truncate" title="{{.Title}}">
|
||||
<button type="button" class="table-title-action" data-detail-open-id="{{.SybID}}"
|
||||
title="打开商品处理详情">{{.Title}}</button>
|
||||
@@ -141,10 +148,10 @@
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr class="empty">
|
||||
<td colspan="12">
|
||||
<td colspan="13">
|
||||
{{if .IsFiltered}}
|
||||
当前筛选条件下没有货运单明细。<br>
|
||||
<small>换个订单号或清空搜索词再试。<a href="/syb">查看全部</a></small>
|
||||
<small>换个店铺、订单号或清空筛选条件再试。<a href="/syb">查看全部</a></small>
|
||||
{{else}}
|
||||
还没有货运单明细。<br>
|
||||
<small>点上方「同步」从顺运宝拉取,第一次同步需要先登录(验证码需要手工输入)。</small>
|
||||
@@ -165,6 +172,7 @@
|
||||
<form method="post" action="/syb/create-task" data-purchase-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
<input type="hidden" name="shop" value="{{.ShopFilter}}">
|
||||
<input type="hidden" name="stage" value="{{.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||
<div class="modal-body form-stack">
|
||||
@@ -241,6 +249,7 @@
|
||||
<form method="post" action="/syb/login-and-sync">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="order_no" value="{{.Keyword}}">
|
||||
<input type="hidden" name="shop" value="{{.ShopFilter}}">
|
||||
<input type="hidden" name="stage" value="{{.StageFilter}}">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||
{{/* 登录和 OCR 降级后必须把工具栏里已确认的日期范围原样带回来。 */}}
|
||||
|
||||
@@ -106,6 +106,7 @@ SQLite 同一时刻只允许一个写事务,连接放太开会互相抢锁、
|
||||
| v6(#50) | Admin 网页登录:新增 `users` 和 `web_sessions`,只在迁移末尾追加,未改写 v1–v5。 |
|
||||
| v7(#54) | 客户端负责人:新增 `client_user_assignments` 和当前归属唯一索引,保留绑定、转交、解绑历史;未改写 v1–v6。 |
|
||||
| v8(#59) | 顺运宝同步记录:新增 `syb_sync_runs` 和开始时间倒序索引;未改写 v1–v7。 |
|
||||
| v9(#151) | `syb_orders` 增加可空 `shop_name`,从有效 `syb_data.stock.shopName` 回填历史店铺名;无效 JSON、缺字段和空白值保持 `NULL`。 |
|
||||
|
||||
**v3 为什么丢弃旧 `sku_mappings` 数据(见 #20):** 新主键需要 `pdd_option_key`,
|
||||
这是 Go 的 `service.OptionKey()` 用 `json.Marshal` 算出来的规范化键,SQL 语句
|
||||
@@ -401,6 +402,7 @@ UPDATE pdd_products
|
||||
CREATE TABLE syb_orders (
|
||||
syb_id TEXT PRIMARY KEY, -- 货运单**明细行** ID(顺运宝 details[].id)
|
||||
order_no TEXT NOT NULL, -- 订单号(顺运宝外层 code,不是 orderCode)
|
||||
shop_name TEXT, -- 蝦皮店铺(顺运宝外层 shopName)
|
||||
title TEXT, -- 商品标题
|
||||
product_spec TEXT, -- 顺运宝规格原文
|
||||
spec_key VARCHAR(191), -- 规格身份键;空规格保持 NULL
|
||||
@@ -422,6 +424,8 @@ CREATE INDEX idx_syb_orders_list ON syb_orders(updated_at DESC, syb_id DESC);
|
||||
- `price_twd_cent` 是**台币分**,是蝦皮那边的售价,
|
||||
和采购任务的人民币价格上限**没有换算关系**,不要互相赋值。
|
||||
- `image_url` 存 URL。`[必须]` 不要把图片二进制存进数据库。
|
||||
- `shop_name` 是顺运宝货运单级事实,同一货运单下的多条商品明细会重复保存同一值;
|
||||
独立列用于列表展示和检索,完整上游值仍保留在 `syb_data.stock.shopName`。
|
||||
- `spec_key` 通过 `SpecKey(product_spec)` 计算:去除首尾空白,把连续空白折叠为一个半角空格,
|
||||
但不做大小写、繁简、颜色或单位转换。空规格不生成键,并在采购流程中作为数据异常阻断。
|
||||
- **处理阶段是派生的,不存字段**:PDD 规格是否已匹配要按
|
||||
@@ -450,6 +454,10 @@ v10 幂等重放 v8、v9 的逐项建列、空值回填、约束和唯一索引
|
||||
形状;只有全部通过才记录 v10。它不删除或改写旧迁移记录,也不清空商品、SKU 或
|
||||
导入记录。
|
||||
|
||||
MySQL schema v11(工单 #151)为 `syb_orders` 增加可空 `VARCHAR(500)` 的
|
||||
`shop_name`,并从合法原始 JSON 的 `$.stock.shopName` 回填历史数据。迁移可重放,
|
||||
只填仍为空的列;新同步直接更新该列,不把它写入或覆盖蝦皮商品主数据。
|
||||
|
||||
蝦皮详情把 `syb_orders` 按 `(shopee_goods_id, spec_key)` 聚合为“顺运宝观测规格”:
|
||||
订单数按唯一 `syb_id` 行计数,累计数量求和,最近一行提供历史台币售价和图片。
|
||||
该读模型不写入 `shopee_skus`。只有同一商品下恰好一条正式 SKU 的 `spec_raw`
|
||||
|
||||
@@ -449,7 +449,7 @@ Go 的 map 是无序的,不靠它定顺序的话,同一个商品每次刷新
|
||||
|
||||
```text
|
||||
开始日期 [2026-08-07] 结束日期 [2026-08-09] [同步] [同步记录]
|
||||
[创建采购任务] 订单号 [___] [搜索] [删除]
|
||||
[创建采购任务] 处理阶段 [全部] 店铺 [___] 订单号 [___] [搜索] [删除]
|
||||
```
|
||||
|
||||
日期始终显示在主工具条,按 UTC+8 解释且两端都包含。通常默认最近 3 天;如果
|
||||
@@ -500,14 +500,17 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表
|
||||
记录时内容突然移动;F5 仍按浏览器默认行为刷新整个 SYB 页面。
|
||||
|
||||
`[必须]` 搜索框宽度见 [§3.1](#31-搜索框宽度)。
|
||||
店铺是独立的模糊搜索条件,可与处理阶段、订单号/商品标题组合;输入会去除首尾空白。
|
||||
翻页、同步、删除、详情内写操作和创建采购任务返回列表时都要保留店铺条件。
|
||||
|
||||
### 6.2 表格列
|
||||
|
||||
☐ / 货运单明细ID / 订单号 / 商品标题 / 规格 / 蝦皮商品ID / 数量 /
|
||||
☐ / 货运单明细ID / 订单号 / 店铺 / 商品标题 / 规格 / 蝦皮商品ID / 数量 /
|
||||
价格(台币)/ 图片 / **处理阶段** / **下一步** / 更新时间
|
||||
|
||||
- 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单
|
||||
可以有多个商品,各占一行。
|
||||
- 店铺显示顺运宝货运单的 `shopName`;长名称省略显示并通过单元格 `title` 查看全文。
|
||||
- 图片显示小缩略图。点击缩略图在弹窗中按比例查看顺运宝原图,并提供新窗口打开入口;
|
||||
图片加载失败时显示可读提示。`[必须]` 只存 URL,不代理、缓存或把图片塞进数据库。
|
||||
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分:
|
||||
|
||||
Reference in New Issue
Block a user