feat: add project work orders

This commit is contained in:
Frudrax Cheng
2026-06-04 10:25:57 +08:00
parent bd40e4afdc
commit 93506639e3
5 changed files with 1045 additions and 0 deletions
+509
View File
@@ -0,0 +1,509 @@
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 = 6
}
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 technician.Role != "admin" && technician.Role != "technician" {
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
}