Files
cmautobuy/admin/repository/shopee.go
T

551 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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)
}
}
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`)
}
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)
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,
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 shopID, title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
var imageManual, shopManual int
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,
&r.CollectStatus, &r.CollectMsg,
); err != nil {
return nil, fmt.Errorf("读取蝦皮商品列表失败: %w", err)
}
r.Title = title.String
r.ShopID = shopID.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
r.DeletedAt = deletedAt.String
r.DeletedByUserID = deletedBy.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 shopID, title, shopeeStatus, mainSKUCode, imageURL, shopName, imageSource, imageObserved, shopSource, shopObserved, source, pddGoodsURL, pddGoodsID, deletedAt, deletedBy sql.NullString
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
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
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(`
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 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(
&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
}
// 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
}
// 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 {
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()
}