feat(admin): 客服手动录单支持平台与操作人,并可后台配置可选项
- 录单列表新增「平台」「操作人」两列,录单表单与编辑弹窗新增同名字段下拉 - 新增 cs_manual_entry_options 表与 4 个接口(读取/新增/重命名/删除),页面右上角弹窗维护平台与操作人 - 迁移 000087:cs_manual_entries 增加 platform/operator 列与索引,注册 cs_manual_entry:option_manage 权限并授予 ops/super_admin - 记录保存名称快照,删除选项不影响历史记录;同类型重名返回 409,跨类型同名允许 - 顶部关键词查询支持命中平台/操作人 - 新增单测:选项 CRUD 边界与录单平台/操作人读写
This commit is contained in:
@@ -34,9 +34,11 @@ func (r *Repository) FindAdminSnapshot(ctx context.Context, adminID uint64) (nam
|
||||
}
|
||||
|
||||
// Create 写入一条录单记录。
|
||||
func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, remark string) (*EntryDTO, error) {
|
||||
func (r *Repository) Create(ctx context.Context, adminID uint64, adminName, adminUsername, entryNo, platform, operator, remark string) (*EntryDTO, error) {
|
||||
row := model.CSManualEntry{
|
||||
EntryNo: entryNo,
|
||||
Platform: platform,
|
||||
Operator: operator,
|
||||
AdminID: adminID,
|
||||
AdminName: adminName,
|
||||
AdminUsername: adminUsername,
|
||||
@@ -65,12 +67,14 @@ 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, remark string) (*EntryDTO, error) {
|
||||
// Update 修正录单的编号、平台、操作人与备注。编号唯一冲突返回 ErrEntryNoExists。
|
||||
func (r *Repository) Update(ctx context.Context, id uint64, entryNo, 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,
|
||||
})
|
||||
if result.Error != nil {
|
||||
@@ -118,7 +122,10 @@ func (r *Repository) List(ctx context.Context, query ListQuery) (*PaginatedResul
|
||||
}
|
||||
if kw := strings.TrimSpace(query.Keyword); kw != "" {
|
||||
like := "%" + kw + "%"
|
||||
cond := r.db.Where("entry_no LIKE ? OR admin_name LIKE ? OR admin_username LIKE ?", like, like, like)
|
||||
cond := r.db.Where(
|
||||
"entry_no LIKE ? OR admin_name LIKE ? OR admin_username LIKE ? OR platform LIKE ? OR operator LIKE ?",
|
||||
like, like, like, like, like,
|
||||
)
|
||||
db = db.Where(cond)
|
||||
countDB = countDB.Where(cond)
|
||||
}
|
||||
@@ -192,11 +199,99 @@ func (r *Repository) TodayAgentStats(ctx context.Context, day time.Time) ([]Agen
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ListOptions 返回某类型(平台 / 操作人)的全部可选项。
|
||||
func (r *Repository) ListOptions(ctx context.Context, optionType string) ([]OptionDTO, error) {
|
||||
if !IsValidOptionType(optionType) {
|
||||
return nil, ErrOptionTypeInvalid
|
||||
}
|
||||
rows := make([]model.CSManualEntryOption, 0)
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("type = ?", optionType).
|
||||
Order("sort_order ASC, id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]OptionDTO, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
items = append(items, toOptionDTO(row))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ListAllOptions 返回平台与操作人两组可选项,供录单表单直接使用。
|
||||
func (r *Repository) ListAllOptions(ctx context.Context) (*OptionListDTO, error) {
|
||||
platforms, err := r.ListOptions(ctx, OptionTypePlatform)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operators, err := r.ListOptions(ctx, OptionTypeOperator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &OptionListDTO{Platforms: platforms, Operators: operators}, nil
|
||||
}
|
||||
|
||||
// CreateOption 新增一个平台 / 操作人选项,重名返回 ErrOptionExists。
|
||||
func (r *Repository) CreateOption(ctx context.Context, optionType, name string) (*OptionDTO, error) {
|
||||
if !IsValidOptionType(optionType) {
|
||||
return nil, ErrOptionTypeInvalid
|
||||
}
|
||||
row := model.CSManualEntryOption{Type: optionType, Name: name}
|
||||
if err := r.db.WithContext(ctx).Create(&row).Error; err != nil {
|
||||
if isDuplicateEntry(err) {
|
||||
return nil, ErrOptionExists
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
dto := toOptionDTO(row)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// UpdateOption 重命名一个选项,重名返回 ErrOptionExists。
|
||||
func (r *Repository) UpdateOption(ctx context.Context, id uint64, name 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) {
|
||||
return nil, ErrOptionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
result := r.db.WithContext(ctx).Model(&model.CSManualEntryOption{}).
|
||||
Where("id = ?", id).
|
||||
Update("name", name)
|
||||
if result.Error != nil {
|
||||
if isDuplicateEntry(result.Error) {
|
||||
return nil, ErrOptionExists
|
||||
}
|
||||
return nil, result.Error
|
||||
}
|
||||
row.Name = name
|
||||
dto := toOptionDTO(row)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
// DeleteOption 删除一个选项(历史录单里的名称快照不受影响)。
|
||||
func (r *Repository) DeleteOption(ctx context.Context, id uint64) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.CSManualEntryOption{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrOptionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsValidOptionType 判断选项类型是否合法。
|
||||
func IsValidOptionType(optionType string) bool {
|
||||
return optionType == OptionTypePlatform || optionType == OptionTypeOperator
|
||||
}
|
||||
|
||||
func parseDay(value string, loc *time.Location) (time.Time, error) {
|
||||
return time.ParseInLocation("2006-01-02", value, loc)
|
||||
}
|
||||
|
||||
// isDuplicateEntry 判断错误是否为 entry_no 唯一键冲突。
|
||||
// isDuplicateEntry 判断错误是否为唯一键冲突。
|
||||
func isDuplicateEntry(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
@@ -211,6 +306,8 @@ func toDTO(row model.CSManualEntry) EntryDTO {
|
||||
return EntryDTO{
|
||||
ID: row.ID,
|
||||
EntryNo: row.EntryNo,
|
||||
Platform: row.Platform,
|
||||
Operator: row.Operator,
|
||||
AdminID: row.AdminID,
|
||||
AdminName: row.AdminName,
|
||||
AdminUsername: row.AdminUsername,
|
||||
@@ -218,3 +315,13 @@ func toDTO(row model.CSManualEntry) EntryDTO {
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toOptionDTO(row model.CSManualEntryOption) OptionDTO {
|
||||
return OptionDTO{
|
||||
ID: row.ID,
|
||||
Type: row.Type,
|
||||
Name: row.Name,
|
||||
SortOrder: row.SortOrder,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user