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:
yml2213
2026-10-09 13:31:11 +08:00
parent 9d9adfbd5e
commit f3c5235b81
12 changed files with 1187 additions and 100 deletions
+280 -1
View File
@@ -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,