@@ -19,7 +19,9 @@ 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,deleted_at TEXT,deleted_by_user_id TEXT,created_at TEXT,updated_at TEXT)`,
|
||||
`CREATE TABLE shops(shop_id TEXT PRIMARY KEY,display_name TEXT,normalized_name TEXT,enabled INTEGER,created_by_user_id TEXT,created_at TEXT,updated_at TEXT)`,
|
||||
`CREATE TABLE shop_channel_aliases(alias_id TEXT PRIMARY KEY,shop_id TEXT,channel TEXT,alias_name TEXT,normalized_alias TEXT,enabled INTEGER,created_at TEXT,updated_at TEXT)`,
|
||||
`CREATE TABLE shopee_products(goods_id TEXT PRIMARY KEY,shop_id TEXT,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,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
const maxShopNameLength = 500
|
||||
|
||||
type ShopListResult struct {
|
||||
Items []model.Shop
|
||||
UnlinkedShopeeCount int
|
||||
}
|
||||
|
||||
func ListShops(db *sql.DB, actor *model.User) (ShopListResult, error) {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ShopListResult{}, ErrAdminRequired
|
||||
}
|
||||
items, err := repository.ListShops(db)
|
||||
if err != nil {
|
||||
return ShopListResult{}, err
|
||||
}
|
||||
unlinked, err := repository.CountUnlinkedShopeeShops(db)
|
||||
if err != nil {
|
||||
return ShopListResult{}, err
|
||||
}
|
||||
return ShopListResult{Items: items, UnlinkedShopeeCount: unlinked}, nil
|
||||
}
|
||||
|
||||
func ShopOptions(db *sql.DB) ([]model.Shop, error) {
|
||||
return repository.ListShopOptions(db)
|
||||
}
|
||||
|
||||
func CreateShop(db *sql.DB, actor *model.User, displayName, sybAlias, shopeeAlias string, now time.Time) error {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ErrAdminRequired
|
||||
}
|
||||
displayName, sybAlias, shopeeAlias, err := validateShopInput(displayName, sybAlias, shopeeAlias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shopID, err := randomID("SHOP-", 16)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成店铺编号失败: %w", err)
|
||||
}
|
||||
at := now.UTC().Format(model.TimeLayout)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := repository.InsertShop(tx, model.Shop{ShopID: shopID, DisplayName: displayName,
|
||||
NormalizedName: displayName, Enabled: true, CreatedByUserID: actor.UserID, CreatedAt: at, UpdatedAt: at}); err != nil {
|
||||
return shopValidationError(err)
|
||||
}
|
||||
if err := insertShopAlias(tx, shopID, "syb", sybAlias, true, at); err != nil {
|
||||
return shopValidationError(err)
|
||||
}
|
||||
if err := insertShopAlias(tx, shopID, "shopee", shopeeAlias, true, at); err != nil {
|
||||
return shopValidationError(err)
|
||||
}
|
||||
if err := repository.RebuildShopAssociations(tx, shopID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func UpdateShop(db *sql.DB, actor *model.User, shopID, displayName, sybAlias, shopeeAlias string, now time.Time) error {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ErrAdminRequired
|
||||
}
|
||||
shopID = strings.TrimSpace(shopID)
|
||||
if shopID == "" {
|
||||
return &validationError{field: "shop_id", message: "店铺编号不能为空"}
|
||||
}
|
||||
displayName, sybAlias, shopeeAlias, err := validateShopInput(displayName, sybAlias, shopeeAlias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := now.UTC().Format(model.TimeLayout)
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, found, err := repository.GetShop(tx, shopID); err != nil {
|
||||
return err
|
||||
} else if !found {
|
||||
return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"}
|
||||
}
|
||||
sybEnabled, hasSybAlias, err := repository.GetShopAliasEnabled(tx, shopID, "syb")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasSybAlias {
|
||||
sybEnabled = true
|
||||
}
|
||||
if _, err := repository.UpdateShopName(tx, shopID, displayName, displayName, at); err != nil {
|
||||
return shopValidationError(err)
|
||||
}
|
||||
if err := replaceShopAlias(tx, shopID, "syb", sybAlias, sybEnabled, at); err != nil {
|
||||
return shopValidationError(err)
|
||||
}
|
||||
if err := replaceShopAlias(tx, shopID, "shopee", shopeeAlias, true, at); err != nil {
|
||||
return shopValidationError(err)
|
||||
}
|
||||
if err := repository.RebuildShopAssociations(tx, shopID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func SetShopEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ErrAdminRequired
|
||||
}
|
||||
found, err := repository.SetShopEnabled(db, strings.TrimSpace(shopID), enabled, now.UTC().Format(model.TimeLayout))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetShopSybEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ErrAdminRequired
|
||||
}
|
||||
found, err := repository.SetShopSybEnabled(db, strings.TrimSpace(shopID), enabled, now.UTC().Format(model.TimeLayout))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return &validationError{field: "shop_id", message: "该店铺没有配置 SYB 店铺名称"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteShop(db *sql.DB, actor *model.User, shopID string) error {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ErrAdminRequired
|
||||
}
|
||||
deleted, err := repository.DeleteDisabledShop(db, strings.TrimSpace(shopID))
|
||||
if errors.Is(err, repository.ErrShopHasReferences) {
|
||||
return &validationError{field: "shop_id", message: "店铺仍有关联数据,只能保留为停用状态"}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !deleted {
|
||||
return &validationError{field: "shop_id", message: "店铺不存在或仍在启用,请先停用"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateShopInput(displayName, sybAlias, shopeeAlias string) (string, string, string, error) {
|
||||
displayName = strings.TrimSpace(displayName)
|
||||
sybAlias = strings.TrimSpace(sybAlias)
|
||||
shopeeAlias = strings.TrimSpace(shopeeAlias)
|
||||
if displayName == "" {
|
||||
return "", "", "", &validationError{field: "display_name", message: "业务店铺名称不能为空"}
|
||||
}
|
||||
for field, value := range map[string]string{"display_name": displayName, "syb_alias": sybAlias, "shopee_alias": shopeeAlias} {
|
||||
if len([]rune(value)) > maxShopNameLength {
|
||||
return "", "", "", &validationError{field: field, message: "店铺名称最多 500 个字符"}
|
||||
}
|
||||
}
|
||||
if sybAlias == "" && shopeeAlias == "" {
|
||||
return "", "", "", &validationError{field: "syb_alias", message: "SYB 或蝦皮店铺名称至少填写一个"}
|
||||
}
|
||||
return displayName, sybAlias, shopeeAlias, nil
|
||||
}
|
||||
|
||||
func insertShopAlias(q repository.Execer, shopID, channel, alias string, enabled bool, at string) error {
|
||||
if alias == "" {
|
||||
return nil
|
||||
}
|
||||
aliasID, err := randomID("ALIAS-", 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.InsertShopAlias(q, model.ShopChannelAlias{AliasID: aliasID, ShopID: shopID,
|
||||
Channel: channel, AliasName: alias, NormalizedAlias: alias, Enabled: enabled, CreatedAt: at, UpdatedAt: at})
|
||||
}
|
||||
|
||||
func replaceShopAlias(q repository.Execer, shopID, channel, alias string, enabled bool, at string) error {
|
||||
aliasID, err := randomID("ALIAS-", 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.ReplaceShopAlias(q, aliasID, shopID, channel, alias, at, enabled)
|
||||
}
|
||||
|
||||
func shopValidationError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, repository.ErrShopNameExists):
|
||||
return &validationError{field: "display_name", message: "业务店铺名称已经存在"}
|
||||
case errors.Is(err, repository.ErrShopAliasExists):
|
||||
return &validationError{field: "syb_alias", message: "该渠道店铺名称已关联到其他业务店铺"}
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
func TestShop_管理员维护渠道别名并精确关联历史数据(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
now := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
||||
admin := &model.User{UserID: "SHOP-ADMIN", Username: "shop-admin", PasswordHash: "x",
|
||||
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(),
|
||||
CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
|
||||
if err := repository.CreateUser(db, *admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,shopee_shop_name,source,created_at,updated_at)
|
||||
VALUES('S-HISTORY','历史商品','蝦皮原名','api',?,?)`, model.NowISO(), model.NowISO()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CreateShop(db, admin, "统一店铺", "SYB原名", "蝦皮原名", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := ListShops(db, admin)
|
||||
if err != nil || len(result.Items) != 1 {
|
||||
t.Fatalf("店铺列表错误: %+v %v", result, err)
|
||||
}
|
||||
shop := result.Items[0]
|
||||
if !shop.Enabled || !shop.SybSyncEnabled || shop.ShopeeProductCount != 1 {
|
||||
t.Fatalf("店铺状态或回填错误: %+v", shop)
|
||||
}
|
||||
product, err := repository.GetShopeeProductByGoodsID(db, "S-HISTORY")
|
||||
if err != nil || product.ShopID != shop.ShopID {
|
||||
t.Fatalf("蝦皮商品未精确关联: %+v %v", product, err)
|
||||
}
|
||||
if err := SetShopSybEnabled(db, admin, shop.ShopID, false, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count, err := CountEnabledSybAllowedShops(db); err != nil || count != 0 {
|
||||
t.Fatalf("SYB 停用没有生效: %d %v", count, err)
|
||||
}
|
||||
if err := EnsureEnabledSybAllowedShops(db); !IsValidationError(err) {
|
||||
t.Fatalf("没有启用 SYB 店铺应阻止同步: %v", err)
|
||||
}
|
||||
if err := SetShopEnabled(db, admin, shop.ShopID, false, now.Add(2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DeleteShop(db, admin, shop.ShopID); !IsValidationError(err) {
|
||||
t.Fatalf("有关联商品的店铺不能删除: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShop_渠道名称不可重复且采购员不能管理(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
now := time.Now()
|
||||
admin := &model.User{UserID: "SHOP-ADMIN-2", Username: "shop-admin-2", PasswordHash: "x", Role: model.RoleAdmin,
|
||||
Status: model.UserActive, PasswordChangedAt: model.NowISO(), CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
|
||||
if err := repository.CreateUser(db, *admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CreateShop(db, admin, "店铺一", "同名", "蝦皮一", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CreateShop(db, admin, "店铺二", "同名", "蝦皮二", now); !IsValidationError(err) {
|
||||
t.Fatalf("重复渠道名称必须拒绝: %v", err)
|
||||
}
|
||||
purchaser := &model.User{UserID: "BUYER", Role: model.RolePurchaser, Status: model.UserActive}
|
||||
if err := CreateShop(nil, purchaser, "店铺", "SYB", "", now); !errors.Is(err, ErrAdminRequired) {
|
||||
t.Fatalf("采购员新增应被拒绝: %v", err)
|
||||
}
|
||||
if _, err := ListShops(nil, purchaser); !errors.Is(err, ErrAdminRequired) {
|
||||
t.Fatalf("采购员读取管理页应被拒绝: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,12 @@ import (
|
||||
//
|
||||
// 一行对应一个**商品**,不是一个 SKU——见工单 #41。
|
||||
type ShopeeProductView struct {
|
||||
GoodsID string
|
||||
Title string
|
||||
ImageURL string
|
||||
ShopName string
|
||||
GoodsID string
|
||||
Title string
|
||||
ImageURL string
|
||||
ShopName string
|
||||
BusinessShopName string
|
||||
ShopUnlinked bool
|
||||
|
||||
// ColorCount / SizeCount 只统计 parse_ok = 1 的 SKU(`[必须]`,见 #41)。
|
||||
ColorCount int
|
||||
@@ -101,24 +103,26 @@ 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 != "" || filter.Deleted,
|
||||
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "" || filter.StoreID != "" || filter.Deleted,
|
||||
Page: page,
|
||||
PageSize: PageSize,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
for _, r := range rows {
|
||||
v := ShopeeProductView{
|
||||
GoodsID: r.GoodsID,
|
||||
Title: r.Title,
|
||||
ImageURL: r.ImageURL,
|
||||
ShopName: r.ShopeeShopName,
|
||||
ColorCount: r.ColorCount,
|
||||
SizeCount: r.SizeCount,
|
||||
SKUCount: r.SKUCount,
|
||||
PendingCount: r.PendingCount,
|
||||
SourceText: shopeeSourceText(r.Source),
|
||||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||
Deleted: r.IsDeleted(),
|
||||
GoodsID: r.GoodsID,
|
||||
Title: r.Title,
|
||||
ImageURL: r.ImageURL,
|
||||
ShopName: r.ShopeeShopName,
|
||||
BusinessShopName: r.BusinessShopName,
|
||||
ShopUnlinked: r.ShopID == "",
|
||||
ColorCount: r.ColorCount,
|
||||
SizeCount: r.SizeCount,
|
||||
SKUCount: r.SKUCount,
|
||||
PendingCount: r.PendingCount,
|
||||
SourceText: shopeeSourceText(r.Source),
|
||||
UpdatedAt: formatLocalTime(r.UpdatedAt),
|
||||
Deleted: r.IsDeleted(),
|
||||
}
|
||||
|
||||
if r.PddGoodsID == "" {
|
||||
|
||||
+17
-13
@@ -18,6 +18,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -553,21 +554,22 @@ func RunSybSync(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.
|
||||
// 局部历史补拉只 upsert 数据、不动游标,否则会让未覆盖的订单永久漏掉。
|
||||
func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client, cfg config.SybConfig, now time.Time, options SybSyncOptions) SyncReport {
|
||||
report := SyncReport{StartedAt: now, Specified: options.IsSpecified()}
|
||||
allowedNames, err := repository.ListEnabledSybShopNames(db)
|
||||
allowedShops, err := repository.ListEnabledSybShopMappings(db)
|
||||
if err != nil {
|
||||
report.Err = fmt.Errorf("读取顺运宝允许店铺失败: %w", err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
if len(allowedNames) == 0 {
|
||||
report.Err = fmt.Errorf("没有启用的顺运宝同步店铺,请先由管理员在“同步店铺”中配置并启用至少一个店铺")
|
||||
if len(allowedShops) == 0 {
|
||||
report.Err = fmt.Errorf("没有启用的顺运宝同步店铺,请先由管理员在“店铺管理”中配置并启用至少一个 SYB 店铺")
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report
|
||||
}
|
||||
allowedShops := make(map[string]struct{}, len(allowedNames))
|
||||
for _, name := range allowedNames {
|
||||
allowedShops[name] = struct{}{}
|
||||
allowedNames := make([]string, 0, len(allowedShops))
|
||||
for name := range allowedShops {
|
||||
allowedNames = append(allowedNames, name)
|
||||
}
|
||||
sort.Strings(allowedNames)
|
||||
report.ShopFilterHash = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(allowedNames, "\x00"))))
|
||||
|
||||
pageSize := cfg.PageSize
|
||||
@@ -754,7 +756,7 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
||||
report.StockCount += len(rawIDs)
|
||||
orderedIDs := make([]int64, 0, len(rawIDs))
|
||||
for _, id := range rawIDs {
|
||||
if !sybShopAllowed(allowedShops, stockByID[id].Raw, nil) {
|
||||
if _, ok := sybShopID(allowedShops, stockByID[id].Raw, nil); !ok {
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
@@ -782,12 +784,13 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
||||
}
|
||||
for _, d := range details {
|
||||
stockRow := stockByID[d.ID]
|
||||
if !sybShopAllowed(allowedShops, stockRow.Raw, d.Raw) {
|
||||
shopID, ok := sybShopID(allowedShops, stockRow.Raw, d.Raw)
|
||||
if !ok {
|
||||
report.AcceptedCount--
|
||||
report.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if err := writeStockDetail(db, cfg.BaseURL, stockRow, d, &report); err != nil {
|
||||
if err := writeStockDetail(db, cfg.BaseURL, shopID, stockRow, d, &report); err != nil {
|
||||
report.Err = fmt.Errorf("写入货运单 %s(id=%d)失败(本次同步整体作废,"+
|
||||
"已写入的数据保留): %w", d.Code, d.ID, err)
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
@@ -820,10 +823,10 @@ func RunSybSyncWithOptions(ctx context.Context, db *sql.DB, client *syb.Client,
|
||||
|
||||
// sybShopAllowed 用明细字段覆盖列表字段后再核对,防止列表通过但明细在同步
|
||||
// 期间已变成其他店铺。detail 为空时只检查列表快照。
|
||||
func sybShopAllowed(allowed map[string]struct{}, listRaw, detailRaw map[string]any) bool {
|
||||
func sybShopID(allowed map[string]string, listRaw, detailRaw map[string]any) (string, bool) {
|
||||
name := trimmedStringField(mergeRaw(listRaw, detailRaw), "shopName")
|
||||
_, ok := allowed[name]
|
||||
return ok
|
||||
shopID, ok := allowed[name]
|
||||
return shopID, ok
|
||||
}
|
||||
|
||||
// validateDetailBatch 确认批量明细响应与请求 ID 一一对应。任何缺失、重复、
|
||||
@@ -856,7 +859,7 @@ func validateDetailBatch(requested []int64, details []syb.StockDetail) error {
|
||||
|
||||
// writeStockDetail 把一张货运单的全部商品明细写进 syb_orders,
|
||||
// 一张货运单一个事务(工单 #46「按货运单为单位提交」)。
|
||||
func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail syb.StockDetail, report *SyncReport) error {
|
||||
func writeStockDetail(db *sql.DB, baseURL, shopID string, stockRow syb.StockRow, detail syb.StockDetail, report *SyncReport) error {
|
||||
if len(detail.Details) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -888,6 +891,7 @@ func writeStockDetail(db *sql.DB, baseURL string, stockRow syb.StockRow, detail
|
||||
order := model.SybOrder{
|
||||
SybID: sybID,
|
||||
OrderNo: detail.Code,
|
||||
ShopID: shopID,
|
||||
ShopName: trimmedStringField(stockRaw, "shopName"),
|
||||
Title: item.ProductTitle,
|
||||
ProductSpec: item.ProductSpec,
|
||||
|
||||
@@ -2,27 +2,13 @@ package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
const maxSybShopNameLength = 500
|
||||
|
||||
func ListSybAllowedShops(db *sql.DB, actor *model.User) ([]model.SybAllowedShop, error) {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return nil, ErrAdminRequired
|
||||
}
|
||||
return repository.ListSybAllowedShops(db)
|
||||
}
|
||||
|
||||
func CountEnabledSybAllowedShops(db *sql.DB) (int, error) {
|
||||
names, err := repository.ListEnabledSybShopNames(db)
|
||||
return len(names), err
|
||||
mappings, err := repository.ListEnabledSybShopMappings(db)
|
||||
return len(mappings), err
|
||||
}
|
||||
|
||||
func EnsureEnabledSybAllowedShops(db *sql.DB) error {
|
||||
@@ -31,69 +17,7 @@ func EnsureEnabledSybAllowedShops(db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return &validationError{field: "shop_name", message: "没有启用的顺运宝同步店铺,请先由管理员配置并启用至少一个店铺"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateSybAllowedShop(db *sql.DB, actor *model.User, rawName string, now time.Time) error {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ErrAdminRequired
|
||||
}
|
||||
name := strings.TrimSpace(rawName)
|
||||
if name == "" {
|
||||
return &validationError{field: "shop_name", message: "店铺名称不能为空"}
|
||||
}
|
||||
if len([]rune(name)) > maxSybShopNameLength {
|
||||
return &validationError{field: "shop_name", message: "店铺名称最多 500 个字符"}
|
||||
}
|
||||
id, err := randomID("SHOP-", 16)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成店铺编号失败: %w", err)
|
||||
}
|
||||
at := now.UTC().Format(model.TimeLayout)
|
||||
err = repository.InsertSybAllowedShop(db, model.SybAllowedShop{
|
||||
ShopID: id, ShopName: name, NormalizedName: name, Enabled: true,
|
||||
CreatedByUserID: actor.UserID, CreatedAt: at, UpdatedAt: at,
|
||||
})
|
||||
if errors.Is(err, repository.ErrSybAllowedShopExists) {
|
||||
return &validationError{field: "shop_name", message: "该店铺已经在允许列表中,可直接重新启用"}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func SetSybAllowedShopEnabled(db *sql.DB, actor *model.User, shopID string, enabled bool, now time.Time) error {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ErrAdminRequired
|
||||
}
|
||||
if strings.TrimSpace(shopID) == "" {
|
||||
return &validationError{field: "shop_id", message: "店铺编号不能为空"}
|
||||
}
|
||||
found, err := repository.SetSybAllowedShopEnabled(db, shopID, enabled, now.UTC().Format(model.TimeLayout))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return &validationError{field: "shop_id", message: "店铺不存在,请刷新页面后重试"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteDisabledSybAllowedShop 永久删除一条已停用配置,不影响历史货运单和同步记录。
|
||||
func DeleteDisabledSybAllowedShop(db *sql.DB, actor *model.User, shopID string) error {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ErrAdminRequired
|
||||
}
|
||||
shopID = strings.TrimSpace(shopID)
|
||||
if shopID == "" {
|
||||
return &validationError{field: "shop_id", message: "店铺编号不能为空"}
|
||||
}
|
||||
deleted, err := repository.DeleteDisabledSybAllowedShop(db, shopID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !deleted {
|
||||
return &validationError{field: "shop_id", message: "店铺不存在或仍在启用,请刷新页面并先停用后再删除"}
|
||||
return &validationError{field: "shop_name", message: "没有启用的顺运宝同步店铺,请先由管理员在店铺管理中配置并启用至少一个 SYB 店铺"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
func TestSybAllowedShop_管理员维护与精确去重(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
now := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC)
|
||||
admin := &model.User{UserID: "SHOP-ADMIN", Username: "shop-admin", PasswordHash: "x",
|
||||
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: model.NowISO(),
|
||||
CreatedAt: model.NowISO(), UpdatedAt: model.NowISO()}
|
||||
if err := repository.CreateUser(db, *admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := CreateSybAllowedShop(db, admin, " qwg8fkb044 ", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repository.UpsertSybOrder(db, model.SybOrder{
|
||||
SybID: "SHOP-HISTORY", OrderNo: "ORDER-HISTORY", ShopName: "qwg8fkb044",
|
||||
Quantity: 1, SybData: `{}`, CreatedAt: model.NowISO(), UpdatedAt: model.NowISO(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.CreateSybSyncRun(db, model.SybSyncRun{
|
||||
RunID: "SHOP-RUN-HISTORY", UserID: admin.UserID, DateFrom: "2026-08-12",
|
||||
DateTo: "2026-08-12", Status: model.SybSyncRunning, StartedAt: model.NowISO(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := ListSybAllowedShops(db, admin)
|
||||
if err != nil || len(rows) != 1 || rows[0].ShopName != "qwg8fkb044" || !rows[0].Enabled {
|
||||
t.Fatalf("新增结果错误: rows=%+v err=%v", rows, err)
|
||||
}
|
||||
if err := CreateSybAllowedShop(db, admin, "qwg8fkb044", now); !IsValidationError(err) {
|
||||
t.Fatalf("去除首尾空白后的重名应是表单错误,实际 %v", err)
|
||||
}
|
||||
if err := SetSybAllowedShopEnabled(db, admin, rows[0].ShopID, false, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count, err := CountEnabledSybAllowedShops(db); err != nil || count != 0 {
|
||||
t.Fatalf("停用后启用数错误: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := EnsureEnabledSybAllowedShops(db); !IsValidationError(err) {
|
||||
t.Fatalf("空白名单应阻止同步: %v", err)
|
||||
}
|
||||
if err := DeleteDisabledSybAllowedShop(db, admin, rows[0].ShopID); err != nil {
|
||||
t.Fatalf("删除已停用店铺失败: %v", err)
|
||||
}
|
||||
if list, err := ListSybAllowedShops(db, admin); err != nil || len(list) != 0 {
|
||||
t.Fatalf("删除后仍存在: rows=%+v err=%v", list, err)
|
||||
}
|
||||
if count, err := repository.CountSybOrdersTotal(db); err != nil || count != 1 {
|
||||
t.Fatalf("删除配置不应影响历史货运单: count=%d err=%v", count, err)
|
||||
}
|
||||
if count, err := repository.CountSybSyncRuns(db); err != nil || count != 1 {
|
||||
t.Fatalf("删除配置不应影响同步记录: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := CreateSybAllowedShop(db, admin, "qwg8fkb044", now.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("删除后应允许重新新增同名店铺: %v", err)
|
||||
}
|
||||
newRows, _ := ListSybAllowedShops(db, admin)
|
||||
if err := DeleteDisabledSybAllowedShop(db, admin, newRows[0].ShopID); !IsValidationError(err) {
|
||||
t.Fatalf("启用店铺必须拒绝删除: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSybAllowedShop_采购员不能管理(t *testing.T) {
|
||||
purchaser := &model.User{UserID: "BUYER", Role: model.RolePurchaser, Status: model.UserActive}
|
||||
if err := CreateSybAllowedShop(nil, purchaser, "店铺", time.Now()); !errors.Is(err, ErrAdminRequired) {
|
||||
t.Fatalf("采购员新增应被拒绝: %v", err)
|
||||
}
|
||||
if _, err := ListSybAllowedShops(nil, purchaser); !errors.Is(err, ErrAdminRequired) {
|
||||
t.Fatalf("采购员读取管理列表应被拒绝: %v", err)
|
||||
}
|
||||
if err := DeleteDisabledSybAllowedShop(nil, purchaser, "SHOP-1"); !errors.Is(err, ErrAdminRequired) {
|
||||
t.Fatalf("采购员删除应被拒绝: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -373,18 +373,23 @@ func newSyncTestDB(t *testing.T) *sql.DB {
|
||||
}); err != nil {
|
||||
t.Fatalf("准备同步测试管理员失败: %v", err)
|
||||
}
|
||||
if err := repository.InsertSybAllowedShop(db, model.SybAllowedShop{
|
||||
ShopID: "SYB-TEST-SHOP", ShopName: "测试店铺", NormalizedName: "测试店铺", Enabled: true,
|
||||
if err := repository.InsertShop(db, model.Shop{
|
||||
ShopID: "SYB-TEST-SHOP", DisplayName: "测试店铺", NormalizedName: "测试店铺", Enabled: true,
|
||||
CreatedByUserID: "SYB-TEST-ADMIN", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("准备同步测试允许店铺失败: %v", err)
|
||||
t.Fatalf("准备同步测试业务店铺失败: %v", err)
|
||||
}
|
||||
if err := repository.InsertShopAlias(db, model.ShopChannelAlias{AliasID: "SYB-TEST-ALIAS",
|
||||
ShopID: "SYB-TEST-SHOP", Channel: "syb", AliasName: "测试店铺", NormalizedAlias: "测试店铺",
|
||||
Enabled: true, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatalf("准备同步测试渠道名称失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRunSybSync_没有启用店铺时不请求顺运宝(t *testing.T) {
|
||||
db := newSyncTestDB(t)
|
||||
if _, err := db.Exec(`UPDATE syb_allowed_shops SET enabled=0`); err != nil {
|
||||
if _, err := db.Exec(`UPDATE shop_channel_aliases SET enabled=0 WHERE channel='syb'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requests := 0
|
||||
@@ -409,14 +414,14 @@ func TestRunSybSync_没有启用店铺时不请求顺运宝(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSybShopAllowed_明细店铺覆盖列表后重新拦截(t *testing.T) {
|
||||
allowed := map[string]struct{}{"测试店铺": {}}
|
||||
if !sybShopAllowed(allowed, map[string]any{"shopName": " 测试店铺 "}, nil) {
|
||||
allowed := map[string]string{"测试店铺": "SHOP-1"}
|
||||
if shopID, ok := sybShopID(allowed, map[string]any{"shopName": " 测试店铺 "}, nil); !ok || shopID != "SHOP-1" {
|
||||
t.Fatal("应忽略允许店铺名称首尾空白")
|
||||
}
|
||||
if sybShopAllowed(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": "其他店铺"}) {
|
||||
if _, ok := sybShopID(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": "其他店铺"}); ok {
|
||||
t.Fatal("明细店铺变化后必须重新拦截")
|
||||
}
|
||||
if sybShopAllowed(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": " "}) {
|
||||
if _, ok := sybShopID(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": " "}); ok {
|
||||
t.Fatal("明细店铺变为空值时必须拦截")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user