diff --git a/backend/internal/model/pickup.go b/backend/internal/model/pickup.go index ed5c6d6..21a1541 100644 --- a/backend/internal/model/pickup.go +++ b/backend/internal/model/pickup.go @@ -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"` diff --git a/backend/internal/model/pickup_shop.go b/backend/internal/model/pickup_shop.go index f34cee2..9c15655 100644 --- a/backend/internal/model/pickup_shop.go +++ b/backend/internal/model/pickup_shop.go @@ -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" +} + diff --git a/backend/internal/modules/pickup/dto.go b/backend/internal/modules/pickup/dto.go index d03fc26..ad0672b 100644 --- a/backend/internal/modules/pickup/dto.go +++ b/backend/internal/modules/pickup/dto.go @@ -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 diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index 7af1bd7..e740921 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -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(), diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index 0becdc0..f61144b 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -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, diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index 980e886..ba47caf 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -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) + } +} + diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index 985e9a7..9dc14f3 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -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) +} + diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 57def36..b775d54 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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) diff --git a/backend/migrations/000080_pickup_shop_mappings.sql b/backend/migrations/000080_pickup_shop_mappings.sql new file mode 100644 index 0000000..dad718e --- /dev/null +++ b/backend/migrations/000080_pickup_shop_mappings.sql @@ -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; diff --git a/docs/线下提号字段扩展与店铺管理实施方案.md b/docs/线下提号字段扩展与店铺管理实施方案.md index 83e59ac..fc3466d 100644 --- a/docs/线下提号字段扩展与店铺管理实施方案.md +++ b/docs/线下提号字段扩展与店铺管理实施方案.md @@ -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 个历史名称与对应单量、当前状态;支持批量勾选映射、一键【设为标准】、单个【映射】、【重映射】与【撤销】。 + diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index 9ffa13c..3a350d5 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -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>( + '/admin/pickups/shops/historical-stats' + ) + return data.data +} + +export async function mapHistoricalShops(req: MapHistoricalShopsRequest) { + const { data } = await apiClient.post>( + '/admin/pickups/shops/map-historical', + req + ) + return data.data +} + +export async function deleteShopMapping(sourceName: string) { + const { data } = await apiClient.delete>( + '/admin/pickups/shops/mappings', + { params: { source_name: sourceName } } + ) + return data.data +} diff --git a/frontend/src/features/admin/components/PickupShopDialog.vue b/frontend/src/features/admin/components/PickupShopDialog.vue index 6e1f572..1334dca 100644 --- a/frontend/src/features/admin/components/PickupShopDialog.vue +++ b/frontend/src/features/admin/components/PickupShopDialog.vue @@ -1,14 +1,19 @@