diff --git a/backend/internal/model/cs_manual_entry.go b/backend/internal/model/cs_manual_entry.go index 3cf143d9..d2aed685 100644 --- a/backend/internal/model/cs_manual_entry.go +++ b/backend/internal/model/cs_manual_entry.go @@ -2,13 +2,21 @@ package model import "time" +// 录单组别取值。 +const ( + CSManualEntryGroupCollect = "collect" // 收号 + CSManualEntryGroupSell = "sell" // 出号 +) + // CSManualEntry 客服手动录单记录。 // 客服在后台手动登记一个编号(如 202610120013),该编号与其登录账号绑定, -// 记录平台、操作人、登记时间等,供后续查询与按客服统计今日录单量。 +// 记录组别(收号/出号)、平台、操作人、登记时间等,供后续查询与按客服统计今日录单量。 +// 同一编号允许收号、出号两组各登记一次,唯一键为 (entry_no, entry_group)。 // platform / operator 存名称快照:后台删掉选项也不影响历史记录展示。 type CSManualEntry struct { ID uint64 `gorm:"primaryKey" json:"id"` - EntryNo string `gorm:"size:64;not null;uniqueIndex" json:"entry_no"` + EntryNo string `gorm:"size:64;not null;uniqueIndex:uk_cs_manual_entries_entry_no_group,priority:1" json:"entry_no"` + EntryGroup string `gorm:"size:16;not null;default:'collect';uniqueIndex:uk_cs_manual_entries_entry_no_group,priority:2;index" json:"entry_group"` Platform string `gorm:"size:64;not null;default:'';index" json:"platform"` Operator string `gorm:"size:64;not null;default:'';index" json:"operator"` AdminID uint64 `gorm:"not null;index" json:"admin_id"` @@ -25,10 +33,12 @@ func (CSManualEntry) TableName() string { // CSManualEntryOption 客服手动录单的可选项配置(平台 / 操作人)。 // 仅作为录单表单的下拉数据源,记录里保存的是名称快照。 +// 操作人选项可通过 group_tag 标记所属组别(收号/出号),录单时用于自动带出组别。 type CSManualEntryOption struct { ID uint64 `gorm:"primaryKey" json:"id"` Type string `gorm:"size:16;not null;uniqueIndex:uk_cs_manual_entry_options_type_name,priority:1" json:"type"` Name string `gorm:"size:64;not null;uniqueIndex:uk_cs_manual_entry_options_type_name,priority:2" json:"name"` + GroupTag string `gorm:"size:16;not null;default:''" json:"group_tag"` SortOrder int `gorm:"not null;default:0" json:"sort_order"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/internal/modules/csmanualentry/dto.go b/backend/internal/modules/csmanualentry/dto.go index c3068b20..7b8fd0ed 100644 --- a/backend/internal/modules/csmanualentry/dto.go +++ b/backend/internal/modules/csmanualentry/dto.go @@ -8,26 +8,35 @@ const ( OptionTypeOperator = "operator" // 操作人 ) +// 录单组别。 +const ( + EntryGroupCollect = "collect" // 收号 + EntryGroupSell = "sell" // 出号 +) + // CreateRequest 客服手动录单请求。 type CreateRequest struct { - EntryNo string `json:"entry_no" binding:"required"` - Platform string `json:"platform"` - Operator string `json:"operator"` - Remark string `json:"remark"` + EntryNo string `json:"entry_no" binding:"required"` + EntryGroup string `json:"entry_group" binding:"required"` + Platform string `json:"platform"` + Operator string `json:"operator"` + Remark string `json:"remark"` } // UpdateRequest 管理员修正录单请求。 type UpdateRequest struct { - EntryNo string `json:"entry_no" binding:"required"` - Platform string `json:"platform"` - Operator string `json:"operator"` - Remark string `json:"remark"` + EntryNo string `json:"entry_no" binding:"required"` + EntryGroup string `json:"entry_group" binding:"required"` + Platform string `json:"platform"` + Operator string `json:"operator"` + Remark string `json:"remark"` } // EntryDTO 录单记录。 type EntryDTO struct { ID uint64 `json:"id"` EntryNo string `json:"entry_no"` + EntryGroup string `json:"entry_group"` Platform string `json:"platform"` Operator string `json:"operator"` AdminID uint64 `json:"admin_id"` @@ -39,13 +48,14 @@ type EntryDTO struct { // ListQuery 录单查询条件。 type ListQuery struct { - EntryNo string - Keyword string - AdminID uint64 - StartDate string // YYYY-MM-DD(北京时间,闭区间) - EndDate string // YYYY-MM-DD(北京时间,闭区间) - Page int - PageSize int + EntryNo string + EntryGroup string + Keyword string + AdminID uint64 + StartDate string // YYYY-MM-DD(北京时间,闭区间) + EndDate string // YYYY-MM-DD(北京时间,闭区间) + Page int + PageSize int } // PaginatedResult 分页结果。 @@ -56,12 +66,14 @@ type PaginatedResult struct { PageSize int `json:"page_size"` } -// AgentDayStatsDTO 某客服某日录单统计。 +// AgentDayStatsDTO 某客服某日录单统计(按组分列)。 type AgentDayStatsDTO struct { - Date string `json:"date"` // YYYY-MM-DD(北京时间) - AdminID uint64 `json:"admin_id"` - AdminName string `json:"admin_name"` - Count int64 `json:"count"` + Date string `json:"date"` // YYYY-MM-DD(北京时间) + AdminID uint64 `json:"admin_id"` + AdminName string `json:"admin_name"` + Count int64 `json:"count"` // 总录单量 + CollectCount int64 `json:"collect_count"` // 收号录单量 + SellCount int64 `json:"sell_count"` // 出号录单量 } // CreateResult 录单结果。 @@ -74,6 +86,7 @@ type OptionDTO struct { ID uint64 `json:"id"` Type string `json:"type"` Name string `json:"name"` + GroupTag string `json:"group_tag"` // 操作人组别标记:collect/sell/空 SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` } @@ -86,11 +99,23 @@ type OptionListDTO struct { // CreateOptionRequest 新增平台 / 操作人选项。 type CreateOptionRequest struct { - Type string `json:"type" binding:"required"` - Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + Name string `json:"name" binding:"required"` + GroupTag string `json:"group_tag"` // 仅操作人类型可用,collect/sell/空 } -// UpdateOptionRequest 重命名平台 / 操作人选项。 +// UpdateOptionRequest 重命名平台 / 操作人选项(可同时更新组别标记)。 type UpdateOptionRequest struct { - Name string `json:"name" binding:"required"` + Name string `json:"name" binding:"required"` + GroupTag string `json:"group_tag"` // 仅操作人类型可用,collect/sell/空 +} + +// IsValidEntryGroup 判断组别取值是否合法。 +func IsValidEntryGroup(group string) bool { + return group == EntryGroupCollect || group == EntryGroupSell +} + +// IsValidGroupTag 判断选项组别标记是否合法(允许为空表示未分组)。 +func IsValidGroupTag(tag string) bool { + return tag == "" || IsValidEntryGroup(tag) } diff --git a/backend/internal/modules/csmanualentry/errors.go b/backend/internal/modules/csmanualentry/errors.go index 6fb8ef5b..d48f3a13 100644 --- a/backend/internal/modules/csmanualentry/errors.go +++ b/backend/internal/modules/csmanualentry/errors.go @@ -6,6 +6,7 @@ var ( ErrDependencyUnavailable = errors.New("dependency unavailable") ErrEntryNoExists = errors.New("manual entry number already exists") ErrEntryNoInvalid = errors.New("entry number is invalid") + ErrEntryGroupInvalid = errors.New("entry group is invalid") ErrEntryNotFound = errors.New("manual entry not found") ErrAdminNotFound = errors.New("admin not found") ErrOptionTypeInvalid = errors.New("manual entry option type is invalid") diff --git a/backend/internal/modules/csmanualentry/handler.go b/backend/internal/modules/csmanualentry/handler.go index 5b42f7a8..77c4a277 100644 --- a/backend/internal/modules/csmanualentry/handler.go +++ b/backend/internal/modules/csmanualentry/handler.go @@ -46,8 +46,9 @@ func (h *Handler) List(c *gin.Context) { } page, pageSize := parsePagination(c) query := ListQuery{ - EntryNo: strings.TrimSpace(c.Query("entry_no")), - Keyword: strings.TrimSpace(c.Query("keyword")), + EntryNo: strings.TrimSpace(c.Query("entry_no")), + EntryGroup: strings.TrimSpace(c.Query("entry_group")), + Keyword: strings.TrimSpace(c.Query("keyword")), StartDate: strings.TrimSpace(c.Query("start_date")), EndDate: strings.TrimSpace(c.Query("end_date")), Page: page, @@ -233,7 +234,9 @@ func writeManualEntryError(c *gin.Context, err error) { case errors.Is(err, ErrEntryNotFound): response.NotFound(c, "录单记录不存在") case errors.Is(err, ErrEntryNoExists): - response.Error(c, http.StatusConflict, "entry_no_exists", "该编号已被登记") + response.Error(c, http.StatusConflict, "entry_no_exists", "该编号在当前组别下已被登记") + case errors.Is(err, ErrEntryGroupInvalid): + response.BadRequest(c, "组别不正确,仅支持收号或出号") case errors.Is(err, ErrOptionTypeInvalid): response.BadRequest(c, "选项类型不正确") case errors.Is(err, ErrOptionNameInvalid): diff --git a/backend/internal/modules/csmanualentry/repository.go b/backend/internal/modules/csmanualentry/repository.go index 69cf0bbd..c188d49d 100644 --- a/backend/internal/modules/csmanualentry/repository.go +++ b/backend/internal/modules/csmanualentry/repository.go @@ -34,9 +34,10 @@ func (r *Repository) FindAdminSnapshot(ctx context.Context, adminID uint64) (nam } // Create 写入一条录单记录。 -func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, platform, operator, remark string) (*EntryDTO, error) { +func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, entryGroup, platform, operator, remark string) (*EntryDTO, error) { row := model.CSManualEntry{ EntryNo: entryNo, + EntryGroup: entryGroup, Platform: platform, Operator: operator, AdminID: adminID, @@ -67,15 +68,16 @@ func (r *Repository) FindByID(ctx context.Context, id uint64) (*EntryDTO, error) return &dto, nil } -// Update 修正录单的编号、平台、操作人与备注。编号唯一冲突返回 ErrEntryNoExists。 -func (r *Repository) Update(ctx context.Context, id uint64, entryNo, platform, operator, remark string) (*EntryDTO, error) { +// Update 修正录单的编号、组别、平台、操作人与备注。同组内编号唯一冲突返回 ErrEntryNoExists。 +func (r *Repository) Update(ctx context.Context, id uint64, entryNo, entryGroup, platform, operator, remark string) (*EntryDTO, error) { result := r.db.WithContext(ctx).Model(&model.CSManualEntry{}). Where("id = ?", id). Updates(map[string]any{ - "entry_no": entryNo, - "platform": platform, - "operator": operator, - "remark": remark, + "entry_no": entryNo, + "entry_group": entryGroup, + "platform": platform, + "operator": operator, + "remark": remark, }) if result.Error != nil { if isDuplicateEntry(result.Error) { @@ -116,6 +118,10 @@ func (r *Repository) List(ctx context.Context, query ListQuery) (*PaginatedResul db = db.Where("entry_no = ?", query.EntryNo) countDB = countDB.Where("entry_no = ?", query.EntryNo) } + if query.EntryGroup != "" { + db = db.Where("entry_group = ?", query.EntryGroup) + countDB = countDB.Where("entry_group = ?", query.EntryGroup) + } if query.AdminID > 0 { db = db.Where("admin_id = ?", query.AdminID) countDB = countDB.Where("admin_id = ?", query.AdminID) @@ -168,13 +174,17 @@ func (r *Repository) TodayAgentStats(ctx context.Context, day time.Time) ([]Agen end := start.AddDate(0, 0, 1) type row struct { - AdminID uint64 - AdminName string - Count int64 + AdminID uint64 + AdminName string + Count int64 + CollectCount int64 + SellCount int64 } rows := make([]row, 0) if err := r.db.WithContext(ctx).Table("cs_manual_entries"). - Select("admin_id, MAX(admin_name) AS admin_name, COUNT(*) AS count"). + Select(`admin_id, MAX(admin_name) AS admin_name, COUNT(*) AS count, + SUM(CASE WHEN entry_group = 'collect' THEN 1 ELSE 0 END) AS collect_count, + SUM(CASE WHEN entry_group = 'sell' THEN 1 ELSE 0 END) AS sell_count`). Where("created_at >= ? AND created_at < ?", start, end). Group("admin_id"). Scan(&rows).Error; err != nil { @@ -190,10 +200,12 @@ func (r *Repository) TodayAgentStats(ctx context.Context, day time.Time) ([]Agen items := make([]AgentDayStatsDTO, 0, len(rows)) for _, item := range rows { items = append(items, AgentDayStatsDTO{ - Date: date, - AdminID: item.AdminID, - AdminName: item.AdminName, - Count: item.Count, + Date: date, + AdminID: item.AdminID, + AdminName: item.AdminName, + Count: item.Count, + CollectCount: item.CollectCount, + SellCount: item.SellCount, }) } return items, nil @@ -232,11 +244,12 @@ func (r *Repository) ListAllOptions(ctx context.Context) (*OptionListDTO, error) } // CreateOption 新增一个平台 / 操作人选项,重名返回 ErrOptionExists。 -func (r *Repository) CreateOption(ctx context.Context, optionType, name string) (*OptionDTO, error) { +// 操作人选项可通过 groupTag 标记所属组别(collect/sell,空表示未分组)。 +func (r *Repository) CreateOption(ctx context.Context, optionType, name, groupTag string) (*OptionDTO, error) { if !IsValidOptionType(optionType) { return nil, ErrOptionTypeInvalid } - row := model.CSManualEntryOption{Type: optionType, Name: name} + row := model.CSManualEntryOption{Type: optionType, Name: name, GroupTag: groupTag} if err := r.db.WithContext(ctx).Create(&row).Error; err != nil { if isDuplicateEntry(err) { return nil, ErrOptionExists @@ -247,8 +260,8 @@ func (r *Repository) CreateOption(ctx context.Context, optionType, name string) return &dto, nil } -// UpdateOption 重命名一个选项,重名返回 ErrOptionExists。 -func (r *Repository) UpdateOption(ctx context.Context, id uint64, name string) (*OptionDTO, error) { +// UpdateOption 重命名一个选项并可更新组别标记,重名返回 ErrOptionExists。 +func (r *Repository) UpdateOption(ctx context.Context, id uint64, name, groupTag string) (*OptionDTO, error) { var row model.CSManualEntryOption if err := r.db.WithContext(ctx).First(&row, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -258,7 +271,10 @@ func (r *Repository) UpdateOption(ctx context.Context, id uint64, name string) ( } result := r.db.WithContext(ctx).Model(&model.CSManualEntryOption{}). Where("id = ?", id). - Update("name", name) + Updates(map[string]any{ + "name": name, + "group_tag": groupTag, + }) if result.Error != nil { if isDuplicateEntry(result.Error) { return nil, ErrOptionExists @@ -266,6 +282,7 @@ func (r *Repository) UpdateOption(ctx context.Context, id uint64, name string) ( return nil, result.Error } row.Name = name + row.GroupTag = groupTag dto := toOptionDTO(row) return &dto, nil } @@ -306,6 +323,7 @@ func toDTO(row model.CSManualEntry) EntryDTO { return EntryDTO{ ID: row.ID, EntryNo: row.EntryNo, + EntryGroup: row.EntryGroup, Platform: row.Platform, Operator: row.Operator, AdminID: row.AdminID, @@ -321,6 +339,7 @@ func toOptionDTO(row model.CSManualEntryOption) OptionDTO { ID: row.ID, Type: row.Type, Name: row.Name, + GroupTag: row.GroupTag, SortOrder: row.SortOrder, CreatedAt: row.CreatedAt, } diff --git a/backend/internal/modules/csmanualentry/repository_test.go b/backend/internal/modules/csmanualentry/repository_test.go index c310aedf..86b638a7 100644 --- a/backend/internal/modules/csmanualentry/repository_test.go +++ b/backend/internal/modules/csmanualentry/repository_test.go @@ -4,6 +4,7 @@ import ( "errors" "strings" "testing" + "time" "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" @@ -61,36 +62,36 @@ func TestRepositoryOptionCRUDAndEntryPlatformOperator(t *testing.T) { } // 1. 选项 CRUD - platform, err := repo.CreateOption(ctx, OptionTypePlatform, "网易大神") + platform, err := repo.CreateOption(ctx, OptionTypePlatform, "网易大神", "") if err != nil { t.Fatalf("创建平台选项失败: %v", err) } // 同类型第二个平台,用于验证重命名撞名(唯一键是 type+name,跨类型不算冲突) - if _, err := repo.CreateOption(ctx, OptionTypePlatform, "交易猫"); err != nil { + if _, err := repo.CreateOption(ctx, OptionTypePlatform, "交易猫", ""); err != nil { t.Fatalf("创建第二个平台选项失败: %v", err) } - operator, err := repo.CreateOption(ctx, OptionTypeOperator, "技术测试") + operator, err := repo.CreateOption(ctx, OptionTypeOperator, "技术测试", "") if err != nil { t.Fatalf("创建操作人选项失败: %v", err) } - if _, err := repo.CreateOption(ctx, OptionTypePlatform, "网易大神"); !errors.Is(err, ErrOptionExists) { + if _, err := repo.CreateOption(ctx, OptionTypePlatform, "网易大神", ""); !errors.Is(err, ErrOptionExists) { t.Fatalf("重复平台应返回 ErrOptionExists, got %v", err) } - if _, err := repo.CreateOption(ctx, "unknown", "x"); !errors.Is(err, ErrOptionTypeInvalid) { + if _, err := repo.CreateOption(ctx, "unknown", "x", ""); !errors.Is(err, ErrOptionTypeInvalid) { t.Fatalf("非法类型应返回 ErrOptionTypeInvalid, got %v", err) } // 与平台同名的操作人是允许的(不同 type) - if _, err := repo.CreateOption(ctx, OptionTypeOperator, "网易大神"); err != nil { + if _, err := repo.CreateOption(ctx, OptionTypeOperator, "网易大神", ""); err != nil { t.Fatalf("跨类型同名应允许: %v", err) } - if _, err := repo.UpdateOption(ctx, platform.ID, "网易"); err != nil { + if _, err := repo.UpdateOption(ctx, platform.ID, "网易", ""); err != nil { t.Fatalf("重命名选项失败: %v", err) } - if _, err := repo.UpdateOption(ctx, platform.ID, "交易猫"); !errors.Is(err, ErrOptionExists) { + if _, err := repo.UpdateOption(ctx, platform.ID, "交易猫", ""); !errors.Is(err, ErrOptionExists) { t.Fatalf("重命名撞同类型已有名应返回 ErrOptionExists, got %v", err) } - if _, err := repo.UpdateOption(ctx, 999999, "任何"); !errors.Is(err, ErrOptionNotFound) { + if _, err := repo.UpdateOption(ctx, 999999, "任何", ""); !errors.Is(err, ErrOptionNotFound) { t.Fatalf("重命名不存在选项应返回 ErrOptionNotFound, got %v", err) } @@ -106,7 +107,7 @@ func TestRepositoryOptionCRUDAndEntryPlatformOperator(t *testing.T) { } // 2. 录单写入平台 / 操作人 - entry, err := repo.Create(ctx, admin.ID, "客服小张", "cs001", "202610130001", "网易", "技术测试", "备注1") + entry, err := repo.Create(ctx, admin.ID, "客服小张", "cs001", "202610130001", EntryGroupCollect, "网易", "技术测试", "备注1") if err != nil { t.Fatalf("录单失败: %v", err) } @@ -115,7 +116,7 @@ func TestRepositoryOptionCRUDAndEntryPlatformOperator(t *testing.T) { } // 修正录单的平台与操作人 - updated, err := repo.Update(ctx, entry.ID, "202610130001", "网易", "客服小李", "备注2") + updated, err := repo.Update(ctx, entry.ID, "202610130001", EntryGroupCollect, "网易", "客服小李", "备注2") if err != nil { t.Fatalf("修正录单失败: %v", err) } @@ -154,3 +155,78 @@ func TestRepositoryOptionCRUDAndEntryPlatformOperator(t *testing.T) { t.Fatalf("删除选项后历史录单操作人 = %q, want 客服小李", after.Operator) } } + +func TestRepositoryEntryGroup(t *testing.T) { + repo, db := newManualEntryTestRepo(t) + ctx := t.Context() + + admin := model.AdminUser{Username: "cs002", Nickname: "客服小李", PasswordHash: "x"} + if err := db.Create(&admin).Error; err != nil { + t.Fatalf("创建管理员失败: %v", err) + } + + // 同一编号:收号组登记一次 + collect, err := repo.Create(ctx, admin.ID, "客服小李", "cs002", "202611050473/0", EntryGroupCollect, "", "", "") + if err != nil { + t.Fatalf("收号组录单失败: %v", err) + } + if collect.EntryGroup != EntryGroupCollect { + t.Fatalf("收号组别 = %q, want collect", collect.EntryGroup) + } + + // 同组重复登记应冲突 + if _, err := repo.Create(ctx, admin.ID, "客服小李", "cs002", "202611050473/0", EntryGroupCollect, "", "", ""); !errors.Is(err, ErrEntryNoExists) { + t.Fatalf("同组重复编号应返回 ErrEntryNoExists, got %v", err) + } + + // 出号组登记同一编号应成功(编号绑定两个客服/两个组) + sell, err := repo.Create(ctx, admin.ID, "客服小李", "cs002", "202611050473/0", EntryGroupSell, "", "", "") + if err != nil { + t.Fatalf("出号组录单失败: %v", err) + } + if sell.EntryGroup != EntryGroupSell { + t.Fatalf("出号组别 = %q, want sell", sell.EntryGroup) + } + + // 按组别筛选 + list, err := repo.List(ctx, ListQuery{EntryNo: "202611050473/0", EntryGroup: EntryGroupSell, Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("按组别查询失败: %v", err) + } + if list.Total != 1 || list.Items[0].EntryGroup != EntryGroupSell { + t.Fatalf("按出号组查询 = %+v, want 1 条 sell 记录", list.Items) + } + + // 今日统计按组分列 + stats, err := repo.TodayAgentStats(ctx, time.Now()) + if err != nil { + t.Fatalf("今日统计失败: %v", err) + } + if len(stats) != 1 { + t.Fatalf("今日统计客服数 = %d, want 1", len(stats)) + } + if stats[0].Count != 2 || stats[0].CollectCount != 1 || stats[0].SellCount != 1 { + t.Fatalf("今日统计 = %+v, want 总量2 收号1 出号1", stats[0]) + } + + // 修正组别:出号改成收号后与原收号记录冲突 + if _, err := repo.Update(ctx, sell.ID, "202611050473/0", EntryGroupCollect, "", "", ""); !errors.Is(err, ErrEntryNoExists) { + t.Fatalf("改成冲突组别应返回 ErrEntryNoExists, got %v", err) + } + + // 操作人选项组别标记 + operator, err := repo.CreateOption(ctx, OptionTypeOperator, "测试操作", EntryGroupCollect) + if err != nil { + t.Fatalf("创建操作人选项失败: %v", err) + } + if operator.GroupTag != EntryGroupCollect { + t.Fatalf("操作人组别标记 = %q, want collect", operator.GroupTag) + } + updated, err := repo.UpdateOption(ctx, operator.ID, "测试操作", EntryGroupSell) + if err != nil { + t.Fatalf("更新操作人组别失败: %v", err) + } + if updated.GroupTag != EntryGroupSell { + t.Fatalf("更新后组别标记 = %q, want sell", updated.GroupTag) + } +} diff --git a/backend/internal/modules/csmanualentry/service.go b/backend/internal/modules/csmanualentry/service.go index 510e0be3..6ba83e24 100644 --- a/backend/internal/modules/csmanualentry/service.go +++ b/backend/internal/modules/csmanualentry/service.go @@ -30,6 +30,10 @@ func (s *Service) Create(ctx context.Context, adminID uint64, req CreateRequest) if err != nil { return nil, err } + entryGroup, err := normalizeEntryGroup(req.EntryGroup) + if err != nil { + return nil, err + } platform, err := normalizeOptionName(req.Platform) if err != nil { return nil, err @@ -45,7 +49,7 @@ func (s *Service) Create(ctx context.Context, adminID uint64, req CreateRequest) if adminName == "" { adminName = adminUsername } - entry, err := s.repo.Create(ctx, adminID, adminName, adminUsername, entryNo, platform, operator, strings.TrimSpace(req.Remark)) + entry, err := s.repo.Create(ctx, adminID, adminName, adminUsername, entryNo, entryGroup, platform, operator, strings.TrimSpace(req.Remark)) if err != nil { return nil, err } @@ -61,6 +65,10 @@ func (s *Service) Update(ctx context.Context, id uint64, req UpdateRequest) (*En if err != nil { return nil, err } + entryGroup, err := normalizeEntryGroup(req.EntryGroup) + if err != nil { + return nil, err + } platform, err := normalizeOptionName(req.Platform) if err != nil { return nil, err @@ -69,7 +77,7 @@ func (s *Service) Update(ctx context.Context, id uint64, req UpdateRequest) (*En if err != nil { return nil, err } - return s.repo.Update(ctx, id, entryNo, platform, operator, strings.TrimSpace(req.Remark)) + return s.repo.Update(ctx, id, entryNo, entryGroup, platform, operator, strings.TrimSpace(req.Remark)) } // Delete 管理员删除录单记录。 @@ -120,7 +128,11 @@ func (s *Service) CreateOption(ctx context.Context, req CreateOptionRequest) (*O if name == "" { return nil, ErrOptionNameInvalid } - return s.repo.CreateOption(ctx, optionType, name) + groupTag, err := normalizeGroupTag(req.GroupTag) + if err != nil { + return nil, err + } + return s.repo.CreateOption(ctx, optionType, name, groupTag) } // UpdateOption 重命名平台 / 操作人选项。 @@ -135,7 +147,11 @@ func (s *Service) UpdateOption(ctx context.Context, id uint64, req UpdateOptionR if name == "" { return nil, ErrOptionNameInvalid } - return s.repo.UpdateOption(ctx, id, name) + groupTag, err := normalizeGroupTag(req.GroupTag) + if err != nil { + return nil, err + } + return s.repo.UpdateOption(ctx, id, name, groupTag) } // DeleteOption 删除平台 / 操作人选项。 @@ -173,3 +189,21 @@ func normalizeOptionName(raw string) (string, error) { } return value, nil } + +// normalizeEntryGroup 规整录单组别:必填且仅允许 collect / sell。 +func normalizeEntryGroup(raw string) (string, error) { + value := strings.TrimSpace(raw) + if !IsValidEntryGroup(value) { + return "", ErrEntryGroupInvalid + } + return value, nil +} + +// normalizeGroupTag 规整选项组别标记:允许为空(未分组),否则仅允许 collect / sell。 +func normalizeGroupTag(raw string) (string, error) { + value := strings.TrimSpace(raw) + if !IsValidGroupTag(value) { + return "", ErrEntryGroupInvalid + } + return value, nil +} diff --git a/backend/migrations/000096_cs_manual_entry_group.sql b/backend/migrations/000096_cs_manual_entry_group.sql new file mode 100644 index 00000000..4b00e057 --- /dev/null +++ b/backend/migrations/000096_cs_manual_entry_group.sql @@ -0,0 +1,27 @@ +-- +goose Up + +-- 客服手动录单:区分「收号 / 出号」两组客服。 +-- entry_group: collect=收号, sell=出号;唯一约束从 entry_no 放宽为 (entry_no, entry_group), +-- 同一编号允许收号、出号各登记一次。 +-- cs_manual_entry_options.group_tag 给操作人选项打组别标记,录单选操作人后自动带出组别。 +ALTER TABLE cs_manual_entries + ADD COLUMN entry_group VARCHAR(16) NOT NULL DEFAULT 'collect' COMMENT '录单组别: collect=收号, sell=出号' AFTER entry_no, + DROP KEY uk_cs_manual_entries_entry_no, + ADD UNIQUE KEY uk_cs_manual_entries_entry_no_group (entry_no, entry_group), + ADD KEY idx_cs_manual_entries_group (entry_group); + +ALTER TABLE cs_manual_entry_options + ADD COLUMN group_tag VARCHAR(16) NOT NULL DEFAULT '' COMMENT '操作人组别标记: collect=收号, sell=出号, 空=未分组' AFTER name; + +-- +goose Down + +-- 注意:若升级后同一编号已在收号/出号两组各登记过,Down 恢复 entry_no 唯一键会因重复失败, +-- 需先人工清理重复编号后再执行回滚。 +ALTER TABLE cs_manual_entry_options + DROP COLUMN group_tag; + +ALTER TABLE cs_manual_entries + DROP KEY idx_cs_manual_entries_group, + DROP KEY uk_cs_manual_entries_entry_no_group, + ADD UNIQUE KEY uk_cs_manual_entries_entry_no (entry_no), + DROP COLUMN entry_group; diff --git a/frontend/src/features/admin/api/adminManualEntry.ts b/frontend/src/features/admin/api/adminManualEntry.ts index 93470449..525ed054 100644 --- a/frontend/src/features/admin/api/adminManualEntry.ts +++ b/frontend/src/features/admin/api/adminManualEntry.ts @@ -5,9 +5,13 @@ import type { ApiResponse, PaginatedResult } from '@/shared/types/types' /** 录单可选项类型:平台 / 操作人 */ export type ManualEntryOptionType = 'platform' | 'operator' +/** 录单组别:收号 / 出号 */ +export type ManualEntryGroup = 'collect' | 'sell' + export interface ManualEntryItem { id: number entry_no: string + entry_group: ManualEntryGroup platform: string operator: string admin_id: number @@ -21,6 +25,8 @@ export interface ManualEntryOption { id: number type: ManualEntryOptionType name: string + /** 操作人组别标记:collect=收号, sell=出号, ''=未分组 */ + group_tag: ManualEntryGroup | '' sort_order: number created_at: string } @@ -34,10 +40,13 @@ export interface ManualEntryAgentStats { admin_id: number admin_name: string count: number + collect_count: number + sell_count: number } export interface CreateManualEntryRequest { entry_no: string + entry_group: ManualEntryGroup platform?: string operator?: string remark?: string @@ -45,6 +54,7 @@ export interface CreateManualEntryRequest { export interface UpdateManualEntryRequest { entry_no: string + entry_group: ManualEntryGroup platform?: string operator?: string remark?: string @@ -52,6 +62,7 @@ export interface UpdateManualEntryRequest { export interface ManualEntryListQuery { entry_no?: string + entry_group?: ManualEntryGroup keyword?: string admin_id?: number start_date?: string @@ -113,6 +124,7 @@ export async function fetchManualEntryOptions() { export async function createManualEntryOption(payload: { type: ManualEntryOptionType name: string + group_tag?: ManualEntryGroup | '' }) { const { data } = await apiClient.post>( '/admin/manual-entries/options', @@ -121,10 +133,13 @@ export async function createManualEntryOption(payload: { return data.data } -export async function updateManualEntryOption(id: number, name: string) { +export async function updateManualEntryOption( + id: number, + payload: { name: string; group_tag?: ManualEntryGroup | '' } +) { const { data } = await apiClient.put>( `/admin/manual-entries/options/${id}`, - { name } + payload ) return data.data } diff --git a/frontend/src/features/admin/views/AdminManualEntryView.css b/frontend/src/features/admin/views/AdminManualEntryView.css index 9014a475..774c435b 100644 --- a/frontend/src/features/admin/views/AdminManualEntryView.css +++ b/frontend/src/features/admin/views/AdminManualEntryView.css @@ -70,6 +70,10 @@ width: 200px; } +.filter-group { + width: 120px; +} + .filter-keyword { width: 320px; } @@ -119,6 +123,15 @@ gap: 8px; } +.option-config-add :deep(.option-config-group-select) { + width: 96px; + flex-shrink: 0; +} + +.option-config-group-select { + width: 96px; +} + .option-config-list { display: flex; flex-direction: column; diff --git a/frontend/src/features/admin/views/AdminManualEntryView.vue b/frontend/src/features/admin/views/AdminManualEntryView.vue index 8008eaf0..a1b2fc6d 100644 --- a/frontend/src/features/admin/views/AdminManualEntryView.vue +++ b/frontend/src/features/admin/views/AdminManualEntryView.vue @@ -11,6 +11,7 @@ import { fetchManualEntryOptions, updateManualEntry, updateManualEntryOption, + type ManualEntryGroup, type ManualEntryItem, type ManualEntryOption, type ManualEntryOptionType, @@ -24,6 +25,30 @@ import AdminTablePagination from '../components/AdminTablePagination.vue' const adminSession = useAdminSessionStore() +/** 录单组别常量与展示文案 */ +const GROUP_COLLECT: ManualEntryGroup = 'collect' +const GROUP_SELL: ManualEntryGroup = 'sell' +const groupLabels: Record = { + collect: '收号', + sell: '出号', +} +/** 组别展示文案 */ +function groupLabel(group: string): string { + return groupLabels[group as ManualEntryGroup] || group +} + +/** 组别标签颜色:收号=警示橙,出号=成功绿 */ +function groupTagType(group: string): 'warning' | 'success' { + return group === GROUP_SELL ? 'success' : 'warning' +} + +/** 配置弹窗里修改操作人组别标记(el-select 值规整) */ +function onOperatorGroupSelect(item: ManualEntryOption, value: unknown) { + const tag: ManualEntryGroup | '' = + value === 'collect' || value === 'sell' ? (value as ManualEntryGroup) : '' + void handleOperatorGroupChange(item, tag) +} + const currentAdminName = computed(() => adminSession.nickname || adminSession.username || '管理员') const canCreate = computed(() => adminSession.hasPermission('cs_manual_entry:create')) const canUpdate = computed(() => adminSession.hasPermission('cs_manual_entry:update')) @@ -33,6 +58,7 @@ const canManageOptions = computed(() => adminSession.hasPermission('cs_manual_en const filters = reactive({ keyword: '', entry_no: '', + entry_group: '' as ManualEntryGroup | '', dateRange: null as string[] | null, }) @@ -41,11 +67,22 @@ const optionsLoading = ref(false) const entryForm = reactive({ entry_no: '', + entry_group: GROUP_COLLECT as ManualEntryGroup, platform: '', operator: '', remark: '', }) +/** + * 选中带组别标记的操作人后自动带出组别(仍可手动修改)。 + */ +function handleEntryOperatorChange(name: string) { + const matched = options.value.operators.find(item => item.name === name) + if (matched?.group_tag) { + entryForm.entry_group = matched.group_tag + } +} + const submitting = ref(false) const { @@ -61,6 +98,7 @@ const { return fetchManualEntries(page, pageSize, { keyword: filters.keyword || undefined, entry_no: filters.entry_no || undefined, + entry_group: filters.entry_group || undefined, start_date: startDate, end_date: endDate, }) @@ -85,6 +123,7 @@ async function loadOptions() { function resetFilters() { filters.keyword = '' filters.entry_no = '' + filters.entry_group = '' filters.dateRange = null currentPage.value = 1 void loadEntries() @@ -101,9 +140,13 @@ async function handleCreate() { ElMessage.warning('请输入编号') return } + if (!entryForm.entry_group) { + ElMessage.warning('请选择组别') + return + } try { await ElMessageBox.confirm( - `确认将编号 ${entryNo} 登记到【${currentAdminName.value}】名下吗?`, + `确认将编号 ${entryNo} 以【${groupLabels[entryForm.entry_group]}】组别登记到【${currentAdminName.value}】名下吗?`, '录单确认', { confirmButtonText: '确认登记', cancelButtonText: '取消', type: 'warning' } ) @@ -114,11 +157,14 @@ async function handleCreate() { try { const result = await createManualEntry({ entry_no: entryNo, + entry_group: entryForm.entry_group, platform: entryForm.platform, operator: entryForm.operator, remark: entryForm.remark, }) - ElMessage.success(`编号 ${result.entry.entry_no} 已登记到【${result.entry.admin_name}】`) + ElMessage.success( + `编号 ${result.entry.entry_no}(${groupLabels[result.entry.entry_group]})已登记到【${result.entry.admin_name}】` + ) entryForm.entry_no = '' entryForm.platform = '' entryForm.operator = '' @@ -136,7 +182,13 @@ async function handleCreate() { const editVisible = ref(false) const editSubmitting = ref(false) const editingEntry = ref(null) -const editForm = reactive({ entry_no: '', platform: '', operator: '', remark: '' }) +const editForm = reactive({ + entry_no: '', + entry_group: GROUP_COLLECT as ManualEntryGroup, + platform: '', + operator: '', + remark: '', +}) /** * 历史记录里的平台/操作人可能已被后台删除选项, @@ -163,6 +215,7 @@ const editOperatorOptions = computed(() => function openEdit(row: ManualEntryItem) { editingEntry.value = row editForm.entry_no = row.entry_no + editForm.entry_group = row.entry_group || GROUP_COLLECT editForm.platform = row.platform || '' editForm.operator = row.operator || '' editForm.remark = row.remark @@ -180,6 +233,7 @@ async function handleUpdate() { try { await updateManualEntry(editingEntry.value.id, { entry_no: entryNo, + entry_group: editForm.entry_group, platform: editForm.platform, operator: editForm.operator, remark: editForm.remark, @@ -218,6 +272,7 @@ const configVisible = ref(false) const optionSubmitting = ref(false) const newPlatformName = ref('') const newOperatorName = ref('') +const newOperatorGroupTag = ref('') function openConfig() { configVisible.value = true @@ -234,9 +289,16 @@ async function handleAddOption(type: ManualEntryOptionType) { } optionSubmitting.value = true try { - await createManualEntryOption({ type, name }) + await createManualEntryOption({ + type, + name, + group_tag: type === 'operator' ? newOperatorGroupTag.value || undefined : undefined, + }) if (type === 'platform') newPlatformName.value = '' - else newOperatorName.value = '' + else { + newOperatorName.value = '' + newOperatorGroupTag.value = '' + } ElMessage.success('已添加') await loadOptions() } catch (error) { @@ -264,7 +326,7 @@ async function handleRenameOption(item: ManualEntryOption) { if (!name || name === item.name) return optionSubmitting.value = true try { - await updateManualEntryOption(item.id, name) + await updateManualEntryOption(item.id, { name, group_tag: item.group_tag }) ElMessage.success('已重命名') await loadOptions() } catch (error) { @@ -274,6 +336,27 @@ async function handleRenameOption(item: ManualEntryOption) { } } +/** + * 修改操作人的组别标记(收号/出号/未分组), + * 仅影响之后录单时的默认组别,不改动历史记录。 + */ +async function handleOperatorGroupChange(item: ManualEntryOption, groupTag: ManualEntryGroup | '') { + optionSubmitting.value = true + try { + await updateManualEntryOption(item.id, { name: item.name, group_tag: groupTag }) + ElMessage.success( + groupTag + ? `【${item.name}】已设为${groupLabels[groupTag]}组` + : `【${item.name}】已移除组别标记` + ) + await loadOptions() + } catch (error) { + ElMessage.error(readError(error, '设置组别失败')) + } finally { + optionSubmitting.value = false + } +} + async function handleRemoveOption(item: ManualEntryOption) { try { await ElMessageBox.confirm( @@ -329,6 +412,12 @@ async function handleRemoveOption(item: ManualEntryOption) { @keyup.enter="handleCreate" /> + + + 收号 + 出号 + + - + @@ -388,6 +478,15 @@ async function handleRemoveOption(item: ManualEntryOption) { class="filter-entry-no" @keyup.enter="applySearch" /> + + + + {{ row.entry_no }} + + + @@ -466,6 +573,12 @@ async function handleRemoveOption(item: ManualEntryOption) { clearable /> + + + 收号 + 出号 + +

这里维护的可选项会出现在录单表单的下拉中;删除选项不会影响已登记的历史记录。 + 操作人可设置组别标记(收号/出号),录单选中后自动带出对应组别。

@@ -569,6 +683,15 @@ async function handleRemoveOption(item: ManualEntryOption) { clearable @keyup.enter="handleAddOption('operator')" /> + + + + {{ item.name }} + + + + 重命名