@@ -19,7 +19,7 @@ func newCatalogTestDB(t *testing.T) *sql.DB {
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
statements := []string{
|
||||
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,created_at TEXT,updated_at TEXT)`,
|
||||
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,title TEXT NOT NULL,shopee_status TEXT,main_sku_code TEXT,image_url TEXT,shopee_shop_name TEXT,image_source TEXT,image_observed_at TEXT,image_is_manual INTEGER DEFAULT 0,shop_name_source TEXT,shop_name_observed_at TEXT,shop_name_is_manual INTEGER DEFAULT 0,source TEXT,source_observed_at TEXT,pdd_goods_url TEXT,pdd_goods_id TEXT,deleted_at TEXT,deleted_by_user_id TEXT,created_at TEXT,updated_at TEXT)`,
|
||||
`CREATE TABLE shopee_skus(sku_id TEXT PRIMARY KEY,shopee_sku_id TEXT UNIQUE,goods_id TEXT NOT NULL,spec_raw TEXT,spec_key TEXT,color TEXT,size TEXT,advice TEXT,parse_ok INTEGER,sku_code TEXT,is_manual INTEGER,source TEXT,field_sources TEXT,field_observed_at TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT,UNIQUE(goods_id,spec_key))`,
|
||||
`CREATE TABLE pdd_products(id INTEGER PRIMARY KEY AUTOINCREMENT,goods_id TEXT UNIQUE,url TEXT,title TEXT,shop_name TEXT,skus_json TEXT,collect_status TEXT,collect_msg TEXT,artifact_ref TEXT,collected_at TEXT,deleted_at TEXT,source TEXT,source_observed_at TEXT,created_at TEXT,updated_at TEXT)`,
|
||||
`CREATE TABLE catalog_import_runs(source TEXT,batch_id TEXT,request_hash TEXT,status TEXT,request_count INTEGER,conflict_count INTEGER,observed_at TEXT,update_policy TEXT DEFAULT 'fill_missing',last_request_at TEXT,last_conflict_at TEXT,shopee_created INTEGER DEFAULT 0,shopee_updated INTEGER DEFAULT 0,shopee_fields_filled INTEGER DEFAULT 0,shopee_fields_same_source_updated INTEGER DEFAULT 0,shopee_fields_manual_skipped INTEGER DEFAULT 0,shopee_fields_stale_skipped INTEGER DEFAULT 0,sku_created INTEGER DEFAULT 0,sku_updated INTEGER DEFAULT 0,sku_filled INTEGER DEFAULT 0,sku_same_source_updated INTEGER DEFAULT 0,sku_skipped INTEGER DEFAULT 0,sku_manual_skipped INTEGER DEFAULT 0,sku_stale_skipped INTEGER DEFAULT 0,pdd_created INTEGER DEFAULT 0,pdd_updated INTEGER DEFAULT 0,association_created INTEGER DEFAULT 0,association_unchanged INTEGER DEFAULT 0,failure_count INTEGER DEFAULT 0,error_summary TEXT,response_body TEXT,created_at TEXT,finished_at TEXT,PRIMARY KEY(source,batch_id))`,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
func normalizeShopeeIDs(ids []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
result := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// DeleteShopeeProducts 由管理员把商品移入可恢复的删除状态。
|
||||
func DeleteShopeeProducts(db *sql.DB, actor *model.User, ids []string) (int64, error) {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return 0, ErrAdminRequired
|
||||
}
|
||||
ids = normalizeShopeeIDs(ids)
|
||||
if len(ids) == 0 {
|
||||
return 0, invalidInput("没有勾选任何蝦皮商品")
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
busy, err := repository.ShopeeProductsWithActiveTasks(tx, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(busy) > 0 {
|
||||
return 0, invalidInput(fmt.Sprintf("商品 %s 仍有进行中的采集或采购任务,不能删除", strings.Join(busy, "、")))
|
||||
}
|
||||
count, err := repository.SetShopeeProductsDeleted(tx, ids, actor.UserID, time.Now().UTC().Format(model.TimeLayout))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, tx.Commit()
|
||||
}
|
||||
|
||||
// RestoreShopeeProducts 由管理员恢复软删除商品,原 SKU 和 PDD 关联保持不变。
|
||||
func RestoreShopeeProducts(db *sql.DB, actor *model.User, ids []string) (int64, error) {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return 0, ErrAdminRequired
|
||||
}
|
||||
ids = normalizeShopeeIDs(ids)
|
||||
if len(ids) == 0 {
|
||||
return 0, invalidInput("没有勾选任何蝦皮商品")
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
count, err := repository.SetShopeeProductsDeleted(tx, ids, actor.UserID, "")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
func TestShopeeSoftDelete_保留关联且可恢复(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
now := model.NowISO()
|
||||
admin := &model.User{UserID: "ADMIN-DELETE", Username: "delete-admin", PasswordHash: "test", Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now}
|
||||
if err := repository.CreateUser(db, *admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO pdd_products(goods_id,url,collect_status,created_at,updated_at) VALUES('P-DEL','https://example.com/p','collected',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,pdd_goods_id,pdd_goods_url,source,created_at,updated_at) VALUES('S-DEL','商品','P-DEL','https://example.com/p','report',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO shopee_skus(sku_id,goods_id,spec_raw,spec_key,parse_ok,is_manual,source,created_at,updated_at) VALUES('SKU-DEL','S-DEL','黑色,M','黑色,M',1,0,'report',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count, err := DeleteShopeeProducts(db, admin, []string{"S-DEL", "S-DEL"})
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("软删除失败:count=%d err=%v", count, err)
|
||||
}
|
||||
if product, _ := repository.GetShopeeProductByGoodsID(db, "S-DEL"); product != nil {
|
||||
t.Fatal("默认查询不应返回已删除商品")
|
||||
}
|
||||
var skuCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM shopee_skus WHERE goods_id='S-DEL'`).Scan(&skuCount); err != nil || skuCount != 1 {
|
||||
t.Fatalf("软删除不应删除 SKU:count=%d err=%v", skuCount, err)
|
||||
}
|
||||
count, err = RestoreShopeeProducts(db, admin, []string{"S-DEL"})
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("恢复失败:count=%d err=%v", count, err)
|
||||
}
|
||||
product, err := repository.GetShopeeProductByGoodsID(db, "S-DEL")
|
||||
if err != nil || product == nil || product.PddGoodsID != "P-DEL" {
|
||||
t.Fatalf("恢复后关联丢失:product=%+v err=%v", product, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShopeeSoftDelete_进行中任务阻止整批删除(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
now := model.NowISO()
|
||||
admin := &model.User{UserID: "ADMIN-BUSY", Username: "busy-admin", PasswordHash: "test", Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now}
|
||||
if err := repository.CreateUser(db, *admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"S-BUSY", "S-FREE"} {
|
||||
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES(?,?,'report',?,?)`, id, id, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO tasks(task_id,task_type,status,goods_id,pdd_goods_url,created_at,updated_at) VALUES('cg999','purchase','pending','S-BUSY','https://example.com/p',?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := DeleteShopeeProducts(db, admin, []string{"S-BUSY", "S-FREE"}); err == nil || !IsValidationError(err) {
|
||||
t.Fatalf("有进行中任务时应阻止整批删除,实际 err=%v", err)
|
||||
}
|
||||
var deleted int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM shopee_products WHERE deleted_at IS NOT NULL`).Scan(&deleted); err != nil || deleted != 0 {
|
||||
t.Fatalf("整批删除应回滚:deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ type ShopeeProductView struct {
|
||||
|
||||
StatusText string
|
||||
UpdatedAt string
|
||||
Deleted bool
|
||||
}
|
||||
|
||||
// ShopeeListResult 是列表页要的全部数据。
|
||||
@@ -100,7 +101,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
|
||||
Rows: make([]ShopeeProductView, 0, len(rows)),
|
||||
Total: total,
|
||||
HasAnyProducts: hasAny > 0,
|
||||
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "",
|
||||
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "" || filter.Deleted,
|
||||
Page: page,
|
||||
PageSize: PageSize,
|
||||
TotalPages: totalPages,
|
||||
@@ -117,6 +118,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
|
||||
PendingCount: r.PendingCount,
|
||||
SourceText: shopeeSourceText(r.Source),
|
||||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||
Deleted: r.IsDeleted(),
|
||||
}
|
||||
|
||||
if r.PddGoodsID == "" {
|
||||
|
||||
@@ -902,6 +902,9 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := repository.UpsertSybShopeeSKU(tx, order.ShopeeGoodsID, order.ProductSpec, model.NowISO()); err != nil {
|
||||
return fmt.Errorf("写入顺运宝规格观测失败: %w", err)
|
||||
}
|
||||
if created {
|
||||
report.Created++
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user