feat(pickup): 店铺管理支持历史店铺可视化手动映射与一键批量清洗
- 数据库升级:新增 pickup_shop_mappings 映射别名表,并在 admin_pickups 增加 raw_shop_name 永久保留原始店铺录入值 - 后端映射能力:提供历史店铺聚合统计、单条/批量映射至目标标准店铺、映射规则撤销与提号单还原接口;新建提号时若录入已映射别名自动归一化 - 前端管理升级:PickupShopDialog 升级为双 Tab 结构,Tab 2「历史店铺映射与清洗」支持总览统计卡片、状态筛选、单条/批量映射清洗、一键设为标准店铺及撤销还原 - 验证方式:执行并通过全套代码门禁(scripts/check.sh:SQL约束、go vet/build/test、MySQL测试库覆盖率、vue大小、eslint、prettier、vue-tsc及vite构建)
This commit is contained in:
@@ -20,6 +20,7 @@ type AdminPickup struct {
|
||||
Platform string `gorm:"size:32;not null;default:''" json:"platform"`
|
||||
ThirdPartyOrderNo string `gorm:"size:128;not null;default:'';index" json:"third_party_order_no"`
|
||||
ShopName string `gorm:"size:100;not null;default:'';index" json:"shop_name"`
|
||||
RawShopName string `gorm:"size:100;not null;default:''" json:"raw_shop_name"`
|
||||
AccountSource string `gorm:"size:32;not null;default:'internal';index" json:"account_source"`
|
||||
SourceChannel string `gorm:"size:32;not null;default:''" json:"source_channel"`
|
||||
SettlementMode string `gorm:"size:32;not null;default:'owner_wallet'" json:"settlement_mode"`
|
||||
|
||||
@@ -17,3 +17,20 @@ type PickupShop struct {
|
||||
func (PickupShop) TableName() string {
|
||||
return "pickup_shops"
|
||||
}
|
||||
|
||||
// PickupShopMapping 线下提号店铺历史映射/别名模型。
|
||||
type PickupShopMapping struct {
|
||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||
SourceName string `gorm:"size:100;not null;uniqueIndex" json:"source_name"`
|
||||
TargetShopID uint64 `gorm:"not null;default:0" json:"target_shop_id"`
|
||||
TargetShopName string `gorm:"size:100;not null;index" json:"target_shop_name"`
|
||||
MappedCount int64 `gorm:"not null;default:0" json:"mapped_count"`
|
||||
Remark string `gorm:"size:255;not null;default:''" json:"remark"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (PickupShopMapping) TableName() string {
|
||||
return "pickup_shop_mappings"
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ type PickupDTO struct {
|
||||
Platform string `json:"platform"`
|
||||
ThirdPartyOrderNo string `json:"third_party_order_no"`
|
||||
ShopName string `json:"shop_name"`
|
||||
RawShopName string `json:"raw_shop_name"`
|
||||
AccountSource string `json:"account_source"`
|
||||
SourceChannel string `json:"source_channel"`
|
||||
SettlementMode string `json:"settlement_mode"`
|
||||
@@ -195,6 +196,39 @@ type PickupShopQuery struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
type PickupShopHistoricalItemDTO struct {
|
||||
SourceName string `json:"source_name"`
|
||||
Count int64 `json:"count"`
|
||||
MappingStatus string `json:"mapping_status"` // standard, mapped, unmapped
|
||||
TargetShopID uint64 `json:"target_shop_id,omitempty"`
|
||||
TargetShopName string `json:"target_shop_name,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
}
|
||||
|
||||
type PickupShopHistoricalStatsDTO struct {
|
||||
Items []PickupShopHistoricalItemDTO `json:"items"`
|
||||
TotalPickups int64 `json:"total_pickups"`
|
||||
TotalShops int `json:"total_shops"`
|
||||
StandardCount int `json:"standard_count"`
|
||||
MappedCount int `json:"mapped_count"`
|
||||
UnmappedCount int `json:"unmapped_count"`
|
||||
}
|
||||
|
||||
type MapHistoricalShopsRequest struct {
|
||||
FromShopNames []string `json:"from_shop_names" binding:"required,min=1"`
|
||||
TargetShopName string `json:"target_shop_name" binding:"required,max=100"`
|
||||
CreateTargetMissing bool `json:"create_target_missing"`
|
||||
TargetPlatform string `json:"target_platform" binding:"max=32"`
|
||||
Remark string `json:"remark" binding:"max=255"`
|
||||
}
|
||||
|
||||
type MapHistoricalShopsResultDTO struct {
|
||||
UpdatedPickupsCount int64 `json:"updated_pickups_count"`
|
||||
TargetShopName string `json:"target_shop_name"`
|
||||
TargetShopID uint64 `json:"target_shop_id"`
|
||||
CreatedStandardShop bool `json:"created_standard_shop"`
|
||||
}
|
||||
|
||||
type SellerPickupQuery struct {
|
||||
Page int
|
||||
PageSize int
|
||||
|
||||
@@ -383,6 +383,55 @@ func (h *Handler) DeleteShop(c *gin.Context) {
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// GetHistoricalShopStats 获取历史店铺聚合统计及映射情况(管理员)
|
||||
func (h *Handler) GetHistoricalShopStats(c *gin.Context) {
|
||||
stats, err := h.service.GetHistoricalShopStats(c.Request.Context())
|
||||
if err != nil {
|
||||
writePickupError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, stats)
|
||||
}
|
||||
|
||||
// MapHistoricalShops 批量映射历史店铺并清洗提号单(管理员)
|
||||
func (h *Handler) MapHistoricalShops(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
var req MapHistoricalShopsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "映射参数格式不正确")
|
||||
return
|
||||
}
|
||||
res, err := h.service.MapHistoricalShops(c.Request.Context(), req, adminID, auditMeta(c))
|
||||
if err != nil {
|
||||
writePickupError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
// DeleteShopMapping 删除历史店铺映射规则并还原提号单(管理员)
|
||||
func (h *Handler) DeleteShopMapping(c *gin.Context) {
|
||||
adminID, ok := currentAdminID(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
return
|
||||
}
|
||||
sourceName := c.Query("source_name")
|
||||
if sourceName == "" {
|
||||
response.BadRequest(c, "缺少 source_name 参数")
|
||||
return
|
||||
}
|
||||
if err := h.service.DeleteShopMapping(c.Request.Context(), sourceName, adminID, auditMeta(c)); err != nil {
|
||||
writePickupError(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func auditMeta(c *gin.Context) auditlog.Meta {
|
||||
return auditlog.Meta{
|
||||
IP: c.ClientIP(),
|
||||
|
||||
@@ -81,6 +81,15 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shopName := strings.TrimSpace(req.ShopName)
|
||||
rawShopName := shopName
|
||||
if shopName != "" {
|
||||
var mapping model.PickupShopMapping
|
||||
if err := tx.Where("source_name = ?", shopName).First(&mapping).Error; err == nil && mapping.TargetShopName != "" {
|
||||
shopName = mapping.TargetShopName
|
||||
}
|
||||
}
|
||||
|
||||
pickup := model.AdminPickup{
|
||||
PickupNo: pickupNo,
|
||||
ListingID: listing.ID,
|
||||
@@ -90,7 +99,8 @@ func (r *Repository) Create(ctx context.Context, req CreateRequest, adminID uint
|
||||
Operator: strings.TrimSpace(req.Operator),
|
||||
Platform: strings.TrimSpace(req.Platform),
|
||||
ThirdPartyOrderNo: strings.TrimSpace(req.ThirdPartyOrderNo),
|
||||
ShopName: strings.TrimSpace(req.ShopName),
|
||||
ShopName: shopName,
|
||||
RawShopName: rawShopName,
|
||||
AccountSource: accountSource,
|
||||
SourceChannel: sourceChannel,
|
||||
SettlementMode: pickupSettlementMode(listing.SettlementMode, accountSource),
|
||||
@@ -1086,6 +1096,274 @@ func (r *Repository) DeleteShop(ctx context.Context, id uint64, adminID uint64,
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHistoricalShopStats 聚合统计 admin_pickups 中的全部历史店铺,并对照标准库与映射规则
|
||||
func (r *Repository) GetHistoricalShopStats(ctx context.Context) (*PickupShopHistoricalStatsDTO, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
|
||||
type rawCountRow struct {
|
||||
SourceName string `gorm:"column:source_name"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
var rows []rawCountRow
|
||||
err := r.db.WithContext(ctx).Model(&model.AdminPickup{}).
|
||||
Select("CASE WHEN raw_shop_name <> '' THEN raw_shop_name ELSE shop_name END AS source_name, COUNT(*) AS cnt").
|
||||
Group("source_name").
|
||||
Order("cnt DESC, source_name ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var standardShops []model.PickupShop
|
||||
if err := r.db.WithContext(ctx).Model(&model.PickupShop{}).Find(&standardShops).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
standardMap := make(map[string]model.PickupShop)
|
||||
for _, s := range standardShops {
|
||||
standardMap[s.Name] = s
|
||||
}
|
||||
|
||||
var mappings []model.PickupShopMapping
|
||||
if err := r.db.WithContext(ctx).Model(&model.PickupShopMapping{}).Find(&mappings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mappingMap := make(map[string]model.PickupShopMapping)
|
||||
for _, m := range mappings {
|
||||
mappingMap[m.SourceName] = m
|
||||
}
|
||||
|
||||
var totalPickups int64
|
||||
standardCount := 0
|
||||
mappedCount := 0
|
||||
unmappedCount := 0
|
||||
|
||||
seenSource := make(map[string]bool)
|
||||
items := make([]PickupShopHistoricalItemDTO, 0, len(rows)+len(mappings))
|
||||
|
||||
for _, row := range rows {
|
||||
seenSource[row.SourceName] = true
|
||||
totalPickups += row.Cnt
|
||||
|
||||
item := PickupShopHistoricalItemDTO{
|
||||
SourceName: row.SourceName,
|
||||
Count: row.Cnt,
|
||||
}
|
||||
|
||||
if mapping, ok := mappingMap[row.SourceName]; ok {
|
||||
item.MappingStatus = "mapped"
|
||||
item.TargetShopID = mapping.TargetShopID
|
||||
item.TargetShopName = mapping.TargetShopName
|
||||
item.Remark = mapping.Remark
|
||||
mappedCount++
|
||||
} else if std, ok := standardMap[row.SourceName]; ok && row.SourceName != "" {
|
||||
item.MappingStatus = "standard"
|
||||
item.TargetShopID = std.ID
|
||||
item.TargetShopName = std.Name
|
||||
standardCount++
|
||||
} else {
|
||||
item.MappingStatus = "unmapped"
|
||||
unmappedCount++
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
for _, m := range mappings {
|
||||
if !seenSource[m.SourceName] {
|
||||
mappedCount++
|
||||
items = append(items, PickupShopHistoricalItemDTO{
|
||||
SourceName: m.SourceName,
|
||||
Count: m.MappedCount,
|
||||
MappingStatus: "mapped",
|
||||
TargetShopID: m.TargetShopID,
|
||||
TargetShopName: m.TargetShopName,
|
||||
Remark: m.Remark,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &PickupShopHistoricalStatsDTO{
|
||||
Items: items,
|
||||
TotalPickups: totalPickups,
|
||||
TotalShops: len(items),
|
||||
StandardCount: standardCount,
|
||||
MappedCount: mappedCount,
|
||||
UnmappedCount: unmappedCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MapHistoricalShops 批量将历史店铺名称映射至目标标准店铺,并同步清洗历史提号单中的店铺名
|
||||
func (r *Repository) MapHistoricalShops(ctx context.Context, req MapHistoricalShopsRequest, adminID uint64, meta auditlog.Meta) (*MapHistoricalShopsResultDTO, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
|
||||
targetName := strings.TrimSpace(req.TargetShopName)
|
||||
if targetName == "" {
|
||||
return nil, ErrInvalidAmount
|
||||
}
|
||||
|
||||
fromNames := make([]string, 0, len(req.FromShopNames))
|
||||
seen := make(map[string]bool)
|
||||
for _, name := range req.FromShopNames {
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
fromNames = append(fromNames, name)
|
||||
}
|
||||
if len(fromNames) == 0 {
|
||||
return nil, ErrInvalidAmount
|
||||
}
|
||||
|
||||
var result MapHistoricalShopsResultDTO
|
||||
result.TargetShopName = targetName
|
||||
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var targetShop model.PickupShop
|
||||
findErr := tx.Where("name = ?", targetName).First(&targetShop).Error
|
||||
if errors.Is(findErr, gorm.ErrRecordNotFound) {
|
||||
if !req.CreateTargetMissing {
|
||||
return ErrPickupShopNotFound
|
||||
}
|
||||
now := time.Now()
|
||||
targetShop = model.PickupShop{
|
||||
Name: targetName,
|
||||
Platform: strings.TrimSpace(req.TargetPlatform),
|
||||
Status: PickupShopStatusActive,
|
||||
SortOrder: 0,
|
||||
Remark: "历史映射自动创建",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&targetShop).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result.CreatedStandardShop = true
|
||||
} else if findErr != nil {
|
||||
return findErr
|
||||
}
|
||||
result.TargetShopID = targetShop.ID
|
||||
|
||||
// 将未初始化 raw_shop_name 的记录填入旧 shop_name
|
||||
if err := tx.Exec(`UPDATE admin_pickups
|
||||
SET raw_shop_name = shop_name
|
||||
WHERE (raw_shop_name = '' OR raw_shop_name IS NULL)
|
||||
AND (shop_name IN ? OR (shop_name = '' AND '' IN ?))`, fromNames, fromNames).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 批量更新 admin_pickups 的 shop_name
|
||||
res := tx.Model(&model.AdminPickup{}).
|
||||
Where("raw_shop_name IN ?", fromNames).
|
||||
Update("shop_name", targetName)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
result.UpdatedPickupsCount = res.RowsAffected
|
||||
|
||||
now := time.Now()
|
||||
for _, fromName := range fromNames {
|
||||
if fromName == targetName {
|
||||
continue
|
||||
}
|
||||
var mappedCount int64
|
||||
tx.Model(&model.AdminPickup{}).Where("raw_shop_name = ?", fromName).Count(&mappedCount)
|
||||
|
||||
var existing model.PickupShopMapping
|
||||
if err := tx.Where("source_name = ?", fromName).First(&existing).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
newMapping := model.PickupShopMapping{
|
||||
SourceName: fromName,
|
||||
TargetShopID: targetShop.ID,
|
||||
TargetShopName: targetName,
|
||||
MappedCount: mappedCount,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&newMapping).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err == nil {
|
||||
existing.TargetShopID = targetShop.ID
|
||||
existing.TargetShopName = targetName
|
||||
existing.MappedCount = mappedCount
|
||||
if req.Remark != "" {
|
||||
existing.Remark = strings.TrimSpace(req.Remark)
|
||||
}
|
||||
existing.UpdatedAt = now
|
||||
if err := tx.Save(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
bid := targetShop.ID
|
||||
_ = auditlog.Append(tx, auditlog.Entry{
|
||||
ActorType: "admin",
|
||||
ActorID: adminID,
|
||||
Action: "pickup_shop_map_historical",
|
||||
BizType: "pickup_shop",
|
||||
BizID: &bid,
|
||||
Meta: meta,
|
||||
Detail: map[string]any{
|
||||
"target_shop_id": targetShop.ID,
|
||||
"target_shop_name": targetName,
|
||||
"from_shop_names": fromNames,
|
||||
"updated_count": result.UpdatedPickupsCount,
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeleteShopMapping 删除指定历史店铺的映射规则,并还原历史提号单中的原始店铺名
|
||||
func (r *Repository) DeleteShopMapping(ctx context.Context, sourceName string, adminID uint64, meta auditlog.Meta) error {
|
||||
if r == nil || r.db == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var mapping model.PickupShopMapping
|
||||
if err := tx.Where("source_name = ?", sourceName).First(&mapping).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.AdminPickup{}).
|
||||
Where("raw_shop_name = ?", sourceName).
|
||||
Update("shop_name", sourceName).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&mapping).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
bid := mapping.ID
|
||||
_ = auditlog.Append(tx, auditlog.Entry{
|
||||
ActorType: "admin",
|
||||
ActorID: adminID,
|
||||
Action: "pickup_shop_unmap_historical",
|
||||
BizType: "pickup_shop",
|
||||
BizID: &bid,
|
||||
Meta: meta,
|
||||
Detail: map[string]any{
|
||||
"source_name": sourceName,
|
||||
"target_shop_name": mapping.TargetShopName,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) ListForSeller(ctx context.Context, ownerID uint64, query SellerPickupQuery) (*PaginatedResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
@@ -1228,6 +1506,7 @@ func (row pickupRow) toDTO() PickupDTO {
|
||||
Platform: row.Platform,
|
||||
ThirdPartyOrderNo: row.ThirdPartyOrderNo,
|
||||
ShopName: row.ShopName,
|
||||
RawShopName: row.RawShopName,
|
||||
AccountSource: row.AccountSource,
|
||||
SourceChannel: row.SourceChannel,
|
||||
SettlementMode: row.SettlementMode,
|
||||
|
||||
@@ -918,3 +918,104 @@ func TestRepositoryPickupShopCRUDAndOptions(t *testing.T) {
|
||||
t.Fatalf("active shops after delete length = %d, want 0", len(shopsAfterDelete))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryHistoricalShopStatsAndMapping(t *testing.T) {
|
||||
repo, db := newPickupTestRepo(t)
|
||||
|
||||
// 1. 创建标准店铺
|
||||
stdShop1, err := repo.CreateShop(t.Context(), CreatePickupShopRequest{
|
||||
Name: "王大锤游戏",
|
||||
Platform: "淘宝",
|
||||
Status: "active",
|
||||
}, 1, auditlog.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateShop() error = %v", err)
|
||||
}
|
||||
|
||||
// 2. 插入测试提号记录
|
||||
listing1 := seedAvailableListing(t, db, "LST-MAP-01", "映射测试商品1", false)
|
||||
_, err = repo.Create(t.Context(), CreateRequest{
|
||||
ListingID: listing1.ID,
|
||||
ShopName: "王大锤游戏",
|
||||
}, 1, auditlog.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
listing2 := seedAvailableListing(t, db, "LST-MAP-02", "映射测试商品2", false)
|
||||
_, err = repo.Create(t.Context(), CreateRequest{
|
||||
ListingID: listing2.ID,
|
||||
ShopName: "王大锤游戏专营",
|
||||
}, 1, auditlog.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
listing3 := seedAvailableListing(t, db, "LST-MAP-03", "映射测试商品3", false)
|
||||
_, err = repo.Create(t.Context(), CreateRequest{
|
||||
ListingID: listing3.ID,
|
||||
ShopName: "淘宝王大锤商行",
|
||||
}, 1, auditlog.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
// 3. 获取历史聚合统计
|
||||
stats, err := repo.GetHistoricalShopStats(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistoricalShopStats() error = %v", err)
|
||||
}
|
||||
if stats.TotalShops < 3 {
|
||||
t.Fatalf("stats.TotalShops = %d, want >= 3", stats.TotalShops)
|
||||
}
|
||||
if stats.StandardCount < 1 {
|
||||
t.Fatalf("stats.StandardCount = %d, want >= 1", stats.StandardCount)
|
||||
}
|
||||
|
||||
// 4. 批量映射
|
||||
mapRes, err := repo.MapHistoricalShops(t.Context(), MapHistoricalShopsRequest{
|
||||
FromShopNames: []string{"王大锤游戏专营", "淘宝王大锤商行"},
|
||||
TargetShopName: "王大锤游戏",
|
||||
Remark: "测试批量映射",
|
||||
}, 1, auditlog.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("MapHistoricalShops() error = %v", err)
|
||||
}
|
||||
if mapRes.UpdatedPickupsCount != 2 {
|
||||
t.Fatalf("UpdatedPickupsCount = %d, want 2", mapRes.UpdatedPickupsCount)
|
||||
}
|
||||
if mapRes.TargetShopID != stdShop1.ID {
|
||||
t.Fatalf("TargetShopID = %d, want %d", mapRes.TargetShopID, stdShop1.ID)
|
||||
}
|
||||
|
||||
// 5. 再次统计
|
||||
statsAfter, err := repo.GetHistoricalShopStats(t.Context())
|
||||
if err != nil {
|
||||
t.Fatalf("GetHistoricalShopStats() error = %v", err)
|
||||
}
|
||||
if statsAfter.MappedCount < 2 {
|
||||
t.Fatalf("statsAfter.MappedCount = %d, want >= 2", statsAfter.MappedCount)
|
||||
}
|
||||
|
||||
// 6. 验证新建提号自动归一化
|
||||
listing4 := seedAvailableListing(t, db, "LST-MAP-04", "映射测试商品4", false)
|
||||
createdWithAlias, err := repo.Create(t.Context(), CreateRequest{
|
||||
ListingID: listing4.ID,
|
||||
ShopName: "王大锤游戏专营",
|
||||
}, 1, auditlog.Meta{})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() with alias error = %v", err)
|
||||
}
|
||||
if createdWithAlias.ShopName != "王大锤游戏" {
|
||||
t.Fatalf("createdWithAlias.ShopName = %q, want 王大锤游戏", createdWithAlias.ShopName)
|
||||
}
|
||||
if createdWithAlias.RawShopName != "王大锤游戏专营" {
|
||||
t.Fatalf("createdWithAlias.RawShopName = %q, want 王大锤游戏专营", createdWithAlias.RawShopName)
|
||||
}
|
||||
|
||||
// 7. 取消映射
|
||||
if err := repo.DeleteShopMapping(t.Context(), "王大锤游戏专营", 1, auditlog.Meta{}); err != nil {
|
||||
t.Fatalf("DeleteShopMapping() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,3 +176,25 @@ func (s *Service) DeleteShop(ctx context.Context, id uint64, adminID uint64, met
|
||||
}
|
||||
return s.repo.DeleteShop(ctx, id, adminID, meta)
|
||||
}
|
||||
|
||||
func (s *Service) GetHistoricalShopStats(ctx context.Context) (*PickupShopHistoricalStatsDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.GetHistoricalShopStats(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) MapHistoricalShops(ctx context.Context, req MapHistoricalShopsRequest, adminID uint64, meta auditlog.Meta) (*MapHistoricalShopsResultDTO, error) {
|
||||
if s.repo == nil {
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.MapHistoricalShops(ctx, req, adminID, meta)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteShopMapping(ctx context.Context, sourceName string, adminID uint64, meta auditlog.Meta) error {
|
||||
if s.repo == nil {
|
||||
return ErrDependencyUnavailable
|
||||
}
|
||||
return s.repo.DeleteShopMapping(ctx, sourceName, adminID, meta)
|
||||
}
|
||||
|
||||
|
||||
@@ -763,6 +763,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
||||
adminRoutes.POST("/pickups/shops", requirePerm("order:pickup"), pickupHandler.CreateShop)
|
||||
adminRoutes.PUT("/pickups/shops/:id", requirePerm("order:pickup"), pickupHandler.UpdateShop)
|
||||
adminRoutes.DELETE("/pickups/shops/:id", requirePerm("order:pickup"), pickupHandler.DeleteShop)
|
||||
adminRoutes.GET("/pickups/shops/historical-stats", requirePerm("order:pickup"), pickupHandler.GetHistoricalShopStats)
|
||||
adminRoutes.POST("/pickups/shops/map-historical", requirePerm("order:pickup"), pickupHandler.MapHistoricalShops)
|
||||
adminRoutes.DELETE("/pickups/shops/mappings", requirePerm("order:pickup"), pickupHandler.DeleteShopMapping)
|
||||
adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings)
|
||||
adminRoutes.GET("/pickups/:id/financial-adjustments", requirePerm("order:pickup"), pickupHandler.ListFinancialAdjustments)
|
||||
adminRoutes.GET("/pickups/:id/process-events", requirePerm("order:pickup"), pickupHandler.ProcessEvents)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- +goose Up
|
||||
|
||||
ALTER TABLE admin_pickups
|
||||
ADD COLUMN raw_shop_name VARCHAR(100) NOT NULL DEFAULT '' COMMENT '原始录入店铺名称' AFTER shop_name;
|
||||
|
||||
UPDATE admin_pickups SET raw_shop_name = shop_name WHERE raw_shop_name = '';
|
||||
|
||||
CREATE TABLE pickup_shop_mappings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
source_name VARCHAR(100) NOT NULL COMMENT '原始/历史店铺名称',
|
||||
target_shop_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '映射的目标标准店铺ID',
|
||||
target_shop_name VARCHAR(100) NOT NULL COMMENT '映射的目标标准店铺名称',
|
||||
mapped_count BIGINT NOT NULL DEFAULT 0 COMMENT '最近一次清洗影响的历史单量',
|
||||
remark VARCHAR(255) NOT NULL DEFAULT '' COMMENT '映射说明',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_pickup_shop_mapping_source (source_name),
|
||||
KEY idx_pickup_shop_mapping_target (target_shop_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='线下提号店铺历史映射表';
|
||||
|
||||
-- +goose Down
|
||||
|
||||
DROP TABLE IF EXISTS pickup_shop_mappings;
|
||||
|
||||
ALTER TABLE admin_pickups
|
||||
DROP COLUMN raw_shop_name;
|
||||
@@ -151,3 +151,18 @@
|
||||
- **提号列表与详情页字段展示**:
|
||||
- 列表中增加操作人、第三方单号、下单金额、押金金额的展示;
|
||||
- 详情页概览卡片同步展示新增字段。
|
||||
|
||||
### 4. 历史店铺可视化手动映射与一键清洗方案 (`000080_pickup_shop_mappings.sql`)
|
||||
- **数据表与持久化**:
|
||||
- `admin_pickups` 增加 `raw_shop_name VARCHAR(100)` 字段,永久留存提号单录入时的原始店铺名,确保清洗操作可追溯且可还原;
|
||||
- 新建 `pickup_shop_mappings` 历史映射别名表,记录 `source_name`, `target_shop_id`, `target_shop_name`, `mapped_count`, `remark`。
|
||||
- **后端服务能力**:
|
||||
- `GET /api/v1/admin/pickup-shops/historical-stats`:聚合统计线上历史店铺数量及每个店铺的提号单量,并标记 `standard`(标准店)、`mapped`(已映射)、`unmapped`(待映射);
|
||||
- `POST /api/v1/admin/pickup-shops/map-historical`:支持单条或批量将历史变体名称映射至目标标准店铺,并在原子事务内将 `admin_pickups` 的 `shop_name` 批量清洗更新,同时写入映射规则与审计日志;
|
||||
- `DELETE /api/v1/admin/pickup-shops/mappings`:支持撤销映射规则并将对应历史提号单的店铺名安全还原为原始名称;
|
||||
- 自动归一化:新建提号单时,若录入已映射的别名,自动归一化为目标标准店铺名,同时将原名称存入 `raw_shop_name`,防止未来脏数据再次混入。
|
||||
- **前端可视化管理**:
|
||||
- `PickupShopDialog.vue` 升级为双 Tab 结构:
|
||||
- **Tab 1(标准店铺库)**:标准店铺增删改查、启用/禁用、权重排序;
|
||||
- **Tab 2(历史店铺映射与清洗)**:顶部展示统计卡片(历史总数/已入库/已映射/待映射/涉及单量),列表直观展示 61 个历史名称与对应单量、当前状态;支持批量勾选映射、一键【设为标准】、单个【映射】、【重映射】与【撤销】。
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AdminPickup {
|
||||
platform: string
|
||||
third_party_order_no: string
|
||||
shop_name: string
|
||||
raw_shop_name?: string
|
||||
account_source: PickupAccountSource
|
||||
source_channel: string
|
||||
settlement_mode: PickupSettlementMode
|
||||
@@ -194,6 +195,41 @@ export interface PickupShopQuery {
|
||||
status?: string
|
||||
}
|
||||
|
||||
export type PickupShopMappingStatus = 'standard' | 'mapped' | 'unmapped'
|
||||
|
||||
export interface PickupShopHistoricalItem {
|
||||
source_name: string
|
||||
count: number
|
||||
mapping_status: PickupShopMappingStatus
|
||||
target_shop_id?: number
|
||||
target_shop_name?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface PickupShopHistoricalStats {
|
||||
items: PickupShopHistoricalItem[]
|
||||
total_pickups: number
|
||||
total_shops: number
|
||||
standard_count: number
|
||||
mapped_count: number
|
||||
unmapped_count: number
|
||||
}
|
||||
|
||||
export interface MapHistoricalShopsRequest {
|
||||
from_shop_names: string[]
|
||||
target_shop_name: string
|
||||
create_target_missing?: boolean
|
||||
target_platform?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface MapHistoricalShopsResult {
|
||||
updated_pickups_count: number
|
||||
target_shop_name: string
|
||||
target_shop_id: number
|
||||
created_standard_shop: boolean
|
||||
}
|
||||
|
||||
function cleanParams(query: object) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
|
||||
@@ -339,3 +375,26 @@ export async function deletePickupShop(id: number) {
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchHistoricalShopStats() {
|
||||
const { data } = await apiClient.get<ApiResponse<PickupShopHistoricalStats>>(
|
||||
'/admin/pickups/shops/historical-stats'
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function mapHistoricalShops(req: MapHistoricalShopsRequest) {
|
||||
const { data } = await apiClient.post<ApiResponse<MapHistoricalShopsResult>>(
|
||||
'/admin/pickups/shops/map-historical',
|
||||
req
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function deleteShopMapping(sourceName: string) {
|
||||
const { data } = await apiClient.delete<ApiResponse<{ deleted: boolean }>>(
|
||||
'/admin/pickups/shops/mappings',
|
||||
{ params: { source_name: sourceName } }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, Edit, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { Check, Connection, Delete, Edit, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
createPickupShop,
|
||||
deletePickupShop,
|
||||
deleteShopMapping,
|
||||
fetchHistoricalShopStats,
|
||||
fetchPickupShops,
|
||||
mapHistoricalShops,
|
||||
updatePickupShop,
|
||||
type PickupShop,
|
||||
type PickupShopCreateRequest,
|
||||
type PickupShopHistoricalItem,
|
||||
type PickupShopHistoricalStats,
|
||||
type PickupShopUpdateRequest,
|
||||
} from '@/features/admin/api/adminPickup'
|
||||
import { formatDateTime } from '@/shared/utils/time'
|
||||
@@ -22,6 +27,9 @@ const emit = defineEmits<{
|
||||
(e: 'updated'): void
|
||||
}>()
|
||||
|
||||
const activeTab = ref<'standard' | 'history'>('standard')
|
||||
|
||||
// ==================== 标准店铺状态 ====================
|
||||
const loading = ref(false)
|
||||
const shopList = ref<PickupShop[]>([])
|
||||
const filters = reactive({
|
||||
@@ -44,10 +52,31 @@ const shopForm = reactive({
|
||||
|
||||
const platformOptions = ['淘宝', '拼多多', '闲鱼', '微信', 'QQ', '抖音', '快手']
|
||||
|
||||
// ==================== 历史店铺映射状态 ====================
|
||||
const historyLoading = ref(false)
|
||||
const historyStats = ref<PickupShopHistoricalStats | null>(null)
|
||||
const historyFilters = reactive({
|
||||
keyword: '',
|
||||
status: '' as '' | 'standard' | 'mapped' | 'unmapped',
|
||||
})
|
||||
const selectedHistory = ref<PickupShopHistoricalItem[]>([])
|
||||
|
||||
// 映射弹窗状态
|
||||
const mapDialogVisible = ref(false)
|
||||
const mappingSubmitting = ref(false)
|
||||
const mappingTargets = ref<string[]>([])
|
||||
const mappingForm = reactive({
|
||||
targetShopName: '',
|
||||
createTargetMissing: true,
|
||||
targetPlatform: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
// -------------------- 标准店铺方法 --------------------
|
||||
async function loadShops() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -124,6 +153,9 @@ async function handleSaveShop() {
|
||||
}
|
||||
formDialogVisible.value = false
|
||||
await loadShops()
|
||||
if (historyStats.value) {
|
||||
loadHistoryStats()
|
||||
}
|
||||
emit('updated')
|
||||
} catch (err: unknown) {
|
||||
ElMessage.error(getErrorMessage(err) || '保存店铺失败')
|
||||
@@ -167,6 +199,9 @@ async function handleDeleteShop(row: PickupShop) {
|
||||
await deletePickupShop(row.id)
|
||||
ElMessage.success('店铺已删除')
|
||||
await loadShops()
|
||||
if (historyStats.value) {
|
||||
loadHistoryStats()
|
||||
}
|
||||
emit('updated')
|
||||
} catch (err: unknown) {
|
||||
ElMessage.error(getErrorMessage(err) || '删除店铺失败')
|
||||
@@ -178,6 +213,150 @@ async function handleDeleteShop(row: PickupShop) {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- 历史店铺映射方法 --------------------
|
||||
async function loadHistoryStats() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
historyStats.value = await fetchHistoricalShopStats()
|
||||
} catch (err: unknown) {
|
||||
ElMessage.error(getErrorMessage(err) || '加载历史店铺统计失败')
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredHistoryList = computed(() => {
|
||||
if (!historyStats.value?.items) return []
|
||||
const kw = historyFilters.keyword.trim().toLowerCase()
|
||||
const status = historyFilters.status
|
||||
return historyStats.value.items.filter(item => {
|
||||
if (status && item.mapping_status !== status) {
|
||||
return false
|
||||
}
|
||||
if (kw) {
|
||||
const sourceMatch = (item.source_name || '(未填/空)').toLowerCase().includes(kw)
|
||||
const targetMatch = (item.target_shop_name || '').toLowerCase().includes(kw)
|
||||
const remarkMatch = (item.remark || '').toLowerCase().includes(kw)
|
||||
if (!sourceMatch && !targetMatch && !remarkMatch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function handleHistorySelectionChange(selection: PickupShopHistoricalItem[]) {
|
||||
selectedHistory.value = selection
|
||||
}
|
||||
|
||||
function openSingleMapDialog(row: PickupShopHistoricalItem) {
|
||||
mappingTargets.value = [row.source_name]
|
||||
mappingForm.targetShopName = row.target_shop_name || ''
|
||||
mappingForm.createTargetMissing = true
|
||||
mappingForm.targetPlatform = ''
|
||||
mappingForm.remark = ''
|
||||
mapDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openBatchMapDialog() {
|
||||
if (selectedHistory.value.length === 0) {
|
||||
ElMessage.warning('请先勾选需要映射的历史店铺')
|
||||
return
|
||||
}
|
||||
mappingTargets.value = selectedHistory.value.map(item => item.source_name)
|
||||
mappingForm.targetShopName = ''
|
||||
mappingForm.createTargetMissing = true
|
||||
mappingForm.targetPlatform = ''
|
||||
mappingForm.remark = ''
|
||||
mapDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleConfirmMap() {
|
||||
const target = mappingForm.targetShopName.trim()
|
||||
if (!target) {
|
||||
ElMessage.warning('请输入或选择目标标准店铺名称')
|
||||
return
|
||||
}
|
||||
mappingSubmitting.value = true
|
||||
try {
|
||||
const res = await mapHistoricalShops({
|
||||
from_shop_names: mappingTargets.value,
|
||||
target_shop_name: target,
|
||||
create_target_missing: mappingForm.createTargetMissing,
|
||||
target_platform: mappingForm.targetPlatform.trim(),
|
||||
remark: mappingForm.remark.trim(),
|
||||
})
|
||||
ElMessage.success(
|
||||
`映射成功!已同步清洗 ${res.updated_pickups_count} 单历史提号为「${res.target_shop_name}」`
|
||||
)
|
||||
mapDialogVisible.value = false
|
||||
await Promise.all([loadHistoryStats(), loadShops()])
|
||||
emit('updated')
|
||||
} catch (err: unknown) {
|
||||
ElMessage.error(getErrorMessage(err) || '映射清洗失败')
|
||||
} finally {
|
||||
mappingSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConvertToStandard(row: PickupShopHistoricalItem) {
|
||||
const shopName = row.source_name.trim()
|
||||
if (!shopName) {
|
||||
ElMessage.warning('历史名称为空,无法直接收录为标准店铺,请使用映射功能')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定将历史店铺「${shopName}」直接收录为标准店铺吗?收录后该名称将作为标准店铺供后续选择。`,
|
||||
'收录为标准店铺',
|
||||
{
|
||||
confirmButtonText: '确定收录',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info',
|
||||
}
|
||||
)
|
||||
await createPickupShop({
|
||||
name: shopName,
|
||||
platform: '',
|
||||
sort_order: 0,
|
||||
status: 'active',
|
||||
remark: '历史店铺一键收录',
|
||||
})
|
||||
ElMessage.success(`店铺「${shopName}」已成功收录至标准店铺库`)
|
||||
await Promise.all([loadHistoryStats(), loadShops()])
|
||||
emit('updated')
|
||||
} catch {
|
||||
/* 用户放弃 */
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetMapping(row: PickupShopHistoricalItem) {
|
||||
const nameLabel = row.source_name || '(未填/空)'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要撤销「${nameLabel}」的映射规则吗?历史提号单中的店铺名将被还原为原始名称。`,
|
||||
'撤销映射确认',
|
||||
{
|
||||
confirmButtonText: '确定撤销',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
await deleteShopMapping(row.source_name)
|
||||
ElMessage.success(`已撤销映射并还原历史提号单`)
|
||||
await Promise.all([loadHistoryStats(), loadShops()])
|
||||
emit('updated')
|
||||
} catch {
|
||||
/* 用户放弃 */
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(tabName: string | number) {
|
||||
if (tabName === 'history' && !historyStats.value) {
|
||||
loadHistoryStats()
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(e: unknown): string {
|
||||
if (e && typeof e === 'object') {
|
||||
const response = (e as { response?: { data?: { message?: string } } }).response
|
||||
@@ -196,113 +375,278 @@ onMounted(() => {
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="props.modelValue"
|
||||
title="店铺管理"
|
||||
width="min(920px, calc(100vw - 32px))"
|
||||
title="店铺管理与历史映射"
|
||||
width="min(980px, calc(100vw - 32px))"
|
||||
:before-close="handleClose"
|
||||
append-to-body
|
||||
>
|
||||
<div class="shop-mgr-toolbar">
|
||||
<el-form inline @submit.prevent>
|
||||
<el-form-item label="店铺名称">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索名称或备注"
|
||||
clearable
|
||||
style="width: 170px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="平台">
|
||||
<el-select
|
||||
v-model="filters.platform"
|
||||
placeholder="全部平台"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option v-for="item in platformOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 110px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="启用" value="active" />
|
||||
<el-option label="禁用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button :icon="Refresh" @click="loadShops">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar-right">
|
||||
<el-button type="success" :icon="Plus" @click="openCreateForm">新增店铺</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" class="shop-tabs" @tab-change="handleTabChange">
|
||||
<!-- ==================== Tab 1: 标准店铺库 ==================== -->
|
||||
<el-tab-pane label="标准店铺库" name="standard">
|
||||
<div class="shop-mgr-toolbar">
|
||||
<el-form inline @submit.prevent>
|
||||
<el-form-item label="店铺名称">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索名称或备注"
|
||||
clearable
|
||||
style="width: 170px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="平台">
|
||||
<el-select
|
||||
v-model="filters.platform"
|
||||
placeholder="全部平台"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in platformOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 110px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="启用" value="active" />
|
||||
<el-option label="禁用" value="disabled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button :icon="Refresh" @click="loadShops">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar-right">
|
||||
<el-button type="success" :icon="Plus" @click="openCreateForm">新增店铺</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="shopList" border stripe max-height="460">
|
||||
<el-table-column prop="name" label="店铺名称" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<strong style="color: var(--el-text-color-primary)">{{ row.name }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="platform" label="平台/渠道" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.platform" size="small" effect="plain">{{ row.platform }}</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序权重" width="100" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'active' ? '启用中' : '已禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="shop-actions">
|
||||
<el-button size="small" :icon="Edit" @click="openEditForm(row)">编辑</el-button>
|
||||
<el-table v-loading="loading" :data="shopList" border stripe max-height="450">
|
||||
<el-table-column prop="name" label="店铺名称" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<strong style="color: var(--el-text-color-primary)">{{ row.name }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="platform" label="平台/渠道" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.platform" size="small" effect="plain">{{ row.platform }}</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序权重" width="100" align="center" />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 'active' ? '启用中' : '已禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="160">
|
||||
<template #default="{ row }">{{ formatDateTime(row.updated_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="shop-actions">
|
||||
<el-button size="small" :icon="Edit" @click="openEditForm(row)">编辑</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.status === 'active' ? 'warning' : 'success'"
|
||||
plain
|
||||
@click="handleToggleStatus(row)"
|
||||
>
|
||||
{{ row.status === 'active' ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:icon="Delete"
|
||||
@click="handleDeleteShop(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ==================== Tab 2: 历史店铺映射与清洗 ==================== -->
|
||||
<el-tab-pane label="历史店铺映射与清洗" name="history">
|
||||
<!-- 统计面板 -->
|
||||
<div v-if="historyStats" class="history-stats-bar">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">历史店铺总数</span>
|
||||
<span class="stat-value">{{ historyStats.total_shops }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">已入标准库</span>
|
||||
<span class="stat-value text-success">{{ historyStats.standard_count }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">已映射清洗</span>
|
||||
<span class="stat-value text-primary">{{ historyStats.mapped_count }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">待映射</span>
|
||||
<span class="stat-value text-warning">{{ historyStats.unmapped_count }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">涉及历史单量</span>
|
||||
<span class="stat-value">{{ historyStats.total_pickups }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="shop-mgr-toolbar">
|
||||
<el-form inline @submit.prevent>
|
||||
<el-form-item label="店铺名称">
|
||||
<el-input
|
||||
v-model="historyFilters.keyword"
|
||||
placeholder="搜索历史或目标店铺"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="映射状态">
|
||||
<el-select
|
||||
v-model="historyFilters.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
>
|
||||
<el-option label="待映射" value="unmapped" />
|
||||
<el-option label="已映射" value="mapped" />
|
||||
<el-option label="标准店铺" value="standard" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button :icon="Refresh" @click="loadHistoryStats">刷新统计</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="toolbar-right">
|
||||
<el-button
|
||||
size="small"
|
||||
:type="row.status === 'active' ? 'warning' : 'success'"
|
||||
plain
|
||||
@click="handleToggleStatus(row)"
|
||||
type="primary"
|
||||
:icon="Connection"
|
||||
:disabled="selectedHistory.length === 0"
|
||||
@click="openBatchMapDialog"
|
||||
>
|
||||
{{ row.status === 'active' ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:icon="Delete"
|
||||
@click="handleDeleteShop(row)"
|
||||
>
|
||||
删除
|
||||
批量映射 ({{ selectedHistory.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑店铺弹窗 -->
|
||||
<el-table
|
||||
v-loading="historyLoading"
|
||||
:data="filteredHistoryList"
|
||||
border
|
||||
stripe
|
||||
max-height="430"
|
||||
@selection-change="handleHistorySelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="45" align="center" />
|
||||
<el-table-column prop="source_name" label="历史店铺名称" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.source_name">
|
||||
<strong>{{ row.source_name }}</strong>
|
||||
</span>
|
||||
<el-tag v-else type="info" size="small">(未填店铺/空)</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="count" label="历史提号单量" width="120" sortable align="center">
|
||||
<template #default="{ row }">
|
||||
<el-badge
|
||||
:value="row.count"
|
||||
:type="row.count > 100 ? 'danger' : row.count > 20 ? 'warning' : 'info'"
|
||||
class="count-badge"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="映射状态" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.mapping_status === 'standard'" type="success" size="small">
|
||||
标准店铺
|
||||
</el-tag>
|
||||
<el-tag v-else-if="row.mapping_status === 'mapped'" type="primary" size="small">
|
||||
已映射
|
||||
</el-tag>
|
||||
<el-tag v-else type="warning" size="small">待映射</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="映射目标店铺" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.mapping_status === 'mapped'" class="text-primary">
|
||||
➜ <strong>{{ row.target_shop_name }}</strong>
|
||||
</span>
|
||||
<span v-else-if="row.mapping_status === 'standard'" class="text-success">
|
||||
自身为标准店铺
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="映射说明" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="shop-actions">
|
||||
<template v-if="row.mapping_status === 'unmapped'">
|
||||
<el-button
|
||||
v-if="row.source_name"
|
||||
size="small"
|
||||
type="success"
|
||||
plain
|
||||
:icon="Check"
|
||||
@click="handleConvertToStandard(row)"
|
||||
>
|
||||
设为标准
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Connection"
|
||||
@click="openSingleMapDialog(row)"
|
||||
>
|
||||
映射
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else-if="row.mapping_status === 'mapped'">
|
||||
<el-button size="small" :icon="Edit" @click="openSingleMapDialog(row)">
|
||||
重映射
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleResetMapping(row)">
|
||||
撤销
|
||||
</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-muted" style="font-size: 12px">已收录</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 新建/编辑标准店铺弹窗 -->
|
||||
<el-dialog
|
||||
v-model="formDialogVisible"
|
||||
:title="editingId ? '编辑店铺' : '新增店铺'"
|
||||
:title="editingId ? '编辑标准店铺' : '新增标准店铺'"
|
||||
width="460px"
|
||||
append-to-body
|
||||
>
|
||||
@@ -310,7 +654,7 @@ onMounted(() => {
|
||||
<el-form-item label="店铺名称" required>
|
||||
<el-input
|
||||
v-model="shopForm.name"
|
||||
placeholder="例如:糖浆、淘宝王大锤专营店"
|
||||
placeholder="例如:糖浆、王大锤游戏、王大锤数娱"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
@@ -361,6 +705,81 @@ onMounted(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 映射配置弹窗 -->
|
||||
<el-dialog v-model="mapDialogVisible" title="映射至标准店铺" width="520px" append-to-body>
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="待映射历史店">
|
||||
<div class="mapping-targets-tags">
|
||||
<el-tag
|
||||
v-for="target in mappingTargets"
|
||||
:key="target"
|
||||
size="small"
|
||||
type="info"
|
||||
effect="plain"
|
||||
>
|
||||
{{ target || '(未填/空)' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="form-hint">共勾选 {{ mappingTargets.length }} 个待映射历史店铺</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标标准店铺" required>
|
||||
<el-select
|
||||
v-model="mappingForm.targetShopName"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="选择已有标准店铺或直接输入新店名"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in shopList.filter(s => s.status === 'active')"
|
||||
:key="item.name"
|
||||
:label="item.name + (item.platform ? ` (${item.platform})` : '')"
|
||||
:value="item.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="自动创建标准">
|
||||
<el-switch
|
||||
v-model="mappingForm.createTargetMissing"
|
||||
active-text="若目标店铺尚不存在,自动收录至标准店铺库"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="mappingForm.createTargetMissing" label="新店所属平台">
|
||||
<el-select
|
||||
v-model="mappingForm.targetPlatform"
|
||||
filterable
|
||||
allow-create
|
||||
clearable
|
||||
placeholder="新收录标准店铺的平台渠道(可选)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="item in platformOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="映射备注">
|
||||
<el-input
|
||||
v-model="mappingForm.remark"
|
||||
placeholder="例如:历史客服拼写变体合并"
|
||||
maxlength="255"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="执行映射后,对应历史提号单的店铺名将被批量清洗更新为目标标准店铺!"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="mapDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="mappingSubmitting" @click="handleConfirmMap">
|
||||
确认映射并清洗数据
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
@@ -368,6 +787,40 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shop-tabs {
|
||||
margin-top: -12px;
|
||||
}
|
||||
|
||||
.history-stats-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
background: var(--el-fill-color-light);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.shop-mgr-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -386,16 +839,43 @@ onMounted(() => {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mapping-targets-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.4;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.count-badge :deep(.el-badge__content) {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user