feat: 增加顺运宝店铺筛选 (#151)

This commit is contained in:
chengma
2026-08-11 14:39:13 +08:00
parent 30bdab3e2e
commit 9d4d6fc1e5
20 changed files with 313 additions and 56 deletions
+45 -15
View File
@@ -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)
}
+7 -5
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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)同步",
"创建真实采购任务", "所有当前账号可见的客户端均可指派",
} {
+1
View File
@@ -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
View File
@@ -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
}
+36 -1
View File
@@ -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())
+45 -2
View File
@@ -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" {
+1 -1
View File
@@ -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
View File
@@ -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
+14 -5
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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)}
+5
View File
@@ -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
+20 -1
View File
@@ -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 {
+3
View File
@@ -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">
+11 -2
View File
@@ -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 降级后必须把工具栏里已确认的日期范围原样带回来。 */}}
+8
View File
@@ -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`
+5 -2
View File
@@ -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,不代理、缓存或把图片塞进数据库。
- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分: