From c6ef54dce1399ad82d5fd2f3b0ea002f9632019a Mon Sep 17 00:00:00 2001 From: chengma Date: Sun, 9 Aug 2026 22:32:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E9=A1=BA=E8=BF=90?= =?UTF-8?q?=E5=AE=9D=E9=87=87=E8=B4=AD=E5=A4=84=E7=90=86=E7=8A=B6=E6=80=81?= =?UTF-8?q?=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/others.go | 52 +++++- admin/handler/web/web.go | 2 + admin/main_test.go | 1 + admin/repository/shopee.go | 27 +++ admin/repository/syb.go | 160 +++++++++++++++- admin/service/syb.go | 255 +++++++++++++++++++++++--- admin/service/syb_workflow_test.go | 137 ++++++++++++++ admin/static/js/app.js | 52 +++--- admin/templates/syb/detail_modal.html | 54 ++++++ admin/templates/syb/list.html | 37 +++- docs/admin/01-requirements.md | 8 +- docs/admin/05-ui-specification.md | 18 +- 12 files changed, 730 insertions(+), 73 deletions(-) create mode 100644 admin/service/syb_workflow_test.go create mode 100644 admin/templates/syb/detail_modal.html diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 0604b24..616242f 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -54,7 +54,8 @@ func sybLoginHint(ocrURL string) string { // 是配置错了还是服务挂了,不能弹一个空的手工输入框了事。 func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, msg, loginReason string) { pageNum := service.ParsePage(pageRaw) - result, err := service.ListSybOrdersView(h.db, keyword, pageNum) + stage := service.ParseSybStage(c.Query("stage")) + result, err := service.ListSybOrdersView(h.db, keyword, stage, pageNum) if err != nil { fail(c, http.StatusInternalServerError, "读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") @@ -80,6 +81,17 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, if keyword != "" { values.Set("order_no", keyword) } + if stage != "" { + values.Set("stage", stage) + } + detailValues := url.Values{} + if keyword != "" { + detailValues.Set("order_no", keyword) + } + if stage != "" { + detailValues.Set("stage", stage) + } + detailValues.Set("page", strconv.Itoa(result.Page)) // `[必须]` 密码不读出来显示、也不回显到 HTML,见工单 #46。 // 这里只取 username(只读展示)和 base_url 是否配置正确。 @@ -139,6 +151,9 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{ "Keyword": keyword, + "StageFilter": stage, + "StageOptions": service.SybStageOptions(), + "CurrentPage": result.Page, "Rows": result.Rows, "Status": status, "HasAny": result.HasAny, @@ -163,9 +178,28 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, "HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages, keyword, dateFrom, dateTo), "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "DetailURL": "/syb/detail?" + detailValues.Encode(), })) } +// SybDetail 返回一条货运单明细的采购处理弹窗片段。 +func (h *Handler) SybDetail(c *gin.Context) { + detail, err := service.GetSybProcessingDetail(h.db, c.Query("id")) + if err != nil { + fail(c, http.StatusInternalServerError, "读取顺运宝处理详情失败:"+err.Error()) + return + } + if detail == nil { + fail(c, http.StatusNotFound, "顺运宝货运单明细不存在。") + return + } + c.HTML(http.StatusOK, "syb/detail_modal", gin.H{ + "D": detail, "CSRFToken": csrfToken(c), + "Keyword": c.Query("order_no"), "StageFilter": c.Query("stage"), + "CurrentPage": service.ParsePage(c.Query("page")), + }) +} + // SybSyncHistory 只返回同步记录弹窗内部的 HTML。它只读本地数据库,不调用 // 顺运宝;完整页面和局部刷新共用同一份模板,避免字段或分页标记漂移。 func (h *Handler) SybSyncHistory(c *gin.Context) { @@ -243,6 +277,9 @@ func sybHistoryPartialURL(pageNum int, keyword, dateFrom, dateTo string) string // `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。 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) + } if !result.HasAny { return "还没有货运单明细。点上方「同步」从顺运宝拉取。" } @@ -510,13 +547,24 @@ func (h *Handler) sybRedirectRange(c *gin.Context, message string) { // appendSybFormState 保存同步/登录跳转后仍需要的页面状态。 func appendSybFormState(params url.Values, c *gin.Context) { - for _, name := range []string{"order_no", "date_from", "date_to"} { + for _, name := range []string{"order_no", "stage", "page", "date_from", "date_to"} { if value := strings.TrimSpace(c.PostForm(name)); value != "" { params.Set(name, value) } } } +// SybSelectSKU 保存采购员确认的蝦皮规格。Service 会再次验证 SKU 归属, +// 不能靠表单隐藏字段决定商品关系。 +func (h *Handler) SybSelectSKU(c *gin.Context) { + err := service.AssignSybShopeeSKU(h.db, c.PostForm("syb_id"), c.PostForm("shopee_sku_id")) + if err != nil { + h.sybRedirect(c, "蝦皮规格未保存:"+err.Error()) + return + } + h.sybRedirect(c, "蝦皮规格已确认,下一步按处理状态继续。") +} + // SybMatch 保存规格匹配结果。 // // 关键:保存的是**可复用的 SKU 映射**(写 sku_mappings), diff --git a/admin/handler/web/web.go b/admin/handler/web/web.go index 594b5a0..64e760e 100644 --- a/admin/handler/web/web.go +++ b/admin/handler/web/web.go @@ -71,10 +71,12 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) { // 3. 顺运宝数据 pages.GET("/syb", h.SybList) + pages.GET("/syb/detail", h.SybDetail) // 采购处理弹窗 pages.GET("/syb/sync-history", h.SybSyncHistory) // 弹窗局部刷新,只读本地同步记录 pages.GET("/syb/captcha", h.SybCaptcha) // 登录弹窗里的验证码图片 pages.POST("/syb/sync", h.SybSync) pages.POST("/syb/login-and-sync", h.SybLoginAndSync) + pages.POST("/syb/select-sku", h.SybSelectSKU) pages.POST("/syb/match", h.SybMatch) pages.POST("/syb/create-task", h.SybCreateTask) pages.POST("/syb/delete", h.SybDelete) diff --git a/admin/main_test.go b/admin/main_test.go index 0dcfbfc..e8ebf19 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -99,6 +99,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=`, "按顺运宝货运单创建日期(UTC+8)同步", } { if !strings.Contains(sybResponse.Body.String(), want) { diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index 50061c8..cf3ca65 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -301,6 +301,33 @@ func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error return list, rows.Err() } +// GetShopeeSKUByID 按规格 ID 查询一条蝦皮 SKU。查不到返回 (nil, nil)。 +func GetShopeeSKUByID(q Execer, skuID string) (*model.ShopeeSKU, error) { + var sk model.ShopeeSKU + var color, size, advice, skuCode sql.NullString + var parseOK, isManual int + err := q.QueryRow(` + SELECT sku_id, goods_id, spec_raw, color, size, advice, parse_ok, sku_code, + is_manual, created_at, updated_at + FROM shopee_skus WHERE sku_id = ?`, skuID).Scan( + &sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice, + &parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询蝦皮规格 %s 失败: %w", skuID, err) + } + sk.Color = color.String + sk.Size = size.String + sk.Advice = advice.String + sk.SKUCode = skuCode.String + sk.ParseOK = parseOK != 0 + sk.IsManual = isManual != 0 + return &sk, nil +} + // CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。 func CountShopeeProducts(q Execer) (int, error) { var n int diff --git a/admin/repository/syb.go b/admin/repository/syb.go index 474263c..da6bc38 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -8,6 +8,7 @@ import ( "database/sql" "errors" "fmt" + "strings" "cmautobuy/admin/model" ) @@ -288,25 +289,164 @@ func nullableText(s string) any { // SybOrderFilter 是货运单列表页支持的筛选条件。 type SybOrderFilter struct { Keyword string // 匹配订单号或商品标题 + Stage string // 处理阶段;空字符串表示全部 } func sybOrderFilterClause(filter SybOrderFilter) (string, []any) { - kw := filter.Keyword - if kw == "" { - return "", nil + var clauses []string + var args []any + if kw := filter.Keyword; kw != "" { + like := "%" + escapeLike(kw) + "%" + clauses = append(clauses, `(so.order_no LIKE ? ESCAPE '\' OR so.title LIKE ? ESCAPE '\')`) + args = append(args, like, like) } - like := "%" + escapeLike(kw) + "%" - return ` WHERE (order_no LIKE ? ESCAPE '\' OR title LIKE ? ESCAPE '\')`, []any{like, like} + switch filter.Stage { + case "missing_shopee": + clauses = append(clauses, `sp.goods_id IS NULL`) + case "sku_pending": + clauses = append(clauses, `sp.goods_id IS NOT NULL AND so.shopee_sku_id IS NULL`) + case "pdd_missing": + clauses = append(clauses, `so.shopee_sku_id IS NOT NULL AND (sp.pdd_goods_id IS NULL OR sp.pdd_goods_id = '' OR pp.goods_id IS NULL)`) + case "pdd_pending", "pdd_collecting", "pdd_failed", "pdd_collected": + clauses = append(clauses, `so.shopee_sku_id IS NOT NULL AND pp.collect_status = ?`) + args = append(args, strings.TrimPrefix(filter.Stage, "pdd_")) + } + if len(clauses) == 0 { + return "", args + } + return ` WHERE ` + strings.Join(clauses, ` AND `), args +} + +const sybOrderContextFrom = ` + FROM syb_orders so + LEFT JOIN shopee_products sp ON sp.goods_id = so.shopee_goods_id + LEFT JOIN pdd_products pp ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` + +// SybOrderContext 是顺运宝明细及其当前蝦皮/PDD 处理上下文。 +// 处理阶段由 service 计算,Repository 只提供数据库事实。 +type SybOrderContext struct { + Order model.SybOrder + ShopeeExists bool + PddGoodsID string + PddGoodsURL string + PddCollectStatus string + PddCollectMsg string +} + +func scanSybOrderContext(s rowScanner) (SybOrderContext, error) { + var c SybOrderContext + var title, productSpec, shopeeGoodsID, shopeeSKUID, imageURL sql.NullString + var priceCent sql.NullInt64 + var shopeeExists int + var pddGoodsID, pddGoodsURL, collectStatus, collectMsg sql.NullString + err := s.Scan( + &c.Order.SybID, &c.Order.OrderNo, &title, &productSpec, &shopeeGoodsID, &shopeeSKUID, + &c.Order.Quantity, &priceCent, &imageURL, &c.Order.SybData, + &c.Order.CreatedAt, &c.Order.UpdatedAt, &shopeeExists, + &pddGoodsID, &pddGoodsURL, &collectStatus, &collectMsg, + ) + c.Order.Title = title.String + c.Order.ProductSpec = productSpec.String + c.Order.ShopeeGoodsID = shopeeGoodsID.String + c.Order.ShopeeSKUID = shopeeSKUID.String + c.Order.PriceTwdCent = priceCent.Int64 + c.Order.ImageURL = imageURL.String + c.ShopeeExists = shopeeExists != 0 + c.PddGoodsID = pddGoodsID.String + c.PddGoodsURL = pddGoodsURL.String + c.PddCollectStatus = collectStatus.String + c.PddCollectMsg = collectMsg.String + return c, err +} + +// ListSybOrderContexts 分页读取采购处理工作台所需的关联事实。 +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.shopee_goods_id, so.shopee_sku_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, + sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg` + + sybOrderContextFrom + where + ` + ORDER BY so.updated_at DESC, so.syb_id DESC LIMIT ? OFFSET ?` + args = append(args, limit, offset) + rows, err := q.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("查询顺运宝采购处理列表失败: %w", err) + } + defer rows.Close() + list := make([]SybOrderContext, 0) + for rows.Next() { + row, err := scanSybOrderContext(rows) + if err != nil { + return nil, fmt.Errorf("读取顺运宝采购处理列表失败: %w", err) + } + list = append(list, row) + } + return list, rows.Err() +} + +// 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.shopee_goods_id, so.shopee_sku_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, + sp.pdd_goods_id, sp.pdd_goods_url, pp.collect_status, pp.collect_msg`+ + sybOrderContextFrom+` WHERE so.syb_id = ?`, sybID)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("查询顺运宝明细 %s 失败: %w", sybID, err) + } + return &row, nil +} + +// SetSybShopeeSKUIfEmpty 只为尚未确认规格的明细写入自动识别结果。 +func SetSybShopeeSKUIfEmpty(q Execer, sybID, skuID string) (bool, error) { + result, err := q.Exec(` + UPDATE syb_orders + SET shopee_sku_id = ?, updated_at = ? + WHERE syb_id = ? AND shopee_sku_id IS NULL + AND EXISTS ( + SELECT 1 FROM shopee_skus sk + WHERE sk.sku_id = ? AND sk.goods_id = syb_orders.shopee_goods_id + )`, skuID, model.NowISO(), sybID, skuID) + if err != nil { + return false, fmt.Errorf("自动确认顺运宝明细 %s 的蝦皮规格失败: %w", sybID, err) + } + n, err := result.RowsAffected() + return n > 0, err +} + +// SetSybShopeeSKU 在调用方完成商品归属校验后保存人工选择。 +func SetSybShopeeSKU(q Execer, sybID, skuID string) error { + result, err := q.Exec(`UPDATE syb_orders SET shopee_sku_id = ?, updated_at = ? WHERE syb_id = ?`, + skuID, model.NowISO(), sybID) + if err != nil { + return fmt.Errorf("保存顺运宝明细 %s 的蝦皮规格失败: %w", sybID, err) + } + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return fmt.Errorf("顺运宝明细 %s 不存在", sybID) + } + return nil } // ListSybOrders 按筛选条件分页查货运单明细列表,按更新时间倒序。 func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model.SybOrder, error) { where, args := sybOrderFilterClause(filter) sqlText := ` - SELECT syb_id, order_no, title, product_spec, shopee_goods_id, shopee_sku_id, - quantity, price_twd_cent, image_url, syb_data, created_at, updated_at - FROM syb_orders` + where + ` - ORDER BY updated_at DESC, syb_id DESC LIMIT ? OFFSET ?` + SELECT so.syb_id, so.order_no, so.title, so.product_spec, so.shopee_goods_id, so.shopee_sku_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 ?` args = append(args, limit, offset) rows, err := q.Query(sqlText, args...) @@ -343,7 +483,7 @@ func ListSybOrders(q Execer, filter SybOrderFilter, limit, offset int) ([]model. // 靠它,写成两份筛选条件迟早有一天会不一致(工单 #43 的教训)。 func CountSybOrders(q Execer, filter SybOrderFilter) (int, error) { where, args := sybOrderFilterClause(filter) - sqlText := `SELECT COUNT(*) FROM syb_orders` + where + sqlText := `SELECT COUNT(*)` + sybOrderContextFrom + where var n int if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil { return 0, fmt.Errorf("统计顺运宝货运单数量失败: %w", err) diff --git a/admin/service/syb.go b/admin/service/syb.go index 0d8f3cf..d93048a 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -784,6 +784,9 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail if err != nil { return err } + if _, err := AutoResolveSybShopeeSKU(tx, order); err != nil { + return err + } if created { report.Created++ } else { @@ -913,18 +916,91 @@ func EnsureSybSession(db *sql.DB, client *syb.Client, username string, now time. // SybOrderView 是列表页一行要显示的全部内容,已经格式化成字符串, // 模板里不做判断和格式化,和其余四个模块的做法一致。 type SybOrderView struct { - SybID string - OrderNo string - Title string - ProductSpec string - ShopeeGoodsID string - ShopeeSKUID string - Quantity int - PriceText string // "NT$239.00",和人民币价格一眼分得清 - ImageURL string - MatchText string // "已匹配" / "待匹配" - Matched bool - UpdatedAt string + SybID string + OrderNo string + Title string + ProductSpec string + ShopeeGoodsID string + ShopeeSKUID string + Quantity int + PriceText string // "NT$239.00",和人民币价格一眼分得清 + ImageURL string + Stage string + StageText string + StageHelp string + ActionText string + NeedsAttention bool + UpdatedAt string +} + +const ( + SybStageMissingShopee = "missing_shopee" + SybStageSKUPending = "sku_pending" + SybStagePddMissing = "pdd_missing" + SybStagePddPending = "pdd_pending" + SybStagePddCollecting = "pdd_collecting" + SybStagePddFailed = "pdd_failed" + SybStageMappingPending = "pdd_collected" +) + +// SybStageOption 是顺运宝处理阶段筛选项。 +type SybStageOption struct { + Value string + Text string +} + +func SybStageOptions() []SybStageOption { + return []SybStageOption{ + {Value: "", Text: "全部"}, + {Value: SybStageMissingShopee, Text: "未找到蝦皮商品"}, + {Value: SybStageSKUPending, Text: "待确认蝦皮规格"}, + {Value: SybStagePddMissing, Text: "未关联 PDD"}, + {Value: SybStagePddPending, Text: "PDD 待采集"}, + {Value: SybStagePddCollecting, Text: "PDD 采集中"}, + {Value: SybStagePddFailed, Text: "PDD 采集失败"}, + {Value: SybStageMappingPending, Text: "规格待匹配"}, + } +} + +func ParseSybStage(raw string) string { + for _, option := range SybStageOptions() { + if raw == option.Value { + return raw + } + } + return "" +} + +func SybStageLabel(stage string) string { + for _, option := range SybStageOptions() { + if option.Value == stage { + return option.Text + } + } + return "" +} + +func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string) { + switch { + case !c.ShopeeExists: + return SybStageMissingShopee, "未找到蝦皮商品", "请先导入或补充该蝦皮商品。", "查看" + case c.Order.ShopeeSKUID == "": + return SybStageSKUPending, "待确认蝦皮规格", "请选择与顺运宝规格原文对应的蝦皮 SKU。", "确认规格" + case c.PddGoodsID == "" || c.PddCollectStatus == "": + return SybStagePddMissing, "未关联 PDD", "蝦皮规格已确认,下一步关联采购商品。", "关联 PDD" + case c.PddCollectStatus == string(model.CollectPending): + return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集" + case c.PddCollectStatus == string(model.CollectCollecting): + return SybStagePddCollecting, "PDD 采集中", "客户端正在采集规格和价格,请稍后刷新。", "查看" + case c.PddCollectStatus == string(model.CollectFailed): + help := "PDD 采集失败,可以重新创建采集任务。" + if c.PddCollectMsg != "" { + help += " 原因:" + c.PddCollectMsg + } + return SybStagePddFailed, "PDD 采集失败", help, "重新采集" + default: + return SybStageMappingPending, "规格待匹配", "PDD 数据已采集,下一步匹配采购规格。", "匹配规格" + } } // SybListResult 是列表页要的全部数据。 @@ -933,6 +1009,7 @@ type SybListResult struct { Total int HasAny bool IsFiltered bool + Stage string Page int TotalPages int } @@ -942,8 +1019,9 @@ type SybListResult struct { // `[必须]` 匹配状态是**算出来的**(shopee_sku_id 是否非空), // 不是存的字段,见工单 #46——本工单不做规格匹配,这里只负责如实 // 显示"有没有"。 -func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, error) { - filter := repository.SybOrderFilter{Keyword: keyword} +func ListSybOrdersView(db *sql.DB, keyword, stage string, page int) (*SybListResult, error) { + stage = ParseSybStage(stage) + filter := repository.SybOrderFilter{Keyword: keyword, Stage: stage} total, err := repository.CountSybOrders(db, filter) if err != nil { return nil, err @@ -952,7 +1030,7 @@ func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, er page = ClampPage(page, totalPages) offset := (page - 1) * PageSize - rows, err := repository.ListSybOrders(db, filter, PageSize, offset) + rows, err := repository.ListSybOrderContexts(db, filter, PageSize, offset) if err != nil { return nil, err } @@ -966,11 +1044,13 @@ func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, er Rows: make([]SybOrderView, 0, len(rows)), Total: total, HasAny: hasAny > 0, - IsFiltered: strings.TrimSpace(keyword) != "", + IsFiltered: strings.TrimSpace(keyword) != "" || stage != "", + Stage: stage, Page: page, TotalPages: totalPages, } - for _, o := range rows { + for _, context := range rows { + o := context.Order v := SybOrderView{ SybID: o.SybID, OrderNo: o.OrderNo, @@ -982,6 +1062,8 @@ func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, er ImageURL: o.ImageURL, UpdatedAt: formatLocalTime(o.UpdatedAt), } + v.Stage, v.StageText, v.StageHelp, v.ActionText = sybStageFor(context) + v.NeedsAttention = v.Stage != SybStagePddCollecting if o.PriceTwdCent > 0 { v.PriceText = fmt.Sprintf("NT$%.2f", float64(o.PriceTwdCent)/100) } else { @@ -993,17 +1075,144 @@ func ListSybOrdersView(db *sql.DB, keyword string, page int) (*SybListResult, er if o.ProductSpec == "" { v.ProductSpec = placeholder } - if o.ShopeeSKUID != "" { - v.Matched = true - v.MatchText = "已匹配" - } else { - v.MatchText = "待匹配" - } result.Rows = append(result.Rows, v) } return result, nil } +// AutoResolveSybShopeeSKU 只接受确定性唯一候选:先比规格原文,再比解析后的 +// 颜色和尺码。零候选或多候选都不写库,交给采购员确认。 +func AutoResolveSybShopeeSKU(q repository.Execer, order model.SybOrder) (string, error) { + if order.ShopeeGoodsID == "" || order.ProductSpec == "" { + return "", nil + } + skus, err := repository.ListShopeeSKUsByGoodsID(q, order.ShopeeGoodsID) + if err != nil { + return "", err + } + var exact []model.ShopeeSKU + wantRaw := strings.TrimSpace(order.ProductSpec) + for _, sku := range skus { + if strings.TrimSpace(sku.SpecRaw) == wantRaw { + exact = append(exact, sku) + } + } + candidate := uniqueShopeeSKU(exact) + if candidate == nil { + color, size, _, ok := ParseSpec(order.ProductSpec) + if ok { + var parsed []model.ShopeeSKU + for _, sku := range skus { + if sku.ParseOK && strings.TrimSpace(sku.Color) == strings.TrimSpace(color) && + strings.TrimSpace(sku.Size) == strings.TrimSpace(size) { + parsed = append(parsed, sku) + } + } + candidate = uniqueShopeeSKU(parsed) + } + } + if candidate == nil { + return "", nil + } + written, err := repository.SetSybShopeeSKUIfEmpty(q, order.SybID, candidate.SKUID) + if err != nil || !written { + return "", err + } + return candidate.SKUID, nil +} + +func uniqueShopeeSKU(candidates []model.ShopeeSKU) *model.ShopeeSKU { + if len(candidates) != 1 { + return nil + } + return &candidates[0] +} + +// AssignSybShopeeSKU 保存人工选择,并在事务内验证 SKU 确实属于该蝦皮商品。 +func AssignSybShopeeSKU(db *sql.DB, sybID, skuID string) error { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("开始确认蝦皮规格事务失败: %w", err) + } + defer tx.Rollback() + context, err := repository.GetSybOrderContext(tx, strings.TrimSpace(sybID)) + if err != nil { + return err + } + if context == nil { + return fmt.Errorf("顺运宝明细不存在") + } + sku, err := repository.GetShopeeSKUByID(tx, strings.TrimSpace(skuID)) + if err != nil { + return err + } + if sku == nil || sku.GoodsID != context.Order.ShopeeGoodsID { + return fmt.Errorf("所选蝦皮规格不属于该商品,请刷新后重试") + } + if err := repository.SetSybShopeeSKU(tx, context.Order.SybID, sku.SKUID); err != nil { + return err + } + return tx.Commit() +} + +type SybSKUCandidateView struct { + SKUID string + SpecRaw string + Color string + Size string + Selected bool +} + +// SybProcessingDetail 是顺运宝“下一步”弹窗第一阶段需要的上下文。 +type SybProcessingDetail struct { + SybID string + OrderNo string + Title string + ProductSpec string + ShopeeGoodsID string + ShopeeSKUID string + Quantity int + Stage string + StageText string + StageHelp string + Candidates []SybSKUCandidateView + ShopeeExists bool +} + +func GetSybProcessingDetail(db *sql.DB, sybID string) (*SybProcessingDetail, error) { + context, err := repository.GetSybOrderContext(db, strings.TrimSpace(sybID)) + if err != nil || context == nil { + return nil, err + } + o := context.Order + d := &SybProcessingDetail{ + SybID: o.SybID, OrderNo: o.OrderNo, Title: o.Title, ProductSpec: o.ProductSpec, + ShopeeGoodsID: o.ShopeeGoodsID, ShopeeSKUID: o.ShopeeSKUID, + Quantity: o.Quantity, ShopeeExists: context.ShopeeExists, + } + d.Stage, d.StageText, d.StageHelp, _ = sybStageFor(*context) + if context.ShopeeExists { + skus, err := repository.ListShopeeSKUsByGoodsID(db, o.ShopeeGoodsID) + if err != nil { + return nil, err + } + for _, sku := range skus { + color, size := sku.Color, sku.Size + if color == "" { + color = placeholder + } + if size == "" { + size = placeholder + } + d.Candidates = append(d.Candidates, SybSKUCandidateView{ + SKUID: sku.SKUID, SpecRaw: sku.SpecRaw, Color: color, Size: size, + Selected: sku.SKUID == o.ShopeeSKUID, + }) + } + } + return d, nil +} + // SaveSybLoginSession 登录成功后把会话缓存进数据库。 // // `[必须]` 缓存写失败不能让已经登录的会话失效——本函数把错误原样 diff --git a/admin/service/syb_workflow_test.go b/admin/service/syb_workflow_test.go new file mode 100644 index 0000000..8f85729 --- /dev/null +++ b/admin/service/syb_workflow_test.go @@ -0,0 +1,137 @@ +package service + +import ( + "database/sql" + "testing" + + "cmautobuy/admin/model" + "cmautobuy/admin/repository" +) + +func seedWorkflowOrder(t *testing.T, db *sql.DB, sybID, goodsID, spec string) model.SybOrder { + t.Helper() + order := model.SybOrder{ + SybID: sybID, OrderNo: "ORDER-" + sybID, Title: "测试商品", + ProductSpec: spec, ShopeeGoodsID: goodsID, Quantity: 1, SybData: `{}`, + } + if _, err := repository.UpsertSybOrder(db, order); err != nil { + t.Fatalf("准备顺运宝明细失败: %v", err) + } + return order +} + +func seedWorkflowSKU(t *testing.T, db *sql.DB, skuID, goodsID, spec string) { + t.Helper() + color, size, advice, ok := ParseSpec(spec) + if err := repository.UpsertShopeeSKU(db, skuID, goodsID, spec, color, size, advice, ok, ""); err != nil { + t.Fatalf("准备蝦皮规格失败: %v", err) + } +} + +func readOrderSKU(t *testing.T, db *sql.DB, sybID string) string { + t.Helper() + var sku sql.NullString + if err := db.QueryRow(`SELECT shopee_sku_id FROM syb_orders WHERE syb_id = ?`, sybID).Scan(&sku); err != nil { + t.Fatal(err) + } + return sku.String +} + +func TestAutoResolveSybShopeeSKU_只采用唯一精确候选(t *testing.T) { + t.Run("规格原文唯一一致", func(t *testing.T) { + db := newSyncTestDB(t) + if err := repository.UpsertShopeeProduct(db, "SP-1", "商品", "", ""); err != nil { + t.Fatal(err) + } + seedWorkflowSKU(t, db, "SKU-1", "SP-1", "黑色,M") + order := seedWorkflowOrder(t, db, "SYB-1", "SP-1", "黑色,M") + + got, err := AutoResolveSybShopeeSKU(db, order) + if err != nil || got != "SKU-1" || readOrderSKU(t, db, "SYB-1") != "SKU-1" { + t.Fatalf("应自动确认唯一原文候选: got=%q stored=%q err=%v", got, readOrderSKU(t, db, "SYB-1"), err) + } + }) + + t.Run("解析后颜色尺码唯一一致", func(t *testing.T) { + db := newSyncTestDB(t) + if err := repository.UpsertShopeeProduct(db, "SP-2", "商品", "", ""); err != nil { + t.Fatal(err) + } + seedWorkflowSKU(t, db, "SKU-2", "SP-2", "黑色,M【建議40-50公斤】") + order := seedWorkflowOrder(t, db, "SYB-2", "SP-2", "黑色,M") + + got, err := AutoResolveSybShopeeSKU(db, order) + if err != nil || got != "SKU-2" { + t.Fatalf("应采用唯一颜色尺码候选: got=%q err=%v", got, err) + } + }) + + t.Run("多个颜色尺码候选不猜测", func(t *testing.T) { + db := newSyncTestDB(t) + if err := repository.UpsertShopeeProduct(db, "SP-3", "商品", "", ""); err != nil { + t.Fatal(err) + } + seedWorkflowSKU(t, db, "SKU-3A", "SP-3", "黑色,M【建議40-50公斤】") + seedWorkflowSKU(t, db, "SKU-3B", "SP-3", "黑色,M【建議50-60公斤】") + order := seedWorkflowOrder(t, db, "SYB-3", "SP-3", "黑色,M") + + got, err := AutoResolveSybShopeeSKU(db, order) + if err != nil || got != "" || readOrderSKU(t, db, "SYB-3") != "" { + t.Fatalf("多候选必须留给人工确认: got=%q stored=%q err=%v", got, readOrderSKU(t, db, "SYB-3"), err) + } + }) +} + +func TestAssignSybShopeeSKU_拒绝其他商品的规格(t *testing.T) { + db := newSyncTestDB(t) + for _, goodsID := range []string{"SP-A", "SP-B"} { + if err := repository.UpsertShopeeProduct(db, goodsID, goodsID, "", ""); err != nil { + t.Fatal(err) + } + } + seedWorkflowSKU(t, db, "SKU-A", "SP-A", "黑色,M") + seedWorkflowSKU(t, db, "SKU-B", "SP-B", "白色,L") + seedWorkflowOrder(t, db, "SYB-A", "SP-A", "未知规格") + + if err := AssignSybShopeeSKU(db, "SYB-A", "SKU-B"); err == nil { + t.Fatal("不得把其他蝦皮商品的 SKU 保存到当前顺运宝明细") + } + if got := readOrderSKU(t, db, "SYB-A"); got != "" { + t.Fatalf("越权选择后不应写库,实际 %q", got) + } + if err := AssignSybShopeeSKU(db, "SYB-A", "SKU-A"); err != nil { + t.Fatalf("同商品 SKU 应保存成功: %v", err) + } +} + +func TestListSybOrdersView_阶段筛选区分蝦皮识别和PDD准备(t *testing.T) { + db := newSyncTestDB(t) + seedWorkflowOrder(t, db, "SYB-MISSING", "NOT-IMPORTED", "黑色,M") + if err := repository.UpsertShopeeProduct(db, "SP-READY", "商品", "", ""); err != nil { + t.Fatal(err) + } + seedWorkflowSKU(t, db, "SKU-READY", "SP-READY", "黑色,M") + seedWorkflowOrder(t, db, "SYB-SKU", "SP-READY", "无法自动匹配") + order := seedWorkflowOrder(t, db, "SYB-PDD", "SP-READY", "黑色,M") + if _, err := AutoResolveSybShopeeSKU(db, order); err != nil { + t.Fatal(err) + } + + cases := []struct{ stage, wantID string }{ + {SybStageMissingShopee, "SYB-MISSING"}, + {SybStageSKUPending, "SYB-SKU"}, + {SybStagePddMissing, "SYB-PDD"}, + } + for _, tc := range cases { + result, err := ListSybOrdersView(db, "", tc.stage, 1) + if err != nil { + t.Fatal(err) + } + if result.Total != 1 || len(result.Rows) != 1 || result.Rows[0].SybID != tc.wantID { + t.Fatalf("阶段 %s 结果错误: %+v", tc.stage, result) + } + if result.Rows[0].StageText == "" || result.Rows[0].ActionText == "" { + t.Fatalf("阶段 %s 必须有文字和下一步: %+v", tc.stage, result.Rows[0]) + } + } +} diff --git a/admin/static/js/app.js b/admin/static/js/app.js index f3fcf7a..27ac0f5 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -203,33 +203,43 @@ var slot = modal.querySelector("[data-detail-slot]"); var base = modal.getAttribute("data-detail-url") || ""; + function loadDetail(id) { + slot.innerHTML = ""; + openModal(modal); + + var separator = base.indexOf("?") === -1 ? "?" : "&"; + fetch(base + separator + "id=" + encodeURIComponent(id), { + headers: { "X-Requested-With": "fetch" } + }) + .then(function (resp) { + if (!resp.ok) throw new Error("HTTP " + resp.status); + return resp.text(); + }) + .then(function (html) { + slot.innerHTML = html; + }) + .catch(function (err) { + /* 出错也要说清楚,不能让弹窗一直停在"正在加载…" */ + slot.innerHTML = + "" + + ""; + }); + } + document.querySelectorAll("tr[data-detail-id]").forEach(function (row) { row.addEventListener("dblclick", function (e) { /* 双击到勾选框或链接上时不要弹窗,那是另一个动作 */ if (e.target.closest("input, a, button")) return; - slot.innerHTML = ""; - openModal(modal); + loadDetail(row.getAttribute("data-detail-id")); + }); + }); - var separator = base.indexOf("?") === -1 ? "?" : "&"; - fetch(base + separator + "id=" + encodeURIComponent(row.getAttribute("data-detail-id")), { - headers: { "X-Requested-With": "fetch" } - }) - .then(function (resp) { - if (!resp.ok) throw new Error("HTTP " + resp.status); - return resp.text(); - }) - .then(function (html) { - slot.innerHTML = html; - }) - .catch(function (err) { - /* 出错也要说清楚,不能让弹窗一直停在"正在加载…" */ - slot.innerHTML = - "" + - ""; - }); + document.querySelectorAll("[data-detail-open-id]").forEach(function (button) { + button.addEventListener("click", function () { + loadDetail(button.getAttribute("data-detail-open-id")); }); }); } diff --git a/admin/templates/syb/detail_modal.html b/admin/templates/syb/detail_modal.html new file mode 100644 index 0000000..868f140 --- /dev/null +++ b/admin/templates/syb/detail_modal.html @@ -0,0 +1,54 @@ +{{define "syb/detail_modal"}} +{{with .D}} + + + + + +{{end}} +{{end}} diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index 2b66946..5a88b12 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -5,6 +5,8 @@
+ + @@ -21,10 +23,19 @@ + + +
+ + {{/* search-narrow 把搜索框收到 30%,让「搜索」按钮紧挨着它。 用类名而不是改 .toolbar input[type="text"]——那条被五个页面共用。 */}} @@ -35,6 +46,9 @@ + + +
@@ -87,13 +101,14 @@ 数量 价格 图片 - 匹配状态 + 处理阶段 + 下一步 更新时间 {{range .Rows}} - + @@ -103,20 +118,19 @@ {{.Title}} {{.ProductSpec}} {{.ShopeeGoodsID}} - {{/* 匹配状态是算出来的(shopee_sku_id 是否非空),本工单不做匹配功能, - 待匹配的行整行标黄提醒,但不提供匹配入口——那是后续工单的范围 */}} {{if .ShopeeSKUID}}{{.ShopeeSKUID}}{{else}}—{{end}} {{.Quantity}} {{.PriceText}} {{if .ImageURL}}{{else}}—{{end}} - {{.MatchText}} + {{.StageText}}
{{.StageHelp}} + {{.UpdatedAt}} {{else}} - + {{if .IsFiltered}} 当前筛选条件下没有货运单明细。
换个订单号或清空搜索词再试。查看全部 @@ -132,10 +146,15 @@

- 「匹配状态」是根据规格 SKU 是否已填算出来的,本页暂不提供匹配入口 - (规格匹配是后续工单的范围)。 + 每行只显示当前最需要完成的下一步;双击行或点击“下一步”按钮可以打开处理详情。

+ + {{/* ── 登录弹窗 ───────────────────────────────── 会话未登录/已过期时自动打开(不加 hidden); `[必须]` 密码不在界面上显示、也不回显到 HTML,只显示只读账号。 */}} @@ -148,6 +167,8 @@
+ + {{/* 登录和 OCR 降级后必须把工具栏里已确认的日期范围原样带回来。 */}} diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 551d296..c79654a 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -200,12 +200,16 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 | 数量 | | | 价格 | 蝦皮售价,**台币分**,见 §7 | | 图片 | 存 URL,表格里显示缩略图 | -| 匹配状态 | 已匹配 / 待匹配 | +| 处理阶段 | 未找到蝦皮商品 / 待确认蝦皮规格 / 未关联 PDD / 待采集 / 采集中 / 采集失败 / 规格待匹配 / 可采购 | +| 下一步 | 每行只显示当前最需要完成的一个操作 | | 更新时间 | | 原始的完整货运单 JSON 存 `syb_data` 字段,**不作为表格列显示**,在详情里看。 -**双击行打开匹配弹窗:** +**双击行或点击下一步打开处理弹窗:** + +- 顺运宝商品 ID 能找到蝦皮商品后,先确认蝦皮 SKU。规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时可以自动确认;零候选和多候选必须人工选择,不能猜。 +- `shopee_sku_id` 只表示“蝦皮规格已确认”,不等于 PDD 规格已经匹配。完整处理阶段还要检查当前 PDD 关联、采集状态和 `sku_mappings`。 - 左边显示蝦皮的颜色尺码,右边显示该商品 `pdd_data` 里的 PDD 颜色尺码; - 操作员选好对应关系,点保存; diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index e4e87a3..5de356d 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -472,20 +472,24 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 ### 6.2 表格列 ☐ / 货运单明细ID / 订单号 / 商品标题 / 规格 / 蝦皮商品ID / 规格SKU / 数量 / -价格(台币)/ 图片 / **匹配状态** / 更新时间 +价格(台币)/ 图片 / **处理阶段** / **下一步** / 更新时间 - 一行对应顺运宝一张货运单的**一个商品明细**,不是一张货运单——一张货运单 可以有多个商品,各占一行。 - 图片显示小缩略图。`[必须]` 存 URL,不要把图片塞进数据库。 -- 匹配状态是**算出来的**(`shopee_sku_id` 是否非空),不是存的字段。 - 本工单(#46)不做规格匹配功能,待匹配的行整行标黄,但不提供匹配入口。 +- 处理阶段是按当前关联事实**算出来的**,不是存一份容易过期的状态字段。阶段至少区分: + 未找到蝦皮商品、待确认蝦皮规格、未关联 PDD、PDD 待采集、采集中、采集失败、 + 规格待匹配和可采购。`shopee_sku_id` 非空只说明蝦皮规格已确认。 +- 每行显示一个有文字的“下一步”按钮;双击行和按钮打开同一个服务端渲染处理弹窗。 +- 规格原文完全一致,或解析后的颜色尺码只有一个唯一候选时,系统可以自动确认 + 蝦皮 SKU;零候选或多候选必须由采购员选择,不能模糊猜测。 - 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。 -### 6.3 规格匹配弹窗(双击行打开)—— 未实现,见下方说明 +### 6.3 采购处理与规格匹配弹窗(双击行或点击下一步打开) -`[不做]` 工单 #46(顺运宝货运单同步)明确不做这一节描述的匹配弹窗, -`shopee_sku_id` 由同步留空,本节描述的是**规格匹配**这个后续工单要实现的目标 -界面,先记录在这里,不代表当前已经能用。 +弹窗按当前处理阶段渐进展示。第一步先确认蝦皮 SKU;顺运宝同步本身仍不提供 +`shopee_sku_id`,但同步写入明细后可以根据本地蝦皮数据做确定性唯一匹配。 +后续 PDD 关联、采集和规格映射使用同一个弹窗继续完成。 ```text ┌──────────────────────────────────────────────────┐