package app import ( "context" "regexp" "strings" "unicode/utf8" "git.5iot.com/iotlcm-public/iotlcm-app/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-app/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-app/internal/library/appidentity" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-app/internal/service" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) const ( appWorkOrderCreatorTerminal = 1 appWorkOrderCreatorEnterprise = 2 appWorkOrderStatusAssigned = 1 appWorkOrderStatusProcessing = 2 appWorkOrderStatusResolved = 3 appWorkOrderStatusClosed = 4 appWorkOrderStatusCanceled = 5 appWorkOrderOperationCreate = 0 ) type sAppWorkOrder struct{} func NewAppWorkOrder() *sAppWorkOrder { return &sAppWorkOrder{} } func init() { service.RegisterAppWorkOrder(NewAppWorkOrder()) } func (s *sAppWorkOrder) Upload(ctx context.Context) (output *model.SysOssUploadModel, err error) { if err = appEnsureFeatureAccess(ctx, "workorder:create"); err != nil { if err = appEnsureFeatureAccess(ctx, "workorder:detail"); err != nil { return nil, err } } request := g.RequestFromCtx(ctx) if request == nil { return nil, gerror.New("上传请求无效") } file := request.GetUploadFile("file") if file == nil { return nil, gerror.New("请选择附件") } if file.Size > 10*1024*1024 { return nil, gerror.New("单个附件不能超过10MB") } originalName, err := appWorkOrderOriginalName(request.GetForm("originalName").String()) if err != nil { return nil, err } output, err = service.SysOss().Upload(ctx, &model.SysOssUploadParam{FileType: "file"}) if err != nil || originalName == "" { return output, err } columns := dao.SysOss.Columns() if _, err = dao.SysOss.Ctx(ctx). Where(columns.OssId, output.OssId). Where(columns.TenantId, output.TenantId). Data(do.SysOss{OriginalName: originalName}).Update(); err != nil { return nil, gerror.Wrap(err, "保存工单附件原名失败") } output.OriginalName = originalName return output, nil } func (s *sAppWorkOrder) List(ctx context.Context, param *model.AppWorkOrderListParam) (items []*model.AppWorkOrderModel, total int, err error) { if err = appEnsureFeatureAccess(ctx, "workorder:list"); err != nil { return nil, 0, err } if param == nil { param = &model.AppWorkOrderListParam{} } if param.Page <= 0 { param.Page = 1 } if param.PageSize <= 0 { param.PageSize = 20 } if param.PageSize > 100 { param.PageSize = 100 } user, err := appCurrentUser(ctx) if err != nil { return nil, 0, err } mod := s.ownerModel(ctx, user) if param.Scope != "" && param.Scope != "all" && param.Scope != "submitted" && param.Scope != "received" { return nil, 0, gerror.New("工单范围不正确") } if param.Scope == "submitted" { mod = mod.Where(dao.TWoMain.Columns().CreatorUserId, user.UserId) } if param.Scope == "received" { if appIsTerminalUser(ctx, user) { return nil, 0, gerror.New("终端账号不能查看待处理工单") } columns := dao.TWoMain.Columns() mod = mod.WhereNot(columns.CreatorUserId, user.UserId).Where("("+columns.HandlerUserId+" = ? OR ("+columns.HandlerDeptId+" = ? AND ? > 0))", user.UserId, user.DeptId, user.DeptId) } if param.CurrentStatus < 0 || param.CurrentStatus > 5 { return nil, 0, gerror.New("工单状态不正确") } if param.CurrentStatus > 0 { mod = mod.Where(dao.TWoMain.Columns().CurrentStatus, param.CurrentStatus) } if param.WoType != nil { if *param.WoType < 0 || *param.WoType > 5 { return nil, 0, gerror.New("工单类型不正确") } mod = mod.Where(dao.TWoMain.Columns().WoType, *param.WoType) } keyword := strings.TrimSpace(param.Keyword) if keyword == "" { keyword = strings.TrimSpace(param.PageInfo.Keyword) } if keyword != "" { like := "%" + keyword + "%" columns := dao.TWoMain.Columns() mod = mod.Where( "("+columns.WoNo+" LIKE ? OR "+columns.Title+" LIKE ? OR "+columns.Content+" LIKE ?)", like, like, like, ) } total, err = mod.Count() if err != nil { return nil, 0, gerror.Wrap(err, "查询工单数量失败") } if total == 0 { return []*model.AppWorkOrderModel{}, 0, nil } var rows []*entity.TWoMain if err = mod. OrderDesc(dao.TWoMain.Columns().CreatedAt). OrderDesc(dao.TWoMain.Columns().Id). Page(param.Page, param.PageSize). Scan(&rows); err != nil { return nil, 0, gerror.Wrap(err, "查询工单列表失败") } items = make([]*model.AppWorkOrderModel, 0, len(rows)) for _, row := range rows { if row != nil { items = append(items, appWorkOrderToModel(row)) } } return items, total, nil } func (s *sAppWorkOrder) Detail(ctx context.Context, param *model.AppWorkOrderDetailParam) (res *model.AppWorkOrderModel, err error) { if err = appEnsureFeatureAccess(ctx, "workorder:detail"); err != nil { return nil, err } if param == nil || strings.TrimSpace(param.WoNo) == "" { return nil, gerror.New("工单编号不能为空") } user, err := appCurrentUser(ctx) if err != nil { return nil, err } var row *entity.TWoMain if err = s.ownerModel(ctx, user). Where(dao.TWoMain.Columns().WoNo, strings.TrimSpace(param.WoNo)). Limit(1). Scan(&row); err != nil { return nil, gerror.Wrap(err, "查询工单详情失败") } if row == nil || row.Id == 0 { return nil, gerror.New("工单不存在或无权查看") } res = appWorkOrderToModel(row) var communications []*entity.TWoCommunication communicationModel := dao.TWoCommunication.Ctx(ctx). Where(dao.TWoCommunication.Columns().TenantId, user.TenantId). Where(dao.TWoCommunication.Columns().WoNo, row.WoNo) if param.BeforeId > 0 { communicationModel = communicationModel.WhereLT(dao.TWoCommunication.Columns().Id, param.BeforeId) } if err = communicationModel.OrderDesc(dao.TWoCommunication.Columns().Id).Limit(21). Scan(&communications); err != nil { return nil, gerror.Wrap(err, "查询工单沟通记录失败") } res.HasMoreCommunications = len(communications) > 20 if res.HasMoreCommunications { communications = communications[:20] } for left, right := 0, len(communications)-1; left < right; left, right = left+1, right-1 { communications[left], communications[right] = communications[right], communications[left] } res.Communications = appWorkOrderCommunicationsToModels(communications) urls := append([]string{}, res.AttachmentUrls...) for _, communication := range communications { if communication != nil && communication.AttachmentUrls != nil { urls = append(urls, communication.AttachmentUrls.Var().Strings()...) } } res.AttachmentNames, err = appWorkOrderAttachmentNames(ctx, user.TenantId, urls) if err != nil { return nil, err } res.Actions = appAvailableWorkOrderActions(ctx, row, user) res.IsCreator = row.CreatorUserId == user.UserId var records []*entity.TWoRecord if err = dao.TWoRecord.Ctx(ctx).Where(dao.TWoRecord.Columns().TenantId, user.TenantId).Where(dao.TWoRecord.Columns().WoNo, row.WoNo).OrderAsc(dao.TWoRecord.Columns().Id).Scan(&records); err != nil { return nil, gerror.Wrap(err, "查询工单进度失败") } for _, record := range records { res.Records = append(res.Records, &model.AppWorkOrderProgressModel{Id: record.Id, OperationType: record.OperationType, OperationText: appWorkOrderOperationText(record), BeforeStatus: record.BeforeStatus, AfterStatus: record.AfterStatus, CreatedAt: record.CreatedAt}) } return res, nil } var appWorkOrderPhonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) func appWorkOrderContactPhone(phone string) (string, error) { value := strings.TrimSpace(phone) if value == "" { return "", gerror.New("请输入创建人手机号") } if !appWorkOrderPhonePattern.MatchString(value) { return "", gerror.New("请输入正确的手机号") } return value, nil } func (s *sAppWorkOrder) Create(ctx context.Context, param *model.AppWorkOrderCreateParam) (res *model.AppWorkOrderModel, err error) { if err = appEnsureFeatureAccess(ctx, "workorder:create"); err != nil { return nil, err } if param == nil { return nil, gerror.New("工单参数不能为空") } title := strings.TrimSpace(param.Title) content := strings.TrimSpace(param.Content) if title == "" { return nil, gerror.New("工单标题不能为空") } if content == "" { return nil, gerror.New("问题描述不能为空") } if param.WoType < 0 || param.WoType > 5 { return nil, gerror.New("工单类型不正确") } user, err := appCurrentUser(ctx) if err != nil { return nil, err } creatorPhone, err := appWorkOrderContactPhone(param.CreatorPhone) if err != nil { return nil, err } if utf8.RuneCountInString(title) > 100 || utf8.RuneCountInString(content) > 2000 { return nil, gerror.New("标题最多100字,问题描述最多2000字") } if err = s.validateRelatedWorkOrder(ctx, user, param); err != nil { return nil, err } if err = appValidateWorkOrderAttachments(param.AttachmentUrls); err != nil { return nil, err } if err = s.validateAttachmentOwnership(ctx, user, param.AttachmentUrls); err != nil { return nil, err } now := gtime.Now() woNo := appCardBatchNo("WO", user.UserId, now) createdDept := user.DeptId if createdDept <= 0 { createdDept = consts.AppTerminalDeptId } creatorType := appWorkOrderCreatorEnterprise if appIsTerminalUser(ctx, user) { creatorType = appWorkOrderCreatorTerminal } creatorName := strings.TrimSpace(appFirstNotEmpty(user.NickName, user.UserName)) handlerDeptId, handlerUserId, err := appWorkOrderHandler(ctx, user, createdDept) if err != nil { return nil, err } var created *entity.TWoMain err = dao.TWoMain.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { _, err := dao.TWoMain.Ctx(ctx).TX(tx).Data(do.TWoMain{ WoNo: woNo, WoType: param.WoType, RelatedId: param.RelatedId, Title: title, Content: content, Attachment: appWorkOrderEncodeAttachments(param.AttachmentUrls), CreatorType: creatorType, CreatorDeptId: createdDept, CreatorUserId: user.UserId, CreatorName: creatorName, CreatorPhone: creatorPhone, AssignDeptId: handlerDeptId, AssignUserId: handlerUserId, CurrentStatus: appWorkOrderStatusProcessing, HandlerDeptId: handlerDeptId, HandlerUserId: handlerUserId, TenantId: user.TenantId, CreatedDept: createdDept, CreatedBy: user.UserId, CreatedAt: now, UpdatedBy: user.UserId, UpdatedAt: now, }).Insert() if err != nil { return gerror.Wrap(err, "创建工单失败") } communicationData := do.TWoCommunication{ WoNo: woNo, CommType: 1, SenderDeptId: createdDept, SenderUserId: user.UserId, SenderName: creatorName, Content: content, TenantId: user.TenantId, CreatedDept: createdDept, CreatedBy: user.UserId, CreatedAt: now, UpdatedBy: user.UserId, UpdatedAt: now, } if len(param.AttachmentUrls) > 0 { communicationData.AttachmentUrls = gjson.New(param.AttachmentUrls) } if _, err = dao.TWoCommunication.Ctx(ctx).TX(tx).Data(communicationData).Insert(); err != nil { return gerror.Wrap(err, "创建工单沟通记录失败") } if _, err = dao.TWoRecord.Ctx(ctx).TX(tx).Data(do.TWoRecord{ WoNo: woNo, OperationType: appWorkOrderOperationCreate, OperationDeptId: createdDept, OperationUserId: user.UserId, BeforeStatus: 0, AfterStatus: appWorkOrderStatusProcessing, TenantId: user.TenantId, CreatedDept: createdDept, CreatedBy: user.UserId, CreatedAt: now, UpdatedBy: user.UserId, UpdatedAt: now, }).Insert(); err != nil { return gerror.Wrap(err, "创建工单操作记录失败") } if err := dao.TWoMain.Ctx(ctx).TX(tx). Where(dao.TWoMain.Columns().TenantId, user.TenantId). Where(dao.TWoMain.Columns().CreatorUserId, user.UserId). Where(dao.TWoMain.Columns().WoNo, woNo). Scan(&created); err != nil { return gerror.Wrap(err, "读取新建工单失败") } return nil }) if err != nil { return nil, err } return appWorkOrderToModel(created), nil } func (s *sAppWorkOrder) ownerModel(ctx context.Context, user *entity.SysUser) *gdb.Model { columns := dao.TWoMain.Columns() mod := dao.TWoMain.Ctx(ctx). Where(columns.TenantId, user.TenantId) terminal, identityErr := appidentity.IsTerminalUser(ctx, user) if identityErr != nil { g.Log().Errorf(ctx, "工单用户身份查询失败: %v", identityErr) return mod.Where("1 = 0") } if terminal { return mod.Where(columns.CreatorUserId, user.UserId) } scope, err := appResolveEnterpriseDataScope(ctx, user) if err != nil { g.Log().Errorf(ctx, "work order data scope: %v", err) return mod.Where("1 = 0") } if scope.All { return mod } condition := "" args := []any{} if !scope.Configured { condition = columns.CreatorDeptId + " = ? OR " + columns.CreatorUserId + " = ?" args = append(args, user.DeptId, user.UserId) } else { if len(scope.DeptIds) > 0 { condition = columns.CreatorDeptId + " IN (?)" args = append(args, scope.DeptIds) } if len(scope.UserIds) > 0 { if condition != "" { condition += " OR " } condition += columns.CreatorUserId + " IN (?)" args = append(args, scope.UserIds) } } if condition != "" { condition += " OR " } condition += columns.HandlerUserId + " = ?" args = append(args, user.UserId) if user.DeptId > 0 { condition += " OR " + columns.HandlerDeptId + " = ?" args = append(args, user.DeptId) } return mod.Where("("+condition+")", args...) } func appWorkOrderHandler(ctx context.Context, user *entity.SysUser, createdDept int64) (deptId int64, userId int64, err error) { if user == nil || strings.TrimSpace(user.TenantId) == "" || createdDept <= 0 { return 0, 0, gerror.New("当前账号未配置有效的工单所属部门") } columns := dao.SysDept.Columns() var currentDept *entity.SysDept if err = dao.SysDept.Ctx(ctx). Fields(columns.DeptId, columns.ParentId, columns.Leader, columns.Status). Where(columns.TenantId, user.TenantId). Where(columns.DeptId, createdDept). WhereNull(columns.DeletedAt). Limit(1). Scan(¤tDept); err != nil { return 0, 0, gerror.Wrap(err, "查询工单所属部门失败") } if currentDept == nil || currentDept.DeptId <= 0 || currentDept.Status != "0" { return 0, 0, gerror.New("工单所属部门不存在或已停用") } if currentDept.ParentId == 0 { return currentDept.DeptId, currentDept.Leader, nil } if currentDept.ParentId == currentDept.DeptId { return 0, 0, gerror.New("工单所属部门的上级配置异常") } var parentDept *entity.SysDept if err = dao.SysDept.Ctx(ctx). Fields(columns.DeptId, columns.Leader, columns.Status). Where(columns.TenantId, user.TenantId). Where(columns.DeptId, currentDept.ParentId). WhereNull(columns.DeletedAt). Limit(1). Scan(&parentDept); err != nil { return 0, 0, gerror.Wrap(err, "查询上级工单处理部门失败") } if parentDept == nil || parentDept.DeptId <= 0 || parentDept.Status != "0" { return 0, 0, gerror.New("上级工单处理部门不存在或已停用,请联系管理员") } return parentDept.DeptId, parentDept.Leader, nil } func appWorkOrderToModel(row *entity.TWoMain) *model.AppWorkOrderModel { if row == nil { return nil } return &model.AppWorkOrderModel{ Id: row.Id, Actions: []string{}, ResolveTime: row.ResolveTime, CloseTime: row.CloseTime, SatisfactionLevel: row.SatisfactionLevel, EvalContent: row.EvalContent, EvalTime: row.EvalTime, WoNo: row.WoNo, WoType: row.WoType, WoTypeText: appWorkOrderTypeText(row.WoType), RelatedId: row.RelatedId, Title: row.Title, Content: row.Content, AttachmentUrls: decodeAttachmentUrls(row.Attachment), CreatorType: row.CreatorType, CreatorName: row.CreatorName, CreatorPhone: row.CreatorPhone, CurrentStatus: row.CurrentStatus, CurrentStatusText: appWorkOrderStatusText(row.CurrentStatus), StatusType: appWorkOrderStatusType(row.CurrentStatus), CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } } func appWorkOrderCommunicationsToModels(rows []*entity.TWoCommunication) []*model.AppWorkOrderCommModel { items := make([]*model.AppWorkOrderCommModel, 0, len(rows)) for _, row := range rows { if row == nil { continue } items = append(items, &model.AppWorkOrderCommModel{ Id: row.Id, WoNo: row.WoNo, CommType: row.CommType, CommTypeText: appWorkOrderCommTypeText(row.CommType), SenderName: row.SenderName, Content: row.Content, AttachmentUrls: row.AttachmentUrls, CreatedAt: row.CreatedAt, }) } return items } func decodeAttachmentUrls(value string) []string { value = strings.TrimSpace(value) if value == "" { return nil } var urls []string if err := gjson.DecodeTo(value, &urls); err == nil && (strings.HasPrefix(value, "[") || value == "null") { return urls } return []string{value} } func appWorkOrderTypeText(value int) string { switch value { case 0: return "卡片" case 1: return "模组" case 2: return "设备" case 3: return "订单" case 4: return "余额" case 5: return "提现" default: return "其他" } } func appWorkOrderStatusText(value int) string { switch value { case appWorkOrderStatusAssigned: return "已分派" case appWorkOrderStatusProcessing: return "处理中" case appWorkOrderStatusResolved: return "已解决" case appWorkOrderStatusClosed: return "已完结" case appWorkOrderStatusCanceled: return "已取消" default: return "待处理" } } func appWorkOrderStatusType(value int) string { switch value { case appWorkOrderStatusResolved, appWorkOrderStatusClosed: return "success" case appWorkOrderStatusCanceled: return "danger" case appWorkOrderStatusProcessing: return "primary" default: return "warning" } } func appWorkOrderCommTypeText(value int) string { if value == 1 { return "反馈" } return "回复" }