package services import ( "encoding/base64" "errors" "fmt" "mime/multipart" "os" "strings" "time" "github.com/google/uuid" qr "github.com/yeqown/go-qrcode/v2" "github.com/yeqown/go-qrcode/writer/standard" "git.beifan.cn/trace-system/backend-go/config" "git.beifan.cn/trace-system/backend-go/database" "git.beifan.cn/trace-system/backend-go/models" ) // ProjectOrdersService 项目工单服务 type ProjectOrdersService struct{} // 工单状态常量 const ( ProjectOrderStatusCreated = "created" ProjectOrderStatusPendingCompletion = "pending_completion" ProjectOrderStatusClosed = "closed" projectOrderSerialPrefix = "zjbf-xm-" ) func normalizeProjectOrderSerial(sn string) string { return strings.ToLower(strings.TrimSpace(sn)) } func hydrateProjectOrder(order *models.ProjectOrder) { if order == nil { return } order.SiteImages = siteImageAccessURLs(parseSiteImages(order.SiteImagesJSON)) } // generateUniqueSerial 生成唯一的项目工单序列号 // 格式:zjbf-xm-YYMMDDNN,YY=年份后两位,MM=月份,DD=日期,NN=当天第几单(至少 2 位,溢出自然加宽) func (s *ProjectOrdersService) generateUniqueSerial() (string, error) { now := time.Now() yy := now.Year() % 100 mm := int(now.Month()) dd := now.Day() dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) nextDay := dayStart.AddDate(0, 0, 1) // 统计当天已创建工单数(含软删除,避免编号回收) var count int64 if err := database.DB.Unscoped().Model(&models.ProjectOrder{}). Where("created_at >= ? AND created_at < ?", dayStart, nextDay). Count(&count).Error; err != nil { return "", fmt.Errorf("统计当日工单数失败: %w", err) } seq := int(count) + 1 for attempt := 0; attempt < 100; attempt++ { candidate := fmt.Sprintf("%s%02d%02d%02d%02d", projectOrderSerialPrefix, yy, mm, dd, seq) var existing models.ProjectOrder result := database.DB.Unscoped().Where("serial_number = ?", candidate).First(&existing) if result.Error != nil { return candidate, nil } seq++ } return "", errors.New("生成唯一序列号失败,请重试") } // Create 创建项目工单 func (s *ProjectOrdersService) Create(dto models.CreateProjectOrderDTO, userId uint) (*models.ProjectOrder, error) { serialNumber, err := s.generateUniqueSerial() if err != nil { return nil, err } technicianID := dto.TechnicianID if technicianID == nil { uid := userId technicianID = &uid } order := models.ProjectOrder{ SerialNumber: serialNumber, CompanyName: dto.CompanyName, CompanyAddress: dto.CompanyAddress, ContactName: dto.ContactName, ContactPhone: dto.ContactPhone, ProjectType: dto.ProjectType, SiteDescription: dto.SiteDescription, TechnicianID: technicianID, CreatedBy: &userId, WorkOrderStatus: ProjectOrderStatusCreated, } if err := database.DB.Create(&order).Error; err != nil { return nil, fmt.Errorf("创建项目工单失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // FindAll 获取项目工单列表 func (s *ProjectOrdersService) FindAll( page int, limit int, search string, workOrderStatus string, serviceType string, technicianID *uint, ) ([]models.ProjectOrder, int, int, error) { var orders []models.ProjectOrder var total int64 offset := (page - 1) * limit db := database.DB.Model(&models.ProjectOrder{}).Preload("Technician").Preload("Creator") if search != "" { pattern := "%" + search + "%" db = db.Where("serial_number LIKE ? OR company_name LIKE ? OR contact_name LIKE ?", pattern, pattern, pattern) } if workOrderStatus != "" { db = db.Where("work_order_status = ?", workOrderStatus) } if serviceType != "" { db = db.Where("project_type = ?", serviceType) } if technicianID != nil { db = db.Where("technician_id = ?", *technicianID) } if err := db.Count(&total).Error; err != nil { return nil, 0, 0, fmt.Errorf("查询项目工单总数失败: %w", err) } if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&orders).Error; err != nil { return nil, 0, 0, fmt.Errorf("查询项目工单列表失败: %w", err) } totalPages := (int(total) + limit - 1) / limit return orders, int(total), totalPages, nil } // FindOne 获取单个项目工单 func (s *ProjectOrdersService) FindOne(serialNumber string) (*models.ProjectOrder, error) { var order models.ProjectOrder result := database.DB.Preload("Technician").Preload("Creator"). Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } hydrateProjectOrder(&order) return &order, nil } // Update 更新项目工单基础信息 func (s *ProjectOrdersService) Update( serialNumber string, dto models.UpdateProjectOrderDTO, currentUser models.User, ) (*models.ProjectOrder, error) { var order models.ProjectOrder result := database.DB.Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } if order.WorkOrderStatus == ProjectOrderStatusClosed { return nil, errors.New("工单已关闭,不可修改") } if currentUser.Role != "admin" { if order.TechnicianID == nil || *order.TechnicianID != currentUser.ID { return nil, errors.New("无权修改此工单") } } if dto.CompanyAddress != "" { order.CompanyAddress = dto.CompanyAddress } if dto.ContactName != "" { order.ContactName = dto.ContactName } if dto.ContactPhone != "" { order.ContactPhone = dto.ContactPhone } if dto.ProjectType != "" { order.ProjectType = dto.ProjectType } if dto.SiteDescription != "" { order.SiteDescription = dto.SiteDescription } if dto.CompletionNote != "" { order.CompletionNote = dto.CompletionNote } if dto.TechnicianID != nil && currentUser.Role == "admin" { order.TechnicianID = dto.TechnicianID } if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("更新项目工单失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // SubmitCompletion 工单负责人提交完成资料,进入待完成确认状态 func (s *ProjectOrdersService) SubmitCompletion( serialNumber string, dto models.SubmitProjectCompletionDTO, currentUser models.User, ) (*models.ProjectOrder, error) { var order models.ProjectOrder result := database.DB.Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } if currentUser.Role != "admin" { if order.TechnicianID == nil || *order.TechnicianID != currentUser.ID { return nil, errors.New("无权操作此工单") } } if order.WorkOrderStatus != ProjectOrderStatusCreated { return nil, errors.New("当前工单状态不可提交完成资料") } order.CompletionNote = dto.CompletionNote order.WorkOrderStatus = ProjectOrderStatusPendingCompletion if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("提交完成资料失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // PublicQuery 公开查询(脱敏视图) func (s *ProjectOrdersService) PublicQuery(serialNumber string) (*models.ProjectOrderPublicView, error) { var order models.ProjectOrder result := database.DB.Preload("Technician"). Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } // 首次扫码记录时间 if order.ScannedAt == nil { now := time.Now() order.ScannedAt = &now _ = database.DB.Model(&order).Update("scanned_at", now).Error } view := &models.ProjectOrderPublicView{ SerialNumber: order.SerialNumber, CompanyName: order.CompanyName, CompanyAddress: order.CompanyAddress, ContactName: order.ContactName, ProjectType: order.ProjectType, SiteDescription: order.SiteDescription, CompletionNote: order.CompletionNote, WorkOrderStatus: order.WorkOrderStatus, CreatedAt: order.CreatedAt, CompletedAt: order.CompletedAt, EngineerSignature: order.EngineerSignature, SiteImages: siteImageAccessURLs(parseSiteImages(order.SiteImagesJSON)), } if order.Technician != nil { view.TechnicianName = order.Technician.Name } return view, nil } // UploadSiteImages 上传完成确认现场图片到 OSS 并保存 object key。 func (s *ProjectOrdersService) UploadSiteImages(serialNumber string, files []*multipart.FileHeader) ([]string, error) { normalized := normalizeProjectOrderSerial(serialNumber) if len(files) == 0 { return nil, errors.New("请选择要上传的现场图片") } cfg := config.GetAppConfig().OSS maxFiles := cfg.MaxFiles if maxFiles <= 0 { maxFiles = 18 } maxFileSize := int64(cfg.MaxFileSizeMB) if maxFileSize <= 0 { maxFileSize = 5 } maxFileSize *= 1024 * 1024 var order models.ProjectOrder if err := database.DB.Where("serial_number = ?", normalized).First(&order).Error; err != nil { return nil, fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } if order.WorkOrderStatus != ProjectOrderStatusCreated && order.WorkOrderStatus != ProjectOrderStatusPendingCompletion { return nil, errors.New("当前工单状态不可上传现场图片") } existing := parseSiteImages(order.SiteImagesJSON) if len(existing)+len(files) > maxFiles { return nil, fmt.Errorf("现场图片最多上传 %d 张", maxFiles) } ossService := NewOSSService() uploadedKeys := make([]string, 0, len(files)) for _, fh := range files { if fh.Size <= 0 { return nil, fmt.Errorf("图片 %s 内容为空", fh.Filename) } if fh.Size > maxFileSize { return nil, fmt.Errorf("图片 %s 超过 %dMB 限制", fh.Filename, maxFileSize/(1024*1024)) } contentType := strings.ToLower(strings.TrimSpace(fh.Header.Get("Content-Type"))) if !allowedSiteImageContentTypes[contentType] { return nil, fmt.Errorf("图片 %s 格式不支持", fh.Filename) } file, err := fh.Open() if err != nil { return nil, fmt.Errorf("读取图片 %s 失败: %w", fh.Filename, err) } objectKey := buildSiteImageObjectKey(cfg.Prefix, normalized, fh.Filename) err = ossService.UploadObject(objectKey, file, contentType) _ = file.Close() if err != nil { return nil, err } uploadedKeys = append(uploadedKeys, objectKey) } merged := append(existing, uploadedKeys...) order.SiteImagesJSON = encodeSiteImages(merged) if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("保存现场图片失败: %w", err) } return siteImageAccessURLs(merged), nil } // EngineerComplete 项目完成提交 func (s *ProjectOrdersService) EngineerComplete(serialNumber string, dto models.ProjectEngineerCompleteDTO) (*models.ProjectOrderPublicView, error) { normalized := normalizeProjectOrderSerial(serialNumber) if !checkConfirmRateLimit(normalized) { return nil, errors.New("操作过于频繁,请稍后再试") } var order models.ProjectOrder result := database.DB.Where("serial_number = ?", normalized).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } if order.WorkOrderStatus == ProjectOrderStatusClosed { return nil, errors.New("工单已完成") } if err := validateSignature(dto.EngineerSignature); err != nil { return nil, err } if strings.TrimSpace(dto.CompletionNote) != "" { order.CompletionNote = strings.TrimSpace(dto.CompletionNote) } if strings.TrimSpace(order.CompletionNote) == "" { return nil, errors.New("请填写完成说明") } if len(parseSiteImages(order.SiteImagesJSON)) == 0 { return nil, errors.New("请至少上传 1 张现场图片") } now := time.Now() order.WorkOrderStatus = ProjectOrderStatusClosed order.CompletedAt = &now order.EngineerSignature = strings.TrimSpace(dto.EngineerSignature) if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("提交完成失败: %w", err) } return s.PublicQuery(normalized) } // Reassign 重新分配工单负责人(仅管理员) func (s *ProjectOrdersService) Reassign(serialNumber string, technicianID uint) (*models.ProjectOrder, error) { var order models.ProjectOrder result := database.DB.Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } if order.WorkOrderStatus == ProjectOrderStatusClosed { return nil, errors.New("工单已关闭,不可重新分配") } var technician models.User if err := database.DB.First(&technician, technicianID).Error; err != nil { return nil, errors.New("指定的工单负责人不存在") } if !models.IsAssignableWorkOrderRole(technician.Role) { return nil, errors.New("指定的用户不是可派单人员") } order.TechnicianID = &technicianID if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("重新分配工单负责人失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // ForceClose 管理员强制关闭工单 func (s *ProjectOrdersService) ForceClose(serialNumber string) (*models.ProjectOrder, error) { var order models.ProjectOrder result := database.DB.Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order) if result.Error != nil { return nil, fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } if order.WorkOrderStatus == ProjectOrderStatusClosed { return nil, errors.New("工单已关闭") } now := time.Now() order.WorkOrderStatus = ProjectOrderStatusClosed order.CompletedAt = &now if err := database.DB.Save(&order).Error; err != nil { return nil, fmt.Errorf("强制关闭工单失败: %w", err) } _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) return &order, nil } // Delete 物理删除项目工单(仅管理员) func (s *ProjectOrdersService) Delete(serialNumber string) error { var order models.ProjectOrder result := database.DB.Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order) if result.Error != nil { return fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } if err := database.DB.Delete(&order).Error; err != nil { return fmt.Errorf("删除项目工单失败: %w", err) } return nil } // GenerateQRCode 生成项目工单二维码 func (s *ProjectOrdersService) GenerateQRCode( serialNumber string, baseUrl string, requestHost string, protocol string, ) (string, string, error) { var order models.ProjectOrder result := database.DB.Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order) if result.Error != nil { return "", "", fmt.Errorf("查询项目工单失败: %w", errors.New("序列号不存在")) } // 项目码二维码直接指向 /project-orders/{serialNumber} if baseUrl == "" { baseUrl = fmt.Sprintf("%s://%s/project-orders/%s", protocol, requestHost, order.SerialNumber) } filePath := fmt.Sprintf("temp_qr_%s.png", uuid.New().String()) writer, err := standard.New(filePath, standard.WithQRWidth(6)) if err != nil { return "", "", fmt.Errorf("二维码写入器创建失败: %w", err) } qrc, errCode := qr.New(baseUrl) if errCode != nil { os.Remove(filePath) return "", "", fmt.Errorf("二维码创建失败: %w", errCode) } if errSave := qrc.Save(writer); errSave != nil { os.Remove(filePath) return "", "", fmt.Errorf("二维码保存失败: %w", errSave) } fileContent, errRead := os.ReadFile(filePath) if errRead != nil { os.Remove(filePath) return "", "", fmt.Errorf("二维码文件读取失败: %w", errRead) } os.Remove(filePath) qrCodeBase64 := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(fileContent)) return qrCodeBase64, baseUrl, nil }