412 lines
17 KiB
Go
412 lines
17 KiB
Go
package repository
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"cmautobuy/admin/model"
|
||
"cmautobuy/admin/spec"
|
||
)
|
||
|
||
// UpsertShopeeProduct 写入或更新一条蝦皮商品汇总行。
|
||
//
|
||
// `[必须]` DO UPDATE SET 里绝不允许出现 pdd_goods_url / pdd_goods_id。
|
||
// 蝦皮报表里没有这两列,写进去就是写空值——操作员可能攒了几周的
|
||
// PDD 链接,导入一次就被静默洗掉,而且不报错,等到建采购任务时
|
||
// 才会发现,那时已经找不回来了。见工单 #38、admin/AGENTS.md
|
||
// 「Excel 导入只做 upsert,绝不允许先清空再导入」。
|
||
func UpsertShopeeProduct(q Execer, goodsID, title, shopeeStatus, mainSKUCode string) error {
|
||
if goodsID == "" {
|
||
return fmt.Errorf("goods_id 不能为空")
|
||
}
|
||
now := model.NowISO()
|
||
_, err := q.Exec(`
|
||
INSERT INTO shopee_products (goods_id, title, shopee_status, main_sku_code, source, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, 'report', ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
title = VALUES(title),
|
||
shopee_status = VALUES(shopee_status),
|
||
main_sku_code = VALUES(main_sku_code),
|
||
source = 'report',
|
||
updated_at = VALUES(updated_at)`,
|
||
// 注意:pdd_goods_url / pdd_goods_id 两列不在 INSERT 的列清单里,
|
||
// 也不在 DO UPDATE SET 里——新建时它们是 NULL(未填链接),
|
||
// 已存在时它们完全不受这条语句影响。
|
||
goodsID, title, shopeeStatus, mainSKUCode, now, now)
|
||
if err != nil {
|
||
return fmt.Errorf("写入蝦皮商品 %s 失败: %w", goodsID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// UpsertShopeeSKU 写入或更新一条蝦皮 SKU 行。
|
||
//
|
||
// `[必须]` is_manual 不在 DO UPDATE SET 里,已存在的行导入时保持原值不变。
|
||
// 人工新增的 SKU(is_manual=1)就是靠这个不被导入覆盖成 0——
|
||
// 本工单不做手动新增功能,但这个口子现在就要留好。
|
||
func UpsertShopeeSKU(q Execer, skuID, goodsID, specRaw, color, size, advice string, parseOK bool, skuCode string) error {
|
||
if skuID == "" {
|
||
return fmt.Errorf("sku_id 不能为空")
|
||
}
|
||
if goodsID == "" {
|
||
return fmt.Errorf("goods_id 不能为空")
|
||
}
|
||
parseOKInt := 0
|
||
if parseOK {
|
||
parseOKInt = 1
|
||
}
|
||
now := model.NowISO()
|
||
specKey, err := spec.SpecKey(specRaw)
|
||
if err != nil {
|
||
return fmt.Errorf("规格原文无效: %w", err)
|
||
}
|
||
fieldSources, fieldTimes := map[string]string{}, map[string]string{}
|
||
for name, value := range map[string]string{"color": color, "size": size, "advice": advice, "sku_code": skuCode} {
|
||
if strings.TrimSpace(value) != "" {
|
||
fieldSources[name], fieldTimes[name] = "report", now
|
||
}
|
||
}
|
||
fieldSourcesJSON, _ := json.Marshal(fieldSources)
|
||
fieldTimesJSON, _ := json.Marshal(fieldTimes)
|
||
_, err = q.Exec(`
|
||
INSERT INTO shopee_skus
|
||
(sku_id, shopee_sku_id, goods_id, spec_raw, spec_key, color, size, advice, parse_ok, sku_code,
|
||
is_manual, source, field_sources, field_observed_at, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'report', ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
goods_id = CASE WHEN is_manual=1 THEN goods_id ELSE VALUES(goods_id) END,
|
||
color = CASE WHEN is_manual=1 THEN color ELSE VALUES(color) END,
|
||
size = CASE WHEN is_manual=1 THEN size ELSE VALUES(size) END,
|
||
advice = CASE WHEN is_manual=1 THEN advice ELSE VALUES(advice) END,
|
||
parse_ok = CASE WHEN is_manual=1 THEN parse_ok ELSE VALUES(parse_ok) END,
|
||
sku_code = CASE WHEN is_manual=1 THEN sku_code ELSE VALUES(sku_code) END,
|
||
field_sources = CASE WHEN is_manual=1 THEN field_sources ELSE VALUES(field_sources) END,
|
||
field_observed_at = CASE WHEN is_manual=1 THEN field_observed_at ELSE VALUES(field_observed_at) END,
|
||
updated_at = CASE WHEN is_manual=1 THEN updated_at ELSE VALUES(updated_at) END`,
|
||
// 人工行的业务字段和更新时间均由 CASE 保留。
|
||
skuID, skuID, goodsID, specRaw, specKey, color, size, advice, parseOKInt, skuCode, string(fieldSourcesJSON), string(fieldTimesJSON), now, now)
|
||
if err != nil {
|
||
return fmt.Errorf("写入蝦皮 SKU %s 失败: %w", skuID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ShopeeProductRow 是列表页要的一行:**商品级**聚合数据,联查出采集状态。
|
||
//
|
||
// `pdd_goods_url` / `pdd_goods_id` 挂在 shopee_products 上,是商品级字段——
|
||
// 一行对应一个商品,不是一个 SKU,见工单 #41。
|
||
//
|
||
// 颜色数 / 尺码数只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41):
|
||
// 把 parse_ok = 0 的空颜色/空尺码算进 DISTINCT 会多出一个"空值"选项。
|
||
// SKUCount 是这个商品报表里出现过的全部 SKU 数(不管解析成功与否)——
|
||
// 蝦皮报表只含有销售成绩的 SKU,这个数天然会小于"颜色数 × 尺码数",
|
||
// 加这一列就是为了防止操作员把两者乘出来的数当成实际规格数(见 #41)。
|
||
// PendingCount 是 parse_ok = 0 的 SKU 数,即"待补",> 0 时列表整行标黄。
|
||
//
|
||
// 采集状态挂在 pdd_products 上,不在蝦皮这边,见
|
||
// docs/admin/01-requirements.md §6.1。CollectStatus / CollectMsg 为
|
||
// NULL 表示这个蝦皮商品还没填 PDD 链接(LEFT JOIN 没查到对应行)。
|
||
type ShopeeProductRow struct {
|
||
model.ShopeeProduct
|
||
ColorCount int
|
||
SizeCount int
|
||
SKUCount int
|
||
PendingCount int
|
||
CollectStatus sql.NullString
|
||
CollectMsg sql.NullString
|
||
}
|
||
|
||
// ShopeeFilter 是蝦皮商品列表页支持的筛选条件,四项都可以为空。
|
||
//
|
||
// Status 取值见 §「状态筛选的四个取值」(工单 #43):
|
||
//
|
||
// "" 不筛选
|
||
// "pending_spec" 有 parse_ok = 0 的 SKU(待补规格)
|
||
// "no_link" pdd_goods_url 为空(未填 PDD 链接)
|
||
// "has_link" pdd_goods_url 非空(已填链接)
|
||
//
|
||
// 认不出的取值一律当作 ""(不筛选),由 service 层的 ParseShopeeStatus 兜底,
|
||
// 这里不做校验——repository 只管拼 SQL。
|
||
type ShopeeFilter struct {
|
||
Keyword string
|
||
Status string
|
||
Shop string // has / missing / 空(全部)
|
||
Image string // has / missing / 空(全部)
|
||
}
|
||
|
||
// shopeeFilterClause 把关键字、状态、店铺和图片筛选拼成 WHERE 子句,供 ListShopeeProducts
|
||
// 和 CountShopeeProductsFiltered 共用——两处筛选逻辑必须完全一致,
|
||
// 否则底部统计会跟表格对不上(#19 踩过一次,见工单 #43)。
|
||
//
|
||
// 「待补规格」用 EXISTS 子查询,不用 JOIN + DISTINCT:一个商品有多个失败
|
||
// SKU 时 JOIN 会出重复行,DISTINCT 又会让外层 LIMIT/OFFSET 的行为难推理
|
||
// (见工单 #43)。
|
||
func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
|
||
var clauses []string
|
||
var args []any
|
||
|
||
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
|
||
like := "%" + escapeLike(kw) + "%"
|
||
clauses = append(clauses, `(sp.goods_id LIKE ? ESCAPE '!' OR sp.title LIKE ? ESCAPE '!')`)
|
||
args = append(args, like, like)
|
||
}
|
||
|
||
switch filter.Status {
|
||
case "pending_spec":
|
||
clauses = append(clauses,
|
||
`EXISTS (SELECT 1 FROM shopee_skus s WHERE s.goods_id = sp.goods_id AND s.parse_ok = 0)`)
|
||
case "no_link":
|
||
clauses = append(clauses, `(sp.pdd_goods_url IS NULL OR sp.pdd_goods_url = '')`)
|
||
case "has_link":
|
||
clauses = append(clauses, `(sp.pdd_goods_url IS NOT NULL AND sp.pdd_goods_url <> '')`)
|
||
}
|
||
|
||
switch filter.Shop {
|
||
case "has":
|
||
clauses = append(clauses, `(sp.shopee_shop_name IS NOT NULL AND TRIM(sp.shopee_shop_name) <> '')`)
|
||
case "missing":
|
||
clauses = append(clauses, `(sp.shopee_shop_name IS NULL OR TRIM(sp.shopee_shop_name) = '')`)
|
||
}
|
||
|
||
switch filter.Image {
|
||
case "has":
|
||
clauses = append(clauses, `(sp.image_url IS NOT NULL AND TRIM(sp.image_url) <> '')`)
|
||
case "missing":
|
||
clauses = append(clauses, `(sp.image_url IS NULL OR TRIM(sp.image_url) = '')`)
|
||
}
|
||
|
||
if len(clauses) == 0 {
|
||
return "", args
|
||
}
|
||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||
}
|
||
|
||
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
|
||
//
|
||
// keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**——
|
||
// 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。
|
||
//
|
||
// `[必须]` 分页用 LIMIT/OFFSET 在数据库里做,不把全量查出来在 Go 里切片
|
||
// (工单 #43:这正是改之前 HTML 一次 3.4MB 的成因)。
|
||
func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) {
|
||
where, args := shopeeFilterClause(filter)
|
||
sqlText := `
|
||
SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code, sp.image_url,sp.shopee_shop_name,sp.image_source,sp.image_observed_at,sp.image_is_manual,sp.shop_name_source,sp.shop_name_observed_at,sp.shop_name_is_manual,sp.source,
|
||
sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at,
|
||
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.color END) AS color_count,
|
||
COUNT(DISTINCT CASE WHEN sk.parse_ok = 1 THEN sk.size END) AS size_count,
|
||
COUNT(sk.sku_id) AS sku_count,
|
||
COUNT(CASE WHEN sk.parse_ok = 0 THEN 1 END) AS pending_count,
|
||
pp.collect_status, pp.collect_msg
|
||
FROM shopee_products sp
|
||
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
|
||
LEFT JOIN pdd_products pp
|
||
ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` +
|
||
where + ` GROUP BY sp.goods_id ORDER BY sp.goods_id LIMIT ? OFFSET ?`
|
||
args = append(args, limit, offset)
|
||
|
||
rows, err := q.Query(sqlText, args...)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("查询蝦皮商品列表失败: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var list []ShopeeProductRow
|
||
for rows.Next() {
|
||
var r ShopeeProductRow
|
||
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID sql.NullString
|
||
var imageManual, shopManual int
|
||
if err := rows.Scan(
|
||
&r.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
||
&pddGoodsURL, &pddGoodsID, &r.CreatedAt, &r.UpdatedAt,
|
||
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
|
||
&r.CollectStatus, &r.CollectMsg,
|
||
); err != nil {
|
||
return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err)
|
||
}
|
||
r.Title = title.String
|
||
r.ShopeeStatus = shopeeStatus.String
|
||
r.MainSKUCode = mainSKUCode.String
|
||
r.ImageURL = imageURL.String
|
||
r.ShopeeShopName = shopName.String
|
||
r.ImageSource = imageSource.String
|
||
r.ImageObservedAt = imageObserved.String
|
||
r.ImageIsManual = imageManual != 0
|
||
r.ShopNameSource = shopSource.String
|
||
r.ShopNameObservedAt = shopObserved.String
|
||
r.ShopNameIsManual = shopManual != 0
|
||
r.Source = source.String
|
||
r.PddGoodsURL = pddGoodsURL.String
|
||
r.PddGoodsID = pddGoodsID.String
|
||
list = append(list, r)
|
||
}
|
||
return list, rows.Err()
|
||
}
|
||
|
||
// CountShopeeProductsFiltered 统计当前筛选条件下的商品总数。
|
||
//
|
||
// `[必须]` 用和 ListShopeeProducts **完全相同**的筛选条件(shopeeFilterClause)——
|
||
// 底部状态条和分页页数都靠它,写成两份 WHERE 迟早有一天会忘了同步改,
|
||
// 页码就会算错而且没人发现(#19 已经踩过一次,见工单 #43)。
|
||
func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) {
|
||
where, args := shopeeFilterClause(filter)
|
||
sqlText := `SELECT COUNT(*) FROM shopee_products sp` + where
|
||
var n int
|
||
if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil {
|
||
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// GetShopeeProductByGoodsID 按 goods_id 查一条蝦皮商品,不带聚合。
|
||
// 弹窗组装商品信息时用。查不到返回 (nil, nil)。
|
||
func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) {
|
||
var p model.ShopeeProduct
|
||
var title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID sql.NullString
|
||
var imageManual, shopManual int
|
||
err := q.QueryRow(`
|
||
SELECT goods_id, title, shopee_status, main_sku_code,image_url,shopee_shop_name,image_source,image_observed_at,image_is_manual,shop_name_source,shop_name_observed_at,shop_name_is_manual, source,
|
||
pdd_goods_url, pdd_goods_id, created_at, updated_at
|
||
FROM shopee_products WHERE goods_id = ?`, goodsID).Scan(
|
||
&p.GoodsID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
|
||
&pddGoodsURL, &pddGoodsID, &p.CreatedAt, &p.UpdatedAt)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, nil
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("查询蝦皮商品 %s 失败: %w", goodsID, err)
|
||
}
|
||
p.Title = title.String
|
||
p.ShopeeStatus = shopeeStatus.String
|
||
p.MainSKUCode = mainSKUCode.String
|
||
p.ImageURL = imageURL.String
|
||
p.ShopeeShopName = shopName.String
|
||
p.ImageSource = imageSource.String
|
||
p.ImageObservedAt = imageObserved.String
|
||
p.ImageIsManual = imageManual != 0
|
||
p.ShopNameSource = shopSource.String
|
||
p.ShopNameObservedAt = shopObserved.String
|
||
p.ShopNameIsManual = shopManual != 0
|
||
p.Source = source.String
|
||
p.PddGoodsURL = pddGoodsURL.String
|
||
p.PddGoodsID = pddGoodsID.String
|
||
return &p, nil
|
||
}
|
||
|
||
// UpdateShopeePddLink 更新蝦皮商品当前关联的 PDD 商品。
|
||
// 调用方必须先在同一事务里保证 PDD 商品存在,避免留下悬空关联。
|
||
func UpdateShopeePddLink(q Execer, shopeeGoodsID, pddGoodsID, pddURL string) error {
|
||
result, err := q.Exec(`
|
||
UPDATE shopee_products
|
||
SET pdd_goods_id = ?, pdd_goods_url = ?, updated_at = ?
|
||
WHERE goods_id = ?`,
|
||
pddGoodsID, pddURL, model.NowISO(), shopeeGoodsID)
|
||
if err != nil {
|
||
return fmt.Errorf("更新蝦皮商品 %s 的 PDD 关联失败: %w", shopeeGoodsID, err)
|
||
}
|
||
n, err := result.RowsAffected()
|
||
if err != nil {
|
||
return fmt.Errorf("读取蝦皮商品 %s 的更新结果失败: %w", shopeeGoodsID, err)
|
||
}
|
||
if n != 1 {
|
||
return fmt.Errorf("蝦皮商品 %s 不存在", shopeeGoodsID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetShopeeCollectStatus 查一个 PDD 商品的采集状态和失败原因。
|
||
// pddGoodsID 为空(蝦皮商品还没填链接)或查不到对应的 pdd_products 行时,
|
||
// 返回的两个 sql.NullString 都是 Valid=false,调用方按"未填链接"/"PDD 商品缺失"处理。
|
||
func GetShopeeCollectStatus(q Execer, pddGoodsID string) (status, msg sql.NullString, err error) {
|
||
if pddGoodsID == "" {
|
||
return status, msg, nil
|
||
}
|
||
err = q.QueryRow(`
|
||
SELECT collect_status, collect_msg FROM pdd_products
|
||
WHERE goods_id = ? AND deleted_at IS NULL`, pddGoodsID).Scan(&status, &msg)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return sql.NullString{}, sql.NullString{}, nil
|
||
}
|
||
if err != nil {
|
||
return status, msg, fmt.Errorf("查询 PDD 商品 %s 采集状态失败: %w", pddGoodsID, err)
|
||
}
|
||
return status, msg, nil
|
||
}
|
||
|
||
// ListShopeeSKUsByGoodsID 查一个商品的完整规格表,**含解析失败的行**。
|
||
//
|
||
// 弹窗要显示全部规格(不管待补与否),所以这里不按 parse_ok 过滤,
|
||
// 见 #41「弹窗里待补的行必须显示 spec_raw 原文」。
|
||
func ListShopeeSKUsByGoodsID(q Execer, goodsID string) ([]model.ShopeeSKU, error) {
|
||
rows, err := q.Query(`
|
||
SELECT sku_id, COALESCE(shopee_sku_id,''), goods_id, spec_raw, color, size, advice, parse_ok, sku_code,
|
||
is_manual, created_at, updated_at
|
||
FROM shopee_skus
|
||
WHERE goods_id = ?
|
||
ORDER BY sku_id`, goodsID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("查询蝦皮商品 %s 的规格失败: %w", goodsID, err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var list []model.ShopeeSKU
|
||
for rows.Next() {
|
||
var sk model.ShopeeSKU
|
||
var color, size, advice, skuCode sql.NullString
|
||
var parseOK, isManual int
|
||
if err := rows.Scan(
|
||
&sk.RecordID, &sk.SKUID, &sk.GoodsID, &sk.SpecRaw, &color, &size, &advice,
|
||
&parseOK, &skuCode, &isManual, &sk.CreatedAt, &sk.UpdatedAt,
|
||
); err != nil {
|
||
return nil, fmt.Errorf("读取蝦皮商品 %s 的规格失败: %w", goodsID, 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
|
||
list = append(list, sk)
|
||
}
|
||
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, COALESCE(shopee_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.RecordID, &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
|
||
if err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products`).Scan(&n); err != nil {
|
||
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
|
||
}
|
||
return n, nil
|
||
}
|