- 录单记录新增组别(收号/出号),唯一约束放宽为编号+组别,同一编号两组各可登记一次 - 操作人选项支持组别标记,录单选中后自动带出组别 - 列表新增组别列与筛选,今日统计按收号/出号分列
210 lines
5.5 KiB
Go
210 lines
5.5 KiB
Go
package csmanualentry
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"hfb_sys/backend/internal/timeutil"
|
|
)
|
|
|
|
const (
|
|
maxEntryNoLength = 64
|
|
maxOptionNameLength = 64
|
|
)
|
|
|
|
type Service struct {
|
|
repo *Repository
|
|
}
|
|
|
|
func NewService(repo *Repository) *Service {
|
|
return &Service{repo: repo}
|
|
}
|
|
|
|
// Create 校验编号并落库。客服身份由 handler 从登录上下文注入。
|
|
func (s *Service) Create(ctx context.Context, adminID uint64, req CreateRequest) (*CreateResult, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
entryNo, err := normalizeEntryNo(req.EntryNo)
|
|
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
|
|
}
|
|
operator, err := normalizeOptionName(req.Operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
adminName, adminUsername, err := s.repo.FindAdminSnapshot(ctx, adminID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if adminName == "" {
|
|
adminName = adminUsername
|
|
}
|
|
entry, err := s.repo.Create(ctx, adminID, adminName, adminUsername, entryNo, entryGroup, platform, operator, strings.TrimSpace(req.Remark))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &CreateResult{Entry: *entry}, nil
|
|
}
|
|
|
|
// Update 管理员修正录单记录。
|
|
func (s *Service) Update(ctx context.Context, id uint64, req UpdateRequest) (*EntryDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
entryNo, err := normalizeEntryNo(req.EntryNo)
|
|
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
|
|
}
|
|
operator, err := normalizeOptionName(req.Operator)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.Update(ctx, id, entryNo, entryGroup, platform, operator, strings.TrimSpace(req.Remark))
|
|
}
|
|
|
|
// Delete 管理员删除录单记录。
|
|
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
|
if s.repo == nil {
|
|
return ErrDependencyUnavailable
|
|
}
|
|
return s.repo.Delete(ctx, id)
|
|
}
|
|
|
|
// List 分页查询录单记录。
|
|
func (s *Service) List(ctx context.Context, query ListQuery) (*PaginatedResult, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
return s.repo.List(ctx, query)
|
|
}
|
|
|
|
// TodayAgentStats 返回今日(北京时间)各客服录单统计。
|
|
func (s *Service) TodayAgentStats(ctx context.Context) ([]AgentDayStatsDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
return s.repo.TodayAgentStats(ctx, timeutil.ShanghaiNow())
|
|
}
|
|
|
|
// ListOptions 返回平台与操作人两组可选项。
|
|
func (s *Service) ListOptions(ctx context.Context) (*OptionListDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
return s.repo.ListAllOptions(ctx)
|
|
}
|
|
|
|
// CreateOption 新增平台 / 操作人选项。
|
|
func (s *Service) CreateOption(ctx context.Context, req CreateOptionRequest) (*OptionDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
optionType := strings.TrimSpace(req.Type)
|
|
if !IsValidOptionType(optionType) {
|
|
return nil, ErrOptionTypeInvalid
|
|
}
|
|
name, err := normalizeOptionName(req.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, ErrOptionNameInvalid
|
|
}
|
|
groupTag, err := normalizeGroupTag(req.GroupTag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.CreateOption(ctx, optionType, name, groupTag)
|
|
}
|
|
|
|
// UpdateOption 重命名平台 / 操作人选项。
|
|
func (s *Service) UpdateOption(ctx context.Context, id uint64, req UpdateOptionRequest) (*OptionDTO, error) {
|
|
if s.repo == nil {
|
|
return nil, ErrDependencyUnavailable
|
|
}
|
|
name, err := normalizeOptionName(req.Name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if name == "" {
|
|
return nil, ErrOptionNameInvalid
|
|
}
|
|
groupTag, err := normalizeGroupTag(req.GroupTag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.UpdateOption(ctx, id, name, groupTag)
|
|
}
|
|
|
|
// DeleteOption 删除平台 / 操作人选项。
|
|
func (s *Service) DeleteOption(ctx context.Context, id uint64) error {
|
|
if s.repo == nil {
|
|
return ErrDependencyUnavailable
|
|
}
|
|
return s.repo.DeleteOption(ctx, id)
|
|
}
|
|
|
|
// normalizeEntryNo 去除首尾空白并做基础校验。
|
|
func normalizeEntryNo(raw string) (string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
return "", ErrEntryNoInvalid
|
|
}
|
|
if len(value) > maxEntryNoLength {
|
|
return "", ErrEntryNoInvalid
|
|
}
|
|
if strings.ContainsAny(value, " \t\r\n") {
|
|
return "", ErrEntryNoInvalid
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// normalizeOptionName 规整平台 / 操作人名称:去首尾空白、按字符数限长。
|
|
// 允许为空(录单时平台与操作人选填),但内部不允许换行等控制字符。
|
|
func normalizeOptionName(raw string) (string, error) {
|
|
value := strings.TrimSpace(raw)
|
|
if utf8.RuneCountInString(value) > maxOptionNameLength {
|
|
return "", ErrOptionNameInvalid
|
|
}
|
|
if strings.ContainsAny(value, "\t\r\n") {
|
|
return "", ErrOptionNameInvalid
|
|
}
|
|
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
|
|
}
|