Add aftersales work order feature
- AftersalesOrder model with state machine (created/pending_confirmation/closed/rejected)
- Public scan-to-confirm flow with phone last-4 verification and rate limiting
- Technician role and middleware for ownership-scoped operations
- QR code generation pointing to /aftersales/{serialNumber}
- Admin overrides: reassign, force-close, delete
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,392 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AftersalesController 售后工单控制器
|
||||||
|
type AftersalesController struct {
|
||||||
|
aftersalesService services.AftersalesService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAftersalesController 创建售后工单控制器实例
|
||||||
|
func NewAftersalesController() *AftersalesController {
|
||||||
|
return &AftersalesController{
|
||||||
|
aftersalesService: services.AftersalesService{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建售后工单
|
||||||
|
// @Summary 创建售后工单
|
||||||
|
// @Description 创建一个新的售后工单并分配编号
|
||||||
|
// @Tags 售后工单
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param data body models.CreateAftersalesOrderDTO true "工单数据"
|
||||||
|
// @Success 200 {object} models.DataResponse
|
||||||
|
// @Failure 400 {object} models.ErrorResponse
|
||||||
|
// @Failure 401 {object} models.ErrorResponse
|
||||||
|
// @Failure 500 {object} models.ErrorResponse
|
||||||
|
// @Router /aftersales [post]
|
||||||
|
func (c *AftersalesController) Create(ctx *gin.Context) {
|
||||||
|
userModel, ok := GetCurrentUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var dto models.CreateAftersalesOrderDTO
|
||||||
|
if !BindJSON(ctx, &dto) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := c.aftersalesService.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 /aftersales [get]
|
||||||
|
func (c *AftersalesController) 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.aftersalesService.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 /aftersales/{serialNumber} [get]
|
||||||
|
func (c *AftersalesController) FindOne(ctx *gin.Context) {
|
||||||
|
serialNumber := ctx.Param("serialNumber")
|
||||||
|
|
||||||
|
order, err := c.aftersalesService.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.UpdateAftersalesOrderDTO true "更新数据"
|
||||||
|
// @Success 200 {object} models.DataResponse
|
||||||
|
// @Failure 400 {object} models.ErrorResponse
|
||||||
|
// @Failure 401 {object} models.ErrorResponse
|
||||||
|
// @Failure 403 {object} models.ErrorResponse
|
||||||
|
// @Router /aftersales/{serialNumber} [patch]
|
||||||
|
func (c *AftersalesController) Update(ctx *gin.Context) {
|
||||||
|
userModel, ok := GetCurrentUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serialNumber := ctx.Param("serialNumber")
|
||||||
|
var dto models.UpdateAftersalesOrderDTO
|
||||||
|
if !BindJSON(ctx, &dto) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := c.aftersalesService.Update(serialNumber, dto, userModel)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(ctx, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessResponse(ctx, "工单更新成功", gin.H{
|
||||||
|
"order": order,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitForConfirmation 技术员提交客户确认
|
||||||
|
// @Summary 提交客户确认
|
||||||
|
// @Description 技术员填写处理结果后提交,工单进入"待客户确认"状态
|
||||||
|
// @Tags 售后工单
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param serialNumber path string true "工单号"
|
||||||
|
// @Param data body models.SubmitForConfirmationDTO true "处理结果"
|
||||||
|
// @Success 200 {object} models.DataResponse
|
||||||
|
// @Failure 400 {object} models.ErrorResponse
|
||||||
|
// @Failure 401 {object} models.ErrorResponse
|
||||||
|
// @Router /aftersales/{serialNumber}/submit [post]
|
||||||
|
func (c *AftersalesController) SubmitForConfirmation(ctx *gin.Context) {
|
||||||
|
userModel, ok := GetCurrentUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serialNumber := ctx.Param("serialNumber")
|
||||||
|
var dto models.SubmitForConfirmationDTO
|
||||||
|
if !BindJSON(ctx, &dto) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := c.aftersalesService.SubmitForConfirmation(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 /aftersales/{serialNumber}/qrcode [post]
|
||||||
|
func (c *AftersalesController) 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.aftersalesService.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.ReassignAftersalesDTO true "新技术员 ID"
|
||||||
|
// @Success 200 {object} models.DataResponse
|
||||||
|
// @Failure 400 {object} models.ErrorResponse
|
||||||
|
// @Failure 401 {object} models.ErrorResponse
|
||||||
|
// @Router /aftersales/{serialNumber}/reassign [post]
|
||||||
|
func (c *AftersalesController) Reassign(ctx *gin.Context) {
|
||||||
|
serialNumber := ctx.Param("serialNumber")
|
||||||
|
var dto models.ReassignAftersalesDTO
|
||||||
|
if !BindJSON(ctx, &dto) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := c.aftersalesService.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 /aftersales/{serialNumber}/force-close [post]
|
||||||
|
func (c *AftersalesController) ForceClose(ctx *gin.Context) {
|
||||||
|
serialNumber := ctx.Param("serialNumber")
|
||||||
|
|
||||||
|
order, err := c.aftersalesService.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 /aftersales/{serialNumber} [delete]
|
||||||
|
func (c *AftersalesController) Delete(ctx *gin.Context) {
|
||||||
|
serialNumber := ctx.Param("serialNumber")
|
||||||
|
|
||||||
|
if err := c.aftersalesService.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 /aftersales/{serialNumber}/query [get]
|
||||||
|
func (c *AftersalesController) PublicQuery(ctx *gin.Context) {
|
||||||
|
serialNumber := ctx.Param("serialNumber")
|
||||||
|
|
||||||
|
view, err := c.aftersalesService.PublicQuery(serialNumber)
|
||||||
|
if err != nil {
|
||||||
|
ErrorResponse(ctx, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SuccessResponse(ctx, "查询成功", gin.H{
|
||||||
|
"order": view,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerConfirm 客户授权/未授权确认
|
||||||
|
// @Summary 客户授权确认
|
||||||
|
// @Description 客户输入手机号后四位后选择已授权或未授权
|
||||||
|
// @Tags 售后工单查询
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param serialNumber path string true "工单号"
|
||||||
|
// @Param data body models.CustomerConfirmDTO true "确认数据"
|
||||||
|
// @Success 200 {object} models.DataResponse
|
||||||
|
// @Failure 400 {object} models.ErrorResponse
|
||||||
|
// @Failure 401 {object} models.ErrorResponse
|
||||||
|
// @Failure 429 {object} models.ErrorResponse
|
||||||
|
// @Router /aftersales/{serialNumber}/confirm [post]
|
||||||
|
func (c *AftersalesController) CustomerConfirm(ctx *gin.Context) {
|
||||||
|
serialNumber := ctx.Param("serialNumber")
|
||||||
|
|
||||||
|
var dto models.CustomerConfirmDTO
|
||||||
|
if !BindJSON(ctx, &dto) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err := c.aftersalesService.CustomerConfirm(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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -114,6 +114,7 @@ func AutoMigrate() {
|
|||||||
&models.Company{},
|
&models.Company{},
|
||||||
&models.Serial{},
|
&models.Serial{},
|
||||||
&models.EmployeeSerial{},
|
&models.EmployeeSerial{},
|
||||||
|
&models.AftersalesOrder{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Fatal("数据库迁移失败", logger.Err(err))
|
logger.Fatal("数据库迁移失败", logger.Err(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,3 +110,26 @@ func AdminMiddleware() gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TechnicianMiddleware 技术员权限中间件(放行 admin 和 technician)
|
||||||
|
func TechnicianMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
user, exists := c.Get("user")
|
||||||
|
if !exists {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"message": "未认证",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userModel := user.(models.User)
|
||||||
|
if userModel.Role != "admin" && userModel.Role != "technician" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||||
|
"message": "无权限访问此资源",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -209,3 +209,88 @@ type UpdateEmployeeSerialDTO struct {
|
|||||||
EmployeeName string `json:"employeeName,omitempty" validate:"omitempty"`
|
EmployeeName string `json:"employeeName,omitempty" validate:"omitempty"`
|
||||||
IsActive *bool `json:"isActive,omitempty"`
|
IsActive *bool `json:"isActive,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AftersalesOrder 售后工单模型
|
||||||
|
type AftersalesOrder 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"`
|
||||||
|
ServiceType string `gorm:"size:32" json:"serviceType"`
|
||||||
|
IssueDescription string `gorm:"type:text" json:"issueDescription"`
|
||||||
|
ResolutionNote string `gorm:"type:text" json:"resolutionNote"`
|
||||||
|
|
||||||
|
WorkOrderStatus string `gorm:"size:32;default:'created'" json:"workOrderStatus"`
|
||||||
|
AuthorizationStatus string `gorm:"size:32;default:'pending'" json:"authorizationStatus"`
|
||||||
|
|
||||||
|
TechnicianID *uint `json:"technicianId"`
|
||||||
|
CreatedBy *uint `json:"createdBy"`
|
||||||
|
ScannedAt *time.Time `json:"scannedAt"`
|
||||||
|
ConfirmedAt *time.Time `json:"confirmedAt"`
|
||||||
|
RejectCount int `gorm:"default:0" json:"rejectCount"`
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Company *Company `gorm:"foreignKey:CompanyName;references:CompanyName" json:"company,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAftersalesOrderDTO 创建售后工单请求数据
|
||||||
|
type CreateAftersalesOrderDTO 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"`
|
||||||
|
ServiceType string `json:"serviceType" validate:"required,oneof=software hardware other"`
|
||||||
|
IssueDescription string `json:"issueDescription" validate:"required"`
|
||||||
|
TechnicianID *uint `json:"technicianId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAftersalesOrderDTO 更新售后工单请求数据
|
||||||
|
type UpdateAftersalesOrderDTO struct {
|
||||||
|
CompanyAddress string `json:"companyAddress,omitempty"`
|
||||||
|
ContactName string `json:"contactName,omitempty"`
|
||||||
|
ContactPhone string `json:"contactPhone,omitempty" validate:"omitempty,len=11"`
|
||||||
|
ServiceType string `json:"serviceType,omitempty" validate:"omitempty,oneof=software hardware other"`
|
||||||
|
IssueDescription string `json:"issueDescription,omitempty"`
|
||||||
|
ResolutionNote string `json:"resolutionNote,omitempty"`
|
||||||
|
TechnicianID *uint `json:"technicianId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitForConfirmationDTO 提交客户确认请求
|
||||||
|
type SubmitForConfirmationDTO struct {
|
||||||
|
ResolutionNote string `json:"resolutionNote" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerConfirmDTO 客户确认请求
|
||||||
|
type CustomerConfirmDTO struct {
|
||||||
|
PhoneLast4 string `json:"phoneLast4" validate:"required,len=4,numeric"`
|
||||||
|
Action string `json:"action" validate:"required,oneof=authorize reject"`
|
||||||
|
RejectReason string `json:"rejectReason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReassignAftersalesDTO 重新分配技术员请求
|
||||||
|
type ReassignAftersalesDTO struct {
|
||||||
|
TechnicianID uint `json:"technicianId" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AftersalesPublicView 公开查询返回视图(脱敏)
|
||||||
|
type AftersalesPublicView struct {
|
||||||
|
SerialNumber string `json:"serialNumber"`
|
||||||
|
CompanyName string `json:"companyName"`
|
||||||
|
CompanyAddress string `json:"companyAddress"`
|
||||||
|
ContactName string `json:"contactName"`
|
||||||
|
ServiceType string `json:"serviceType"`
|
||||||
|
IssueDescription string `json:"issueDescription"`
|
||||||
|
ResolutionNote string `json:"resolutionNote"`
|
||||||
|
WorkOrderStatus string `json:"workOrderStatus"`
|
||||||
|
AuthorizationStatus string `json:"authorizationStatus"`
|
||||||
|
TechnicianName string `json:"technicianName"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
ConfirmedAt *time.Time `json:"confirmedAt"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,4 +73,26 @@ func SetupAPIRoutes(r *gin.RouterGroup) {
|
|||||||
employeeSerialsRoutes.POST("/:serialNumber/revoke", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Revoke)
|
employeeSerialsRoutes.POST("/:serialNumber/revoke", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Revoke)
|
||||||
employeeSerialsRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Delete)
|
employeeSerialsRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Delete)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 售后工单路由
|
||||||
|
aftersalesController := controllers.NewAftersalesController()
|
||||||
|
aftersalesRoutes := r.Group("/aftersales")
|
||||||
|
{
|
||||||
|
// 公开(无需登录)
|
||||||
|
aftersalesRoutes.GET("/:serialNumber/query", aftersalesController.PublicQuery)
|
||||||
|
aftersalesRoutes.POST("/:serialNumber/confirm", aftersalesController.CustomerConfirm)
|
||||||
|
|
||||||
|
// 技术员 + 管理员
|
||||||
|
aftersalesRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Create)
|
||||||
|
aftersalesRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindAll)
|
||||||
|
aftersalesRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindOne)
|
||||||
|
aftersalesRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Update)
|
||||||
|
aftersalesRoutes.POST("/:serialNumber/qrcode", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.GenerateQRCode)
|
||||||
|
aftersalesRoutes.POST("/:serialNumber/submit", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.SubmitForConfirmation)
|
||||||
|
|
||||||
|
// 仅管理员
|
||||||
|
aftersalesRoutes.POST("/:serialNumber/reassign", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.Reassign)
|
||||||
|
aftersalesRoutes.POST("/:serialNumber/force-close", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.ForceClose)
|
||||||
|
aftersalesRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.Delete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"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/database"
|
||||||
|
"git.beifan.cn/trace-system/backend-go/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AftersalesService 售后工单服务
|
||||||
|
type AftersalesService struct{}
|
||||||
|
|
||||||
|
// 工单状态常量
|
||||||
|
const (
|
||||||
|
WorkOrderStatusCreated = "created"
|
||||||
|
WorkOrderStatusPendingConfirmation = "pending_confirmation"
|
||||||
|
WorkOrderStatusClosed = "closed"
|
||||||
|
WorkOrderStatusRejected = "rejected"
|
||||||
|
|
||||||
|
AuthorizationStatusPending = "pending"
|
||||||
|
AuthorizationStatusAuthorized = "authorized"
|
||||||
|
AuthorizationStatusUnauthorized = "unauthorized"
|
||||||
|
|
||||||
|
aftersalesSerialPrefix = "zjbf-sh-"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 客户确认接口频率限制:每分钟同一工单最多 5 次尝试
|
||||||
|
var confirmRateLimiter = struct {
|
||||||
|
sync.Mutex
|
||||||
|
attempts map[string][]time.Time
|
||||||
|
}{attempts: map[string][]time.Time{}}
|
||||||
|
|
||||||
|
func checkConfirmRateLimit(serialNumber string) bool {
|
||||||
|
confirmRateLimiter.Lock()
|
||||||
|
defer confirmRateLimiter.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
windowStart := now.Add(-time.Minute)
|
||||||
|
|
||||||
|
attempts := confirmRateLimiter.attempts[serialNumber]
|
||||||
|
filtered := attempts[:0]
|
||||||
|
for _, t := range attempts {
|
||||||
|
if t.After(windowStart) {
|
||||||
|
filtered = append(filtered, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) >= 5 {
|
||||||
|
confirmRateLimiter.attempts[serialNumber] = filtered
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
filtered = append(filtered, now)
|
||||||
|
confirmRateLimiter.attempts[serialNumber] = filtered
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAftersalesSerial(sn string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(sn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateUniqueSerial 生成唯一的售后工单序列号
|
||||||
|
func (s *AftersalesService) generateUniqueSerial() (string, error) {
|
||||||
|
for attempt := 0; attempt < 10; attempt++ {
|
||||||
|
randomBytes := make([]byte, 3)
|
||||||
|
if _, err := rand.Read(randomBytes); err != nil {
|
||||||
|
return "", fmt.Errorf("生成随机数失败: %w", err)
|
||||||
|
}
|
||||||
|
randomPart := strings.ToLower(hex.EncodeToString(randomBytes))[:6]
|
||||||
|
candidate := aftersalesSerialPrefix + randomPart
|
||||||
|
|
||||||
|
var existing models.AftersalesOrder
|
||||||
|
result := database.DB.Where("serial_number = ?", candidate).First(&existing)
|
||||||
|
if result.Error != nil {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("生成唯一序列号失败,请重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建售后工单
|
||||||
|
func (s *AftersalesService) Create(dto models.CreateAftersalesOrderDTO, userId uint) (*models.AftersalesOrder, error) {
|
||||||
|
// 确保公司存在
|
||||||
|
var company models.Company
|
||||||
|
result := database.DB.Where("company_name = ?", dto.CompanyName).First(&company)
|
||||||
|
if result.Error != nil {
|
||||||
|
company = models.Company{
|
||||||
|
CompanyName: dto.CompanyName,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&company).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("创建公司失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serialNumber, err := s.generateUniqueSerial()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
technicianID := dto.TechnicianID
|
||||||
|
if technicianID == nil {
|
||||||
|
uid := userId
|
||||||
|
technicianID = &uid
|
||||||
|
}
|
||||||
|
|
||||||
|
order := models.AftersalesOrder{
|
||||||
|
SerialNumber: serialNumber,
|
||||||
|
CompanyName: dto.CompanyName,
|
||||||
|
CompanyAddress: dto.CompanyAddress,
|
||||||
|
ContactName: dto.ContactName,
|
||||||
|
ContactPhone: dto.ContactPhone,
|
||||||
|
ServiceType: dto.ServiceType,
|
||||||
|
IssueDescription: dto.IssueDescription,
|
||||||
|
TechnicianID: technicianID,
|
||||||
|
CreatedBy: &userId,
|
||||||
|
WorkOrderStatus: WorkOrderStatusCreated,
|
||||||
|
AuthorizationStatus: AuthorizationStatusPending,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 *AftersalesService) FindAll(
|
||||||
|
page int,
|
||||||
|
limit int,
|
||||||
|
search string,
|
||||||
|
workOrderStatus string,
|
||||||
|
serviceType string,
|
||||||
|
technicianID *uint,
|
||||||
|
) ([]models.AftersalesOrder, int, int, error) {
|
||||||
|
var orders []models.AftersalesOrder
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
db := database.DB.Model(&models.AftersalesOrder{}).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("service_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 *AftersalesService) FindOne(serialNumber string) (*models.AftersalesOrder, error) {
|
||||||
|
var order models.AftersalesOrder
|
||||||
|
result := database.DB.Preload("Technician").Preload("Creator").
|
||||||
|
Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||||
|
}
|
||||||
|
return &order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新售后工单基础信息
|
||||||
|
func (s *AftersalesService) Update(
|
||||||
|
serialNumber string,
|
||||||
|
dto models.UpdateAftersalesOrderDTO,
|
||||||
|
currentUser models.User,
|
||||||
|
) (*models.AftersalesOrder, error) {
|
||||||
|
var order models.AftersalesOrder
|
||||||
|
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.WorkOrderStatus == WorkOrderStatusClosed {
|
||||||
|
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.ServiceType != "" {
|
||||||
|
order.ServiceType = dto.ServiceType
|
||||||
|
}
|
||||||
|
if dto.IssueDescription != "" {
|
||||||
|
order.IssueDescription = dto.IssueDescription
|
||||||
|
}
|
||||||
|
if dto.ResolutionNote != "" {
|
||||||
|
order.ResolutionNote = dto.ResolutionNote
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitForConfirmation 技术员提交客户确认
|
||||||
|
func (s *AftersalesService) SubmitForConfirmation(
|
||||||
|
serialNumber string,
|
||||||
|
dto models.SubmitForConfirmationDTO,
|
||||||
|
currentUser models.User,
|
||||||
|
) (*models.AftersalesOrder, error) {
|
||||||
|
var order models.AftersalesOrder
|
||||||
|
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(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 != WorkOrderStatusCreated && order.WorkOrderStatus != WorkOrderStatusRejected {
|
||||||
|
return nil, errors.New("当前工单状态不可提交确认")
|
||||||
|
}
|
||||||
|
|
||||||
|
order.ResolutionNote = dto.ResolutionNote
|
||||||
|
order.WorkOrderStatus = WorkOrderStatusPendingConfirmation
|
||||||
|
|
||||||
|
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 *AftersalesService) PublicQuery(serialNumber string) (*models.AftersalesPublicView, error) {
|
||||||
|
var order models.AftersalesOrder
|
||||||
|
result := database.DB.Preload("Technician").
|
||||||
|
Where("serial_number = ?", normalizeAftersalesSerial(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.AftersalesPublicView{
|
||||||
|
SerialNumber: order.SerialNumber,
|
||||||
|
CompanyName: order.CompanyName,
|
||||||
|
CompanyAddress: order.CompanyAddress,
|
||||||
|
ContactName: order.ContactName,
|
||||||
|
ServiceType: order.ServiceType,
|
||||||
|
IssueDescription: order.IssueDescription,
|
||||||
|
ResolutionNote: order.ResolutionNote,
|
||||||
|
WorkOrderStatus: order.WorkOrderStatus,
|
||||||
|
AuthorizationStatus: order.AuthorizationStatus,
|
||||||
|
CreatedAt: order.CreatedAt,
|
||||||
|
ConfirmedAt: order.ConfirmedAt,
|
||||||
|
}
|
||||||
|
if order.Technician != nil {
|
||||||
|
view.TechnicianName = order.Technician.Name
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerConfirm 客户授权/未授权确认
|
||||||
|
func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.CustomerConfirmDTO) (*models.AftersalesPublicView, error) {
|
||||||
|
normalized := normalizeAftersalesSerial(serialNumber)
|
||||||
|
|
||||||
|
if !checkConfirmRateLimit(normalized) {
|
||||||
|
return nil, errors.New("操作过于频繁,请稍后再试")
|
||||||
|
}
|
||||||
|
|
||||||
|
var order models.AftersalesOrder
|
||||||
|
result := database.DB.Where("serial_number = ?", normalized).First(&order)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.WorkOrderStatus != WorkOrderStatusPendingConfirmation {
|
||||||
|
return nil, errors.New("当前工单状态不可确认")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(order.ContactPhone) < 4 || order.ContactPhone[len(order.ContactPhone)-4:] != dto.PhoneLast4 {
|
||||||
|
return nil, errors.New("手机号校验失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
switch dto.Action {
|
||||||
|
case "authorize":
|
||||||
|
order.WorkOrderStatus = WorkOrderStatusClosed
|
||||||
|
order.AuthorizationStatus = AuthorizationStatusAuthorized
|
||||||
|
order.ConfirmedAt = &now
|
||||||
|
case "reject":
|
||||||
|
order.WorkOrderStatus = WorkOrderStatusRejected
|
||||||
|
order.AuthorizationStatus = AuthorizationStatusUnauthorized
|
||||||
|
order.RejectCount++
|
||||||
|
if dto.RejectReason != "" {
|
||||||
|
order.ResolutionNote = order.ResolutionNote + "\n\n[客户退回] " + dto.RejectReason
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, errors.New("无效的操作")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.DB.Save(&order).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("提交确认失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.PublicQuery(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reassign 重新分配技术员(仅管理员)
|
||||||
|
func (s *AftersalesService) Reassign(serialNumber string, technicianID uint) (*models.AftersalesOrder, error) {
|
||||||
|
var order models.AftersalesOrder
|
||||||
|
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.WorkOrderStatus == WorkOrderStatusClosed {
|
||||||
|
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 *AftersalesService) ForceClose(serialNumber string) (*models.AftersalesOrder, error) {
|
||||||
|
var order models.AftersalesOrder
|
||||||
|
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if order.WorkOrderStatus == WorkOrderStatusClosed {
|
||||||
|
return nil, errors.New("工单已关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
order.WorkOrderStatus = WorkOrderStatusClosed
|
||||||
|
order.AuthorizationStatus = AuthorizationStatusAuthorized
|
||||||
|
order.ConfirmedAt = &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 *AftersalesService) Delete(serialNumber string) error {
|
||||||
|
var order models.AftersalesOrder
|
||||||
|
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(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 *AftersalesService) GenerateQRCode(
|
||||||
|
serialNumber string,
|
||||||
|
baseUrl string,
|
||||||
|
requestHost string,
|
||||||
|
protocol string,
|
||||||
|
) (string, string, error) {
|
||||||
|
var order models.AftersalesOrder
|
||||||
|
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
|
||||||
|
if result.Error != nil {
|
||||||
|
return "", "", fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 售后码二维码直接指向 /aftersales/{serialNumber}
|
||||||
|
if baseUrl == "" {
|
||||||
|
baseUrl = fmt.Sprintf("%s://%s/aftersales/%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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user