Files
cmautobuy/admin/repository/shopee.go
T

551 lines
23 KiB
Go
Raw Normal View History

2026-08-08 09:57:12 +08:00
package repository
import (
"database/sql"
2026-08-11 11:03:18 +08:00
"encoding/json"
"errors"
2026-08-08 09:57:12 +08:00
"fmt"
"strings"
"cmautobuy/admin/model"
2026-08-11 11:03:18 +08:00
"cmautobuy/admin/spec"
2026-08-08 09:57:12 +08:00
)
// 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(`
2026-08-10 12:24:23 +08:00
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),
2026-08-10 12:24:23 +08:00
source = 'report',
updated_at = VALUES(updated_at)`,
2026-08-08 09:57:12 +08:00
// 注意: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()
2026-08-11 11:03:18 +08:00
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(`
2026-08-08 09:57:12 +08:00
INSERT INTO shopee_skus
2026-08-11 11:03:18 +08:00
(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
2026-08-11 11:03:18 +08:00
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)
2026-08-08 09:57:12 +08:00
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 时列表整行标黄。
2026-08-08 09:57:12 +08:00
//
// 采集状态挂在 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
2026-08-08 09:57:12 +08:00
}
// 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
SearchField string // goods_id / title / shop_name
Status string
Deleted bool // true 只看软删除数据;false 只看正常数据
Unlinked bool // true 只看尚未登记到店铺管理的正常商品
}
// shopeeFilterClause 把分类关键词、状态和内部范围拼成 WHERE 子句,供 ListShopeeProducts
// 和 CountShopeeProductsFiltered 共用——两处筛选逻辑必须完全一致,
// 否则底部统计会跟表格对不上(#19 踩过一次,见工单 #43)。
//
// 「待补规格」用 EXISTS 子查询,不用 JOIN + DISTINCT:一个商品有多个失败
// SKU 时 JOIN 会出重复行,DISTINCT 又会让外层 LIMIT/OFFSET 的行为难推理
// (见工单 #43)。
func shopeeFilterClause(filter ShopeeFilter) (string, []any) {
clauses := []string{"sp.deleted_at IS NULL"}
if filter.Deleted {
clauses[0] = "sp.deleted_at IS NOT NULL"
}
var args []any
if kw := strings.TrimSpace(filter.Keyword); kw != "" {
switch filter.SearchField {
case "title":
clauses = append(clauses, `sp.title LIKE ? ESCAPE '!'`)
args = append(args, "%"+escapeLike(kw)+"%")
case "shop_name":
clauses = append(clauses, `sp.shopee_shop_name LIKE ? ESCAPE '!'`)
args = append(args, "%"+escapeLike(kw)+"%")
default:
clauses = append(clauses, `sp.goods_id = ?`)
args = append(args, kw)
}
2026-08-12 15:09:35 +08:00
}
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 <> '')`)
}
if filter.Unlinked {
clauses = append(clauses, `sp.shop_id IS NULL`)
}
2026-08-11 12:04:38 +08:00
return " WHERE " + strings.Join(clauses, " AND "), args
}
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。
//
// keyword 只匹配 SearchField 指定的商品 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)
2026-08-08 09:57:12 +08:00
sqlText := `
SELECT sp.goods_id,sp.shop_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.deleted_at,sp.deleted_by_user_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,
2026-08-08 09:57:12 +08:00
pp.collect_status, pp.collect_msg
FROM shopee_products sp
LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id
2026-08-08 09:57:12 +08:00
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)
2026-08-08 09:57:12 +08:00
rows, err := q.Query(sqlText, args...)
if err != nil {
return nil, fmt.Errorf("查询蝦皮商品列表失败: %w", err)
2026-08-08 09:57:12 +08:00
}
defer rows.Close()
var list []ShopeeProductRow
2026-08-08 09:57:12 +08:00
for rows.Next() {
var r ShopeeProductRow
var shopID, title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
2026-08-11 11:18:51 +08:00
var imageManual, shopManual int
2026-08-08 09:57:12 +08:00
if err := rows.Scan(
&r.GoodsID, &shopID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
&pddGoodsURL, &pddGoodsID, &deletedAt, &deletedBy, &r.CreatedAt, &r.UpdatedAt,
&r.ColorCount, &r.SizeCount, &r.SKUCount, &r.PendingCount,
2026-08-08 09:57:12 +08:00
&r.CollectStatus, &r.CollectMsg,
); err != nil {
return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err)
2026-08-08 09:57:12 +08:00
}
r.Title = title.String
r.ShopID = shopID.String
r.ShopeeStatus = shopeeStatus.String
r.MainSKUCode = mainSKUCode.String
2026-08-11 11:18:51 +08:00
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
2026-08-10 12:24:23 +08:00
r.Source = source.String
2026-08-08 09:57:12 +08:00
r.PddGoodsURL = pddGoodsURL.String
r.PddGoodsID = pddGoodsID.String
r.DeletedAt = deletedAt.String
r.DeletedByUserID = deletedBy.String
2026-08-08 09:57:12 +08:00
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 shopID, title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
2026-08-11 11:18:51 +08:00
var imageManual, shopManual int
err := q.QueryRow(`
SELECT goods_id,shop_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,deleted_at,deleted_by_user_id, created_at, updated_at
FROM shopee_products WHERE goods_id = ? AND deleted_at IS NULL`, goodsID).Scan(
&p.GoodsID, &shopID, &title, &shopeeStatus, &mainSKUCode, &imageURL, &shopName, &imageSource, &imageObserved, &imageManual, &shopSource, &shopObserved, &shopManual, &source,
&pddGoodsURL, &pddGoodsID, &deletedAt, &deletedBy, &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.ShopID = shopID.String
p.ShopeeStatus = shopeeStatus.String
p.MainSKUCode = mainSKUCode.String
2026-08-11 11:18:51 +08:00
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
2026-08-10 12:24:23 +08:00
p.Source = source.String
p.PddGoodsURL = pddGoodsURL.String
p.PddGoodsID = pddGoodsID.String
p.DeletedAt = deletedAt.String
p.DeletedByUserID = deletedBy.String
return &p, nil
}
// ListShopeePddLinksByGoodsIDs 批量读取蝦皮商品当前关联的 PDD 商品 ID。
// 返回结果只包含仍存在的蝦皮商品;值为空表示商品存在但尚未关联 PDD。
func ListShopeePddLinksByGoodsIDs(q Execer, goodsIDs []string) (map[string]string, error) {
links := make(map[string]string, len(goodsIDs))
if len(goodsIDs) == 0 {
return links, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",")
args := make([]any, len(goodsIDs))
for i, goodsID := range goodsIDs {
args[i] = goodsID
}
rows, err := q.Query(`SELECT goods_id, COALESCE(pdd_goods_id, '')
FROM shopee_products WHERE deleted_at IS NULL AND goods_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, fmt.Errorf("批量查询蝦皮商品 PDD 关联失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var shopeeGoodsID, pddGoodsID string
if err := rows.Scan(&shopeeGoodsID, &pddGoodsID); err != nil {
return nil, fmt.Errorf("读取蝦皮商品 PDD 关联失败: %w", err)
}
links[shopeeGoodsID] = pddGoodsID
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("遍历蝦皮商品 PDD 关联失败: %w", err)
}
return links, 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 = ? AND deleted_at IS NULL`,
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(`
2026-08-11 11:03:18 +08:00
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(
2026-08-11 11:03:18 +08:00
&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(`
2026-08-11 11:03:18 +08:00
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 sk WHERE sku_id = ?
AND EXISTS (SELECT 1 FROM shopee_products sp WHERE sp.goods_id=sk.goods_id AND sp.deleted_at IS NULL)`, skuID).Scan(
2026-08-11 11:03:18 +08:00
&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
}
2026-08-13 12:14:02 +08:00
// ShopeeSKUManualState 是人工编辑前需要读取的最小状态。
// 字段来源 JSON 必须先读出再合并,不能直接重建,否则会丢掉 sku_code 等
// 本次表单没有修改的字段来源。
type ShopeeSKUManualState struct {
RecordID, GoodsID string
FieldSources, FieldObservedAt string
}
// GetShopeeSKUManualState 查询一条仍属于正常商品的 SKU 及其字段来源。
// 查不到返回 (nil, nil)。
func GetShopeeSKUManualState(q Execer, skuID string) (*ShopeeSKUManualState, error) {
var state ShopeeSKUManualState
err := q.QueryRow(`SELECT sku_id,goods_id,COALESCE(field_sources,'{}'),COALESCE(field_observed_at,'{}')
FROM shopee_skus sk WHERE sku_id=?
AND EXISTS (SELECT 1 FROM shopee_products sp WHERE sp.goods_id=sk.goods_id AND sp.deleted_at IS NULL)`, skuID).
Scan(&state.RecordID, &state.GoodsID, &state.FieldSources, &state.FieldObservedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询待编辑蝦皮规格 %s 失败: %w", skuID, err)
}
return &state, nil
}
// UpdateShopeeSKUManualFields 原子保存人工确认的颜色、尺码和建议说明。
// spec_raw、spec_key、真实 SKU ID、SKU 编码和 is_manual 均不在更新清单中。
func UpdateShopeeSKUManualFields(q Execer, skuID, goodsID, color, size, advice,
fieldSources, fieldObservedAt, updatedAt string) (bool, error) {
result, err := q.Exec(`UPDATE shopee_skus SET color=?,size=?,advice=NULLIF(?,''),parse_ok=1,
field_sources=?,field_observed_at=?,updated_at=?
WHERE sku_id=? AND goods_id=?
AND EXISTS (SELECT 1 FROM shopee_products sp WHERE sp.goods_id=shopee_skus.goods_id AND sp.deleted_at IS NULL)`,
color, size, advice, fieldSources, fieldObservedAt, updatedAt, skuID, goodsID)
if err != nil {
return false, fmt.Errorf("保存蝦皮规格 %s 失败: %w", skuID, err)
}
count, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("确认蝦皮规格 %s 保存结果失败: %w", skuID, err)
}
return count == 1, nil
}
2026-08-08 09:57:12 +08:00
// CountShopeeProducts 统计蝦皮商品总数,供列表页判断"是否已导入过任何数据"。
func CountShopeeProducts(q Execer) (int, error) {
var n int
if err := q.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE deleted_at IS NULL`).Scan(&n); err != nil {
2026-08-08 09:57:12 +08:00
return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err)
}
return n, nil
}
// ShopeeProductsWithActiveTasks 返回仍有采集或采购任务执行中的商品编号。
func ShopeeProductsWithActiveTasks(q Execer, goodsIDs []string) ([]string, error) {
if len(goodsIDs) == 0 {
return nil, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",")
args := make([]any, len(goodsIDs))
for i, id := range goodsIDs {
args[i] = id
}
rows, err := q.Query(`SELECT DISTINCT sp.goods_id
FROM shopee_products sp JOIN tasks t
ON (t.task_type='purchase' AND t.goods_id=sp.goods_id)
OR (t.task_type='collect' AND t.goods_id=sp.pdd_goods_id)
WHERE sp.goods_id IN (`+placeholders+`)
AND t.status IN ('pending','assigned','claimed') ORDER BY sp.goods_id`, args...)
if err != nil {
return nil, fmt.Errorf("检查蝦皮商品进行中任务失败: %w", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// SetShopeeProductsDeleted 批量设置或清除软删除标记。
func SetShopeeProductsDeleted(q Execer, goodsIDs []string, actorUserID, deletedAt string) (int64, error) {
if len(goodsIDs) == 0 {
return 0, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(goodsIDs)), ",")
args := make([]any, 0, len(goodsIDs)+3)
var sqlText string
if deletedAt != "" {
sqlText = `UPDATE shopee_products SET deleted_at=?,deleted_by_user_id=?,updated_at=?
WHERE deleted_at IS NULL AND goods_id IN (` + placeholders + `)`
args = append(args, deletedAt, actorUserID, deletedAt)
} else {
sqlText = `UPDATE shopee_products SET deleted_at=NULL,deleted_by_user_id=NULL,updated_at=?
WHERE deleted_at IS NOT NULL AND goods_id IN (` + placeholders + `)`
args = append(args, model.NowISO())
}
for _, id := range goodsIDs {
args = append(args, id)
}
result, err := q.Exec(sqlText, args...)
if err != nil {
return 0, fmt.Errorf("更新蝦皮商品删除状态失败: %w", err)
}
return result.RowsAffected()
}