655 lines
20 KiB
Go
655 lines
20 KiB
Go
package services
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"mime/multipart"
|
||
"os"
|
||
"path/filepath"
|
||
"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/config"
|
||
"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-"
|
||
|
||
// 签名 base64 解码后的大小限制
|
||
signatureMinBytes = 200
|
||
signatureMaxBytes = 500 * 1024
|
||
)
|
||
|
||
var allowedSiteImageContentTypes = map[string]bool{
|
||
"image/jpeg": true,
|
||
"image/png": true,
|
||
"image/webp": true,
|
||
"image/heic": true,
|
||
"image/heif": true,
|
||
}
|
||
|
||
// validateSignature 校验客户签名 dataURL 是否合法
|
||
// 接受 data:image/png;base64,... 或 data:image/jpeg;base64,... 形式
|
||
func validateSignature(s string) error {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return errors.New("签名不能为空")
|
||
}
|
||
var payload string
|
||
switch {
|
||
case strings.HasPrefix(s, "data:image/png;base64,"):
|
||
payload = strings.TrimPrefix(s, "data:image/png;base64,")
|
||
case strings.HasPrefix(s, "data:image/jpeg;base64,"):
|
||
payload = strings.TrimPrefix(s, "data:image/jpeg;base64,")
|
||
default:
|
||
return errors.New("签名格式不合法")
|
||
}
|
||
decoded, err := base64.StdEncoding.DecodeString(payload)
|
||
if err != nil {
|
||
return errors.New("签名内容解码失败")
|
||
}
|
||
if len(decoded) < signatureMinBytes {
|
||
return errors.New("签名内容过短,请重新签名")
|
||
}
|
||
if len(decoded) > signatureMaxBytes {
|
||
return errors.New("签名内容过大,请精简后重试")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 客户确认接口频率限制:每分钟同一工单最多 5 次尝试
|
||
var confirmRateLimiter = struct {
|
||
sync.Mutex
|
||
attempts map[string][]time.Time
|
||
}{attempts: map[string][]time.Time{}}
|
||
|
||
// ResetConfirmRateLimit 清空确认限流器(仅用于测试)
|
||
func ResetConfirmRateLimit() {
|
||
confirmRateLimiter.Lock()
|
||
defer confirmRateLimiter.Unlock()
|
||
confirmRateLimiter.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))
|
||
}
|
||
|
||
func parseSiteImages(raw string) []string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
var images []string
|
||
if err := json.Unmarshal([]byte(raw), &images); err != nil {
|
||
return nil
|
||
}
|
||
return images
|
||
}
|
||
|
||
func encodeSiteImages(images []string) string {
|
||
if len(images) == 0 {
|
||
return ""
|
||
}
|
||
data, err := json.Marshal(images)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return string(data)
|
||
}
|
||
|
||
func hydrateAftersalesOrder(order *models.AftersalesOrder) {
|
||
if order == nil {
|
||
return
|
||
}
|
||
order.SiteImages = siteImageAccessURLs(parseSiteImages(order.SiteImagesJSON))
|
||
}
|
||
|
||
func siteImageAccessURLs(images []string) []string {
|
||
if len(images) == 0 {
|
||
return nil
|
||
}
|
||
ossService := NewOSSService()
|
||
urls := make([]string, 0, len(images))
|
||
for _, image := range images {
|
||
urls = append(urls, ossService.AccessURL(image))
|
||
}
|
||
return urls
|
||
}
|
||
|
||
// generateUniqueSerial 生成唯一的售后工单序列号
|
||
// 格式:zjbf-sh-YYMMDDNN,YY=年份后两位,MM=月份,DD=日期,NN=当天第几单(至少 2 位,溢出自然加宽)
|
||
func (s *AftersalesService) 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.AftersalesOrder{}).
|
||
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", aftersalesSerialPrefix, yy, mm, dd, seq)
|
||
|
||
var existing models.AftersalesOrder
|
||
result := database.DB.Unscoped().Where("serial_number = ?", candidate).First(&existing)
|
||
if result.Error != nil {
|
||
return candidate, nil
|
||
}
|
||
seq++
|
||
}
|
||
return "", errors.New("生成唯一序列号失败,请重试")
|
||
}
|
||
|
||
// Create 创建售后工单
|
||
func (s *AftersalesService) Create(dto models.CreateAftersalesOrderDTO, userId uint) (*models.AftersalesOrder, error) {
|
||
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("序列号不存在"))
|
||
}
|
||
hydrateAftersalesOrder(&order)
|
||
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,
|
||
Signature: order.Signature,
|
||
ResponsibleSignature: order.ResponsibleSignature,
|
||
SiteImages: siteImageAccessURLs(parseSiteImages(order.SiteImagesJSON)),
|
||
}
|
||
if order.Technician != nil {
|
||
view.TechnicianName = order.Technician.Name
|
||
}
|
||
return view, nil
|
||
}
|
||
|
||
// UploadSiteImages 上传客户确认现场图片到 OSS 并保存 object key。
|
||
func (s *AftersalesService) UploadSiteImages(serialNumber string, files []*multipart.FileHeader) ([]string, error) {
|
||
normalized := normalizeAftersalesSerial(serialNumber)
|
||
if len(files) == 0 {
|
||
return nil, errors.New("请选择要上传的现场图片")
|
||
}
|
||
|
||
cfg := config.GetAppConfig().OSS
|
||
maxFiles := cfg.MaxFiles
|
||
if maxFiles <= 0 {
|
||
maxFiles = 18
|
||
}
|
||
maxFileSize := int64(cfg.MaxFileSizeMB)
|
||
if maxFileSize <= 0 {
|
||
maxFileSize = 5
|
||
}
|
||
maxFileSize *= 1024 * 1024
|
||
|
||
var order models.AftersalesOrder
|
||
if err := database.DB.Where("serial_number = ?", normalized).First(&order).Error; err != nil {
|
||
return nil, fmt.Errorf("查询售后工单失败: %w", errors.New("序列号不存在"))
|
||
}
|
||
if order.WorkOrderStatus != WorkOrderStatusPendingConfirmation {
|
||
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
|
||
}
|
||
|
||
func buildSiteImageObjectKey(prefix string, serialNumber string, filename string) string {
|
||
prefix = strings.Trim(strings.TrimSpace(prefix), "/")
|
||
ext := strings.ToLower(filepath.Ext(filename))
|
||
if ext == "" {
|
||
ext = ".jpg"
|
||
}
|
||
name := uuid.NewString() + ext
|
||
if prefix == "" {
|
||
return serialNumber + "/" + name
|
||
}
|
||
return prefix + "/" + serialNumber + "/" + name
|
||
}
|
||
|
||
// 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("当前工单状态不可确认")
|
||
}
|
||
|
||
now := time.Now()
|
||
switch dto.Action {
|
||
case "authorize":
|
||
if err := validateSignature(dto.Signature); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := validateSignature(dto.ResponsibleSignature); err != nil {
|
||
return nil, err
|
||
}
|
||
order.WorkOrderStatus = WorkOrderStatusClosed
|
||
order.AuthorizationStatus = AuthorizationStatusAuthorized
|
||
order.ConfirmedAt = &now
|
||
order.Signature = strings.TrimSpace(dto.Signature)
|
||
order.ResponsibleSignature = strings.TrimSpace(dto.ResponsibleSignature)
|
||
case "reject":
|
||
reason := strings.TrimSpace(dto.RejectReason)
|
||
if reason == "" {
|
||
return nil, errors.New("请填写退回原因")
|
||
}
|
||
order.WorkOrderStatus = WorkOrderStatusRejected
|
||
order.AuthorizationStatus = AuthorizationStatusUnauthorized
|
||
order.RejectCount++
|
||
order.ResolutionNote = order.ResolutionNote + "\n\n[客户退回] " + reason
|
||
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 !models.IsAssignableWorkOrderRole(technician.Role) {
|
||
return nil, errors.New("指定的用户不是可派单人员")
|
||
}
|
||
|
||
order.TechnicianID = &technicianID
|
||
if err := database.DB.Save(&order).Error; err != nil {
|
||
return nil, fmt.Errorf("重新分配工单负责人失败: %w", err)
|
||
}
|
||
|
||
_ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order)
|
||
return &order, nil
|
||
}
|
||
|
||
// ForceClose 管理员强制关闭工单
|
||
func (s *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
|
||
}
|