// 蝦皮数据列表页的查询和界面文字组装。 // // 分成单独文件是因为它只负责蝦皮列表查询视图,不承担商品目录批量写入。 package service import ( "database/sql" "fmt" "strings" "cmautobuy/admin/model" "cmautobuy/admin/repository" "cmautobuy/admin/spec" ) // ShopeeProductView 是列表页一行要显示的全部内容,全部已经是字符串, // 模板里不做判断和格式化。 // // 一行对应一个**商品**,不是一个 SKU——见工单 #41。 type ShopeeProductView struct { GoodsID string Title string // ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。 ColorCount int SizeCount int // SKUCount 是这个商品报表里实际出现过的 SKU 数(不管解析成功与否)。 // `[必须]` 必须单独展示:蝦皮报表只含有销售成绩的 SKU, // 颜色数 × 尺码数经常大于 SKUCount,只显示前两者会严重误导操作员, // 让人以为要匹配的规格比实际多得多(实测最悬殊相差 2.7 倍,见 #41)。 SKUCount int // PendingCount 是 parse_ok = 0 的 SKU 数,即"待补"。 // `[必须]` > 0 时整行标黄(沿用 .row-warn)——聚合成商品级之后, // "部分失败"的商品数字看起来完全正常,坏数据会被吃掉,见 #41。 PendingCount int SourceText string // PddURL 为空且 PddMissing 为 true 时,模板显示"未填写"并标红—— // 这是最需要操作员注意的状态,见 §4.2。 PddURL string PddMissing bool StatusText string UpdatedAt string } // ShopeeListResult 是列表页要的全部数据。 type ShopeeListResult struct { Rows []ShopeeProductView // Total 是**当前筛选条件下**的商品总数,不是本页行数。 // `[必须]` 底部状态条必须显示这个,不能显示本页行数(20), // 否则操作员会以为总共就这么多,见工单 #43。 Total int // HasAnyProducts 是 shopee_products 的全库总数是否大于 0,不受筛选影响, // 只用于区分空状态文案:"从没导入过" vs "筛选没结果"。 HasAnyProducts bool IsFiltered bool Page int PageSize int TotalPages int } // ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。 // // 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段, // 见 docs/admin/01-requirements.md §6.1: // // shopee_products.pdd_goods_id 为空 -> "未填链接" // pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败 // // `[必须]` Total 用和 ListShopeeProducts 同一套筛选条件的 COUNT // (repository.CountShopeeProductsFiltered),分页页数据此算出, // page 越界时兜到最后一页,见 service/pagination.go(工单 #43)。 func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*ShopeeListResult, error) { total, err := repository.CountShopeeProductsFiltered(db, filter) if err != nil { return nil, err } totalPages := TotalPages(total) page = ClampPage(page, totalPages) offset := (page - 1) * PageSize rows, err := repository.ListShopeeProducts(db, filter, PageSize, offset) if err != nil { return nil, err } hasAny, err := repository.CountShopeeProducts(db) if err != nil { return nil, err } result := &ShopeeListResult{ Rows: make([]ShopeeProductView, 0, len(rows)), Total: total, HasAnyProducts: hasAny > 0, IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "", Page: page, PageSize: PageSize, TotalPages: totalPages, } for _, r := range rows { v := ShopeeProductView{ GoodsID: r.GoodsID, Title: r.Title, ColorCount: r.ColorCount, SizeCount: r.SizeCount, SKUCount: r.SKUCount, PendingCount: r.PendingCount, SourceText: shopeeSourceText(r.Source), UpdatedAt: formatLocalTime(r.UpdatedAt), } if r.PddGoodsID == "" { v.PddMissing = true v.PddURL = "未填写" v.StatusText = "未填链接" } else { v.PddURL = r.PddGoodsURL if r.CollectStatus.Valid { v.StatusText = collectStatusText(model.CollectStatus(r.CollectStatus.String)) } else { // pdd_goods_id 有值但 pdd_products 里查不到对应行 // (比如那条 PDD 商品被软删除了),如实说明,不要装作"未采集"。 v.StatusText = "PDD 商品缺失" } } result.Rows = append(result.Rows, v) } return result, nil } func shopeeSourceText(source string) string { if source == "syb" { return "顺运宝骨架" } return "蝦皮报表" } // ---------- 状态筛选 ---------- // shopeeStatusTexts 是状态筛选下拉框每个取值对应的中文文字, // 也用于底部状态条筛选后的前缀("待补规格:6 个商品"),见工单 #43。 var shopeeStatusTexts = map[string]string{ "pending_spec": "待补规格", "no_link": "未填 PDD 链接", "has_link": "已填链接", } // ShopeeStatusOption 是筛选下拉框的一项。 type ShopeeStatusOption struct { Value string // 空串表示"全部" Text string } // ShopeeStatusOptions 返回状态筛选下拉框的全部选项:全部 + 三个状态。 func ShopeeStatusOptions() []ShopeeStatusOption { return []ShopeeStatusOption{ {"", "全部"}, {"pending_spec", shopeeStatusTexts["pending_spec"]}, {"no_link", shopeeStatusTexts["no_link"]}, {"has_link", shopeeStatusTexts["has_link"]}, } } // ParseShopeeStatus 校验筛选参数。认不出的一律当"全部",不报错—— // 地址栏参数是用户可以随便改的,不值得为它弹错误页(工单 #43)。 func ParseShopeeStatus(s string) string { status := strings.TrimSpace(s) if _, ok := shopeeStatusTexts[status]; ok { return status } return "" } // ShopeeStatusLabel 返回状态筛选取值对应的中文文字,取值为空或认不出时 // 返回空串——调用方(handler 层组装状态条文案)据此判断要不要用作前缀。 func ShopeeStatusLabel(status string) string { return shopeeStatusTexts[status] } // ---------- 弹窗 ---------- // ShopeeSpecView 是弹窗规格表的一行。 type ShopeeSpecView struct { SKUID string Color string Size string Advice string // StatusText 是"✓"或"待补",`[必须]` 要有文字,不能只靠颜色区分(见 #41)。 StatusText string // Pending 为 true 时模板整行标黄,并在下面加一行显示 SpecRaw 原文。 Pending bool // SpecRaw 只在 Pending 为 true 时才有意义。 // `[必须]` 待补的行必须显示规格原文——不显示的话人工不知道该填什么, // 这正是保留 spec_raw 这个设计的全部意义,见 #41。 SpecRaw string } // ShopeeProductDetail 是双击弹窗要显示的全部内容。 type ShopeeProductDetail struct { GoodsID string Title string ShopeeStatus string MainSKUCode string PddURL string PddGoodsID string PddMissing bool StatusText string CollectStatus string CollectMsg string CanCollect bool SKUs []ShopeeSpecView PendingCount int SybObservations []SybObservationView } // SybObservationView 是详情页只读的顺运宝历史观测,不是正式 SKU。 type SybObservationView struct { SpecRaw, PriceText, ImageURL, LastObservedAt string OrderCount, TotalQuantity int MatchedSKUID, Color, Size, MatchText string } // GetShopeeProductDetail 读一个蝦皮商品的详情(商品信息 + 完整规格表)。 // 商品不存在返回 (nil, nil)。 func GetShopeeProductDetail(db *sql.DB, goodsID string) (*ShopeeProductDetail, error) { p, err := repository.GetShopeeProductByGoodsID(db, goodsID) if err != nil || p == nil { return nil, err } d := &ShopeeProductDetail{ GoodsID: p.GoodsID, Title: p.Title, ShopeeStatus: p.ShopeeStatus, MainSKUCode: p.MainSKUCode, PddGoodsID: p.PddGoodsID, } if d.Title == "" { d.Title = placeholder } if d.ShopeeStatus == "" { d.ShopeeStatus = placeholder } if d.MainSKUCode == "" { d.MainSKUCode = placeholder } if p.PddGoodsID == "" { d.PddMissing = true d.PddURL = "未填写" d.StatusText = "未填链接" } else { d.PddURL = p.PddGoodsURL status, msg, err := repository.GetShopeeCollectStatus(db, p.PddGoodsID) if err != nil { return nil, err } if status.Valid { d.CollectStatus = status.String d.CollectMsg = msg.String d.StatusText = collectStatusText(model.CollectStatus(status.String)) d.CanCollect = status.String == string(model.CollectPending) || status.String == string(model.CollectFailed) } else { d.StatusText = "PDD 商品缺失" } } skus, err := repository.ListShopeeSKUsByGoodsID(db, goodsID) if err != nil { return nil, err } d.SKUs = make([]ShopeeSpecView, 0, len(skus)) for _, sk := range skus { row := ShopeeSpecView{SKUID: sk.SKUID} if sk.ParseOK { row.Color, row.Size, row.Advice = sk.Color, sk.Size, sk.Advice if row.Advice == "" { row.Advice = placeholder } row.StatusText = "✓" } else { row.Color, row.Size, row.Advice = placeholder, placeholder, placeholder row.StatusText = "待补" row.Pending = true row.SpecRaw = sk.SpecRaw d.PendingCount++ } d.SKUs = append(d.SKUs, row) } observations, err := repository.ListSybSpecObservations(db, goodsID) if err != nil { return nil, err } formalByKey := map[string][]model.ShopeeSKU{} for _, sk := range skus { if key, keyErr := spec.SpecKey(sk.SpecRaw); keyErr == nil { formalByKey[key] = append(formalByKey[key], sk) } } for _, observation := range observations { view := SybObservationView{SpecRaw: observation.SpecRaw, PriceText: fmt.Sprintf("NT$%.2f", float64(observation.LatestPriceCent)/100), ImageURL: observation.LatestImageURL, OrderCount: observation.OrderCount, TotalQuantity: observation.TotalQuantity, LastObservedAt: formatLocalTime(observation.LastObservedAt), MatchText: "待目录补全"} if matches := formalByKey[observation.SpecKey]; len(matches) == 1 { view.MatchedSKUID = matches[0].SKUID view.Color = matches[0].Color view.Size = matches[0].Size view.MatchText = "唯一匹配" } else if len(matches) > 1 { view.MatchText = "多条匹配,待人工确认" } d.SybObservations = append(d.SybObservations, view) } return d, nil }