Files
backend-go/services/project_orders_service.go
2026-06-06 13:50:56 +08:00

510 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-YYMMDDNNYY=年份后两位,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
}