From 93506639e39d7b7c6df34fca3ca6fc3187976b70 Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Thu, 4 Jun 2026 10:25:57 +0800 Subject: [PATCH] feat: add project work orders --- controllers/project_orders_controller.go | 428 +++++++++++++++++++ database/database.go | 1 + models/models.go | 84 ++++ routes/routes.go | 23 + services/project_orders_service.go | 509 +++++++++++++++++++++++ 5 files changed, 1045 insertions(+) create mode 100644 controllers/project_orders_controller.go create mode 100644 services/project_orders_service.go diff --git a/controllers/project_orders_controller.go b/controllers/project_orders_controller.go new file mode 100644 index 0000000..40a72c9 --- /dev/null +++ b/controllers/project_orders_controller.go @@ -0,0 +1,428 @@ +package controllers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.beifan.cn/trace-system/backend-go/models" + "git.beifan.cn/trace-system/backend-go/services" +) + +// ProjectOrdersController 项目工单控制器 +type ProjectOrdersController struct { + projectOrdersService services.ProjectOrdersService +} + +// NewProjectOrdersController 创建项目工单控制器实例 +func NewProjectOrdersController() *ProjectOrdersController { + return &ProjectOrdersController{ + projectOrdersService: services.ProjectOrdersService{}, + } +} + +// Create 创建项目工单 +// @Summary 创建项目工单 +// @Description 创建一个新的项目工单并分配编号 +// @Tags 项目工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param data body models.CreateProjectOrderDTO true "工单数据" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /project-orders [post] +func (c *ProjectOrdersController) Create(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + var dto models.CreateProjectOrderDTO + if !BindJSON(ctx, &dto) { + return + } + + order, err := c.projectOrdersService.Create(dto, userModel.ID) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "项目工单创建成功", gin.H{ + "order": order, + }) +} + +// FindAll 获取项目工单列表 +// @Summary 获取项目工单列表 +// @Description 支持分页、搜索、按状态/服务类型/技术员筛选 +// @Tags 项目工单 +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" +// @Param limit query int false "每页数量" +// @Param search query string false "搜索关键词" +// @Param workOrderStatus query string false "工单状态" +// @Param serviceType query string false "服务类型" +// @Param technicianId query int false "技术员 ID" +// @Param mine query bool false "仅查看自己负责的工单" +// @Success 200 {object} models.PaginationResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 500 {object} models.ErrorResponse +// @Router /project-orders [get] +func (c *ProjectOrdersController) FindAll(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) + search := ctx.DefaultQuery("search", "") + workOrderStatus := ctx.DefaultQuery("workOrderStatus", "") + serviceType := ctx.DefaultQuery("serviceType", "") + + var technicianID *uint + if tidStr := ctx.Query("technicianId"); tidStr != "" { + if tid, err := strconv.ParseUint(tidStr, 10, 32); err == nil { + t := uint(tid) + technicianID = &t + } + } + // 非管理员默认只看自己的工单(除非显式指定 technicianId) + if userModel.Role != "admin" && technicianID == nil { + technicianID = &userModel.ID + } + // mine=true 强制只看自己的 + if ctx.Query("mine") == "true" { + technicianID = &userModel.ID + } + + orders, total, totalPages, err := c.projectOrdersService.FindAll(page, limit, search, workOrderStatus, serviceType, technicianID) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "获取项目工单列表成功", gin.H{ + "data": orders, + "pagination": gin.H{ + "page": page, + "limit": limit, + "total": total, + "totalPages": totalPages, + }, + }) +} + +// FindOne 获取单个项目工单详情 +// @Summary 获取项目工单详情 +// @Tags 项目工单 +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Success 200 {object} models.DataResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber} [get] +func (c *ProjectOrdersController) FindOne(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + order, err := c.projectOrdersService.FindOne(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + return + } + + SuccessResponse(ctx, "查询成功", gin.H{ + "order": order, + }) +} + +// Update 更新项目工单信息 +// @Summary 更新项目工单 +// @Tags 项目工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Param data body models.UpdateProjectOrderDTO true "更新数据" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 403 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber} [patch] +func (c *ProjectOrdersController) Update(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + serialNumber := ctx.Param("serialNumber") + var dto models.UpdateProjectOrderDTO + if !BindJSON(ctx, &dto) { + return + } + + order, err := c.projectOrdersService.Update(serialNumber, dto, userModel) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "工单更新成功", gin.H{ + "order": order, + }) +} + +// SubmitCompletion 技术员提交完成资料 +// @Summary 提交完成确认 +// @Description 技术员填写处理结果后提交,工单进入"待完成确认"状态 +// @Tags 项目工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Param data body models.SubmitProjectCompletionDTO true "处理结果" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber}/submit [post] +func (c *ProjectOrdersController) SubmitCompletion(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + serialNumber := ctx.Param("serialNumber") + var dto models.SubmitProjectCompletionDTO + if !BindJSON(ctx, &dto) { + return + } + + order, err := c.projectOrdersService.SubmitCompletion(serialNumber, dto, userModel) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "已提交完成确认", gin.H{ + "order": order, + }) +} + +// GenerateQRCode 生成项目工单二维码 +// @Summary 生成项目工单二维码 +// @Tags 项目工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Param data body models.QRCodeDTO false "二维码参数" +// @Success 200 {object} models.QRCodeResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber}/qrcode [post] +func (c *ProjectOrdersController) GenerateQRCode(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + var qrCodeData models.QRCodeDTO + if !BindJSON(ctx, &qrCodeData) { + return + } + + protocol := "http" + if ctx.Request.TLS != nil { + protocol = "https" + } + + qrCodeBase64, queryUrl, err := c.projectOrdersService.GenerateQRCode( + serialNumber, + qrCodeData.BaseUrl, + ctx.Request.Host, + protocol, + ) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "二维码生成成功", gin.H{ + "qrCodeData": qrCodeBase64, + "queryUrl": queryUrl, + }) +} + +// Reassign 重新分配技术员(仅管理员) +// @Summary 重新分配技术员 +// @Tags 项目工单 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Param data body models.ReassignProjectOrderDTO true "新技术员 ID" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber}/reassign [post] +func (c *ProjectOrdersController) Reassign(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + var dto models.ReassignProjectOrderDTO + if !BindJSON(ctx, &dto) { + return + } + + order, err := c.projectOrdersService.Reassign(serialNumber, dto.TechnicianID) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "重新分配成功", gin.H{ + "order": order, + }) +} + +// ForceClose 强制关闭工单(仅管理员) +// @Summary 强制关闭工单 +// @Tags 项目工单 +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber}/force-close [post] +func (c *ProjectOrdersController) ForceClose(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + order, err := c.projectOrdersService.ForceClose(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "工单已强制关闭", gin.H{ + "order": order, + }) +} + +// Delete 删除项目工单(仅管理员) +// @Summary 删除项目工单 +// @Tags 项目工单 +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "工单号" +// @Success 200 {object} models.BaseResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber} [delete] +func (c *ProjectOrdersController) Delete(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + if err := c.projectOrdersService.Delete(serialNumber); err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "项目工单删除成功") +} + +// PublicQuery 公开查询项目工单(无需登录,脱敏) +// @Summary 公开查询项目工单 +// @Tags 项目工单查询 +// @Produce json +// @Param serialNumber path string true "工单号" +// @Success 200 {object} models.DataResponse +// @Failure 404 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber}/query [get] +func (c *ProjectOrdersController) PublicQuery(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + view, err := c.projectOrdersService.PublicQuery(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusNotFound, err.Error()) + return + } + + SuccessResponse(ctx, "查询成功", gin.H{ + "order": view, + }) +} + +// EngineerComplete 项目完成提交 +// @Summary 工程师提交完成 +// @Description 工程师上传现场图片后签字提交,工单进入已完成状态 +// @Tags 项目工单查询 +// @Accept json +// @Produce json +// @Param serialNumber path string true "工单号" +// @Param data body models.ProjectEngineerCompleteDTO true "确认数据" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Failure 429 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber}/complete [post] +func (c *ProjectOrdersController) EngineerComplete(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + var dto models.ProjectEngineerCompleteDTO + if !BindJSON(ctx, &dto) { + return + } + + view, err := c.projectOrdersService.EngineerComplete(serialNumber, dto) + if err != nil { + // 频率限制单独返回 429 + if err.Error() == "操作过于频繁,请稍后再试" { + ErrorResponse(ctx, http.StatusTooManyRequests, err.Error()) + return + } + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "提交成功", gin.H{ + "order": view, + }) +} + +// UploadSiteImages 上传完成确认现场图片 +// @Summary 上传项目现场图片 +// @Tags 项目工单查询 +// @Accept multipart/form-data +// @Produce json +// @Param serialNumber path string true "工单号" +// @Param files formData file true "现场图片" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 404 {object} models.ErrorResponse +// @Router /project-orders/{serialNumber}/site-images [post] +func (c *ProjectOrdersController) UploadSiteImages(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + form, err := ctx.MultipartForm() + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, "请选择要上传的现场图片") + return + } + + files := form.File["files"] + if len(files) == 0 { + files = form.File["file"] + } + + images, err := c.projectOrdersService.UploadSiteImages(serialNumber, files) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "现场图片上传成功", gin.H{ + "siteImages": images, + }) +} diff --git a/database/database.go b/database/database.go index 4652aef..19cf974 100644 --- a/database/database.go +++ b/database/database.go @@ -115,6 +115,7 @@ func AutoMigrate() { &models.Serial{}, &models.EmployeeSerial{}, &models.AftersalesOrder{}, + &models.ProjectOrder{}, ); err != nil { logger.Fatal("数据库迁移失败", logger.Err(err)) } diff --git a/models/models.go b/models/models.go index 690ed63..c1b0007 100644 --- a/models/models.go +++ b/models/models.go @@ -345,3 +345,87 @@ type AftersalesPublicView struct { ResponsibleSignature string `json:"responsibleSignature,omitempty"` SiteImages []string `json:"siteImages,omitempty"` } + +// ProjectOrder 项目工单模型 +type ProjectOrder struct { + ID uint `gorm:"primaryKey" json:"id"` + SerialNumber string `gorm:"uniqueIndex;size:64" json:"serialNumber"` + CompanyName string `gorm:"index;size:255" json:"companyName"` + CompanyAddress string `gorm:"size:500" json:"companyAddress"` + ContactName string `gorm:"size:100" json:"contactName"` + ContactPhone string `gorm:"size:32" json:"contactPhone"` + ProjectType string `gorm:"size:32" json:"projectType"` + SiteDescription string `gorm:"type:text" json:"siteDescription"` + CompletionNote string `gorm:"type:text" json:"completionNote"` + WorkOrderStatus string `gorm:"size:32;default:'created'" json:"workOrderStatus"` + + TechnicianID *uint `json:"technicianId"` + CreatedBy *uint `json:"createdBy"` + ScannedAt *time.Time `json:"scannedAt"` + CompletedAt *time.Time `json:"completedAt"` + EngineerSignature string `gorm:"type:text" json:"engineerSignature,omitempty"` + SiteImagesJSON string `gorm:"type:text;column:site_images" json:"-"` + SiteImages []string `gorm:"-" json:"siteImages,omitempty"` + + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Technician *User `gorm:"foreignKey:TechnicianID" json:"technician,omitempty"` + Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"` +} + +// CreateProjectOrderDTO 创建项目工单请求数据 +type CreateProjectOrderDTO struct { + CompanyName string `json:"companyName" validate:"required"` + CompanyAddress string `json:"companyAddress" validate:"required"` + ContactName string `json:"contactName" validate:"required"` + ContactPhone string `json:"contactPhone" validate:"required,len=11"` + ProjectType string `json:"projectType" validate:"required,oneof=survey implementation maintenance other"` + SiteDescription string `json:"siteDescription" validate:"required"` + TechnicianID *uint `json:"technicianId,omitempty"` +} + +// UpdateProjectOrderDTO 更新项目工单请求数据 +type UpdateProjectOrderDTO struct { + CompanyAddress string `json:"companyAddress,omitempty"` + ContactName string `json:"contactName,omitempty"` + ContactPhone string `json:"contactPhone,omitempty" validate:"omitempty,len=11"` + ProjectType string `json:"projectType,omitempty" validate:"omitempty,oneof=survey implementation maintenance other"` + SiteDescription string `json:"siteDescription,omitempty"` + CompletionNote string `json:"completionNote,omitempty"` + TechnicianID *uint `json:"technicianId,omitempty"` +} + +// SubmitProjectCompletionDTO 提交完成资料请求 +type SubmitProjectCompletionDTO struct { + CompletionNote string `json:"completionNote" validate:"required"` +} + +// ProjectEngineerCompleteDTO 工程师完成确认请求 +type ProjectEngineerCompleteDTO struct { + EngineerSignature string `json:"engineerSignature" validate:"required"` + CompletionNote string `json:"completionNote,omitempty"` +} + +// ReassignProjectOrderDTO 重新分配工程师请求 +type ReassignProjectOrderDTO struct { + TechnicianID uint `json:"technicianId" validate:"required"` +} + +// ProjectOrderPublicView 公开查询返回视图 +type ProjectOrderPublicView struct { + SerialNumber string `json:"serialNumber"` + CompanyName string `json:"companyName"` + CompanyAddress string `json:"companyAddress"` + ContactName string `json:"contactName"` + ProjectType string `json:"projectType"` + SiteDescription string `json:"siteDescription"` + CompletionNote string `json:"completionNote"` + WorkOrderStatus string `json:"workOrderStatus"` + TechnicianName string `json:"technicianName"` + CreatedAt time.Time `json:"createdAt"` + CompletedAt *time.Time `json:"completedAt"` + EngineerSignature string `json:"engineerSignature,omitempty"` + SiteImages []string `json:"siteImages,omitempty"` +} diff --git a/routes/routes.go b/routes/routes.go index 9d38eef..2ecd39b 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -112,4 +112,27 @@ func SetupAPIRoutes(r *gin.RouterGroup) { aftersalesRoutes.POST("/:serialNumber/force-close", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.ForceClose) aftersalesRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.Delete) } + + // 项目工单路由 + projectOrdersController := controllers.NewProjectOrdersController() + projectOrdersRoutes := r.Group("/project-orders") + { + // 公开(无需登录) + projectOrdersRoutes.GET("/:serialNumber/query", projectOrdersController.PublicQuery) + projectOrdersRoutes.POST("/:serialNumber/site-images", projectOrdersController.UploadSiteImages) + projectOrdersRoutes.POST("/:serialNumber/complete", projectOrdersController.EngineerComplete) + + // 技术员 + 管理员 + projectOrdersRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.Create) + projectOrdersRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.FindAll) + projectOrdersRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.FindOne) + projectOrdersRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.Update) + projectOrdersRoutes.POST("/:serialNumber/qrcode", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.GenerateQRCode) + projectOrdersRoutes.POST("/:serialNumber/submit", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.SubmitCompletion) + + // 仅管理员 + projectOrdersRoutes.POST("/:serialNumber/reassign", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), projectOrdersController.Reassign) + projectOrdersRoutes.POST("/:serialNumber/force-close", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), projectOrdersController.ForceClose) + projectOrdersRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), projectOrdersController.Delete) + } } diff --git a/services/project_orders_service.go b/services/project_orders_service.go new file mode 100644 index 0000000..896ed14 --- /dev/null +++ b/services/project_orders_service.go @@ -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-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 = 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 +}