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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user