Add aftersales stats to dashboard and service-layer tests
- CompanyStatsOverviewDTO and GetStats() now include aftersales counts (total, pending confirmation, closed, rejected) and a recentAftersales list - aftersales_service_test.go covers YYMMNN sequence, owner-only submit, state machine, phone last-4 check, reject increment, force-close - users_service_test.go covers duplicate username, self-demotion guard, last-admin guard, password reset, assignable filter Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,8 +68,10 @@ backend-go/
|
|||||||
│ ├── companies_service.go # Company CRUD
|
│ ├── companies_service.go # Company CRUD
|
||||||
│ ├── employees_service.go # Employee serials: generate, query, update, revoke, qrcode
|
│ ├── employees_service.go # Employee serials: generate, query, update, revoke, qrcode
|
||||||
│ ├── serials_service.go # Company serials: generate, query, update, revoke, qrcode
|
│ ├── serials_service.go # Company serials: generate, query, update, revoke, qrcode
|
||||||
│ ├── services_test.go # Unit tests
|
│ ├── aftersales_service_test.go # Aftersales unit tests
|
||||||
│ └── users_service.go # User CRUD, role management, password reset (admin)
|
│ ├── services_test.go # Auth / Serials / Employees / Companies unit tests
|
||||||
|
│ ├── users_service.go # User CRUD, role management, password reset (admin)
|
||||||
|
│ └── users_service_test.go # Users unit tests
|
||||||
├── tests/ # Integration tests
|
├── tests/ # Integration tests
|
||||||
│ └── main_test.go # End-to-end tests
|
│ └── main_test.go # End-to-end tests
|
||||||
├── data/ # SQLite data directory
|
├── data/ # SQLite data directory
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ backend-go/
|
|||||||
│ ├── companies_service.go # 企业管理业务逻辑
|
│ ├── companies_service.go # 企业管理业务逻辑
|
||||||
│ ├── employees_service.go # 员工赋码业务逻辑
|
│ ├── employees_service.go # 员工赋码业务逻辑
|
||||||
│ ├── serials_service.go # 序列号业务逻辑
|
│ ├── serials_service.go # 序列号业务逻辑
|
||||||
│ ├── services_test.go # 服务层单元测试
|
│ ├── aftersales_service_test.go # 售后工单单元测试
|
||||||
│ └── users_service.go # 用户管理业务逻辑
|
│ ├── services_test.go # 认证/序列号/员工/企业单元测试
|
||||||
|
│ ├── users_service.go # 用户管理业务逻辑
|
||||||
|
│ └── users_service_test.go # 用户管理单元测试
|
||||||
├── tests/ # 集成测试
|
├── tests/ # 集成测试
|
||||||
│ └── main_test.go # 端到端测试
|
│ └── main_test.go # 端到端测试
|
||||||
├── data/ # 数据目录(SQLite 数据库存储位置)
|
├── data/ # 数据目录(SQLite 数据库存储位置)
|
||||||
@@ -365,11 +367,13 @@ go tool cover -html=coverage.out
|
|||||||
|
|
||||||
### 当前测试覆盖
|
### 当前测试覆盖
|
||||||
|
|
||||||
- **services/**: 包含 AuthService、SerialsService、EmployeeSerialsService 和 CompaniesService 的完整单元测试
|
- **services/**: 包含 AuthService、SerialsService、EmployeeSerialsService、CompaniesService、AftersalesService 和 UsersService 的完整单元测试
|
||||||
- 用户认证测试(登录、获取用户信息、修改密码、更新资料)
|
- 用户认证测试(登录、获取用户信息、修改密码、更新资料)
|
||||||
- 序列号管理测试(生成、查询、更新、吊销、分页列表)
|
- 序列号管理测试(生成、查询、更新、吊销、分页列表)
|
||||||
- 员工赋码测试(生成、查询、更新、吊销、二维码生成)
|
- 员工赋码测试(生成、查询、更新、吊销、二维码生成)
|
||||||
- 企业统计测试(统计概览)
|
- 企业统计测试(统计概览)
|
||||||
|
- 售后工单测试(YYMMNN 序号生成、状态机、客户确认手机号校验、强制关闭)
|
||||||
|
- 用户管理测试(重复用户名、自降级保护、最后管理员保护、密码重置)
|
||||||
- **tests/**: 集成测试(健康检查、登录流程)
|
- **tests/**: 集成测试(健康检查、登录流程)
|
||||||
|
|
||||||
## 代码检查
|
## 代码检查
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ type CompanyStatsOverviewDTO struct {
|
|||||||
TotalEmployeeSerials int64 `json:"totalEmployeeSerials"`
|
TotalEmployeeSerials int64 `json:"totalEmployeeSerials"`
|
||||||
ActiveEmployeeSerials int64 `json:"activeEmployeeSerials"`
|
ActiveEmployeeSerials int64 `json:"activeEmployeeSerials"`
|
||||||
RevokedEmployeeSerials int64 `json:"revokedEmployeeSerials"`
|
RevokedEmployeeSerials int64 `json:"revokedEmployeeSerials"`
|
||||||
|
TotalAftersales int64 `json:"totalAftersales"`
|
||||||
|
PendingConfirmation int64 `json:"pendingConfirmation"`
|
||||||
|
ClosedAftersales int64 `json:"closedAftersales"`
|
||||||
|
RejectedAftersales int64 `json:"rejectedAftersales"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmployeeSerial 员工序列号模型
|
// EmployeeSerial 员工序列号模型
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"git.beifan.cn/trace-system/backend-go/database"
|
||||||
|
"git.beifan.cn/trace-system/backend-go/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedTechnician(t *testing.T, username string) models.User {
|
||||||
|
t.Helper()
|
||||||
|
user := models.User{
|
||||||
|
Username: username,
|
||||||
|
Password: "hashed",
|
||||||
|
Name: "技术员-" + username,
|
||||||
|
Email: username + "@example.com",
|
||||||
|
Role: "technician",
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&user).Error; err != nil {
|
||||||
|
t.Fatalf("seed technician failed: %v", err)
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOrderFor(t *testing.T, user models.User, phone string) *models.AftersalesOrder {
|
||||||
|
t.Helper()
|
||||||
|
svc := AftersalesService{}
|
||||||
|
order, err := svc.Create(models.CreateAftersalesOrderDTO{
|
||||||
|
CompanyName: "TestCo_" + user.Username,
|
||||||
|
CompanyAddress: "测试地址",
|
||||||
|
ContactName: "张三",
|
||||||
|
ContactPhone: phone,
|
||||||
|
ServiceType: "software",
|
||||||
|
IssueDescription: "系统无法启动",
|
||||||
|
}, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create order failed: %v", err)
|
||||||
|
}
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_Create_GeneratesYYMMNNSerial(t *testing.T) {
|
||||||
|
user := seedTechnician(t, "aftersales_create_tech")
|
||||||
|
defer database.DB.Unscoped().Delete(&user)
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
order, err := svc.Create(models.CreateAftersalesOrderDTO{
|
||||||
|
CompanyName: "AftersalesSerialCo",
|
||||||
|
CompanyAddress: "杭州市西湖区",
|
||||||
|
ContactName: "李四",
|
||||||
|
ContactPhone: "13800001234",
|
||||||
|
ServiceType: "hardware",
|
||||||
|
IssueDescription: "硬盘故障",
|
||||||
|
}, user.ID)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, order)
|
||||||
|
assert.True(t, strings.HasPrefix(order.SerialNumber, "zjbf-sh-"))
|
||||||
|
assert.Len(t, order.SerialNumber, len("zjbf-sh-")+6, "default serial should be 6 digits (YYMMNN)")
|
||||||
|
assert.Equal(t, WorkOrderStatusCreated, order.WorkOrderStatus)
|
||||||
|
assert.Equal(t, AuthorizationStatusPending, order.AuthorizationStatus)
|
||||||
|
assert.NotNil(t, order.TechnicianID)
|
||||||
|
assert.Equal(t, user.ID, *order.TechnicianID)
|
||||||
|
|
||||||
|
database.DB.Unscoped().Delete(order)
|
||||||
|
database.DB.Unscoped().Where("company_name = ?", "AftersalesSerialCo").Delete(&models.Company{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_Create_SerialIncrementsWithinMonth(t *testing.T) {
|
||||||
|
user := seedTechnician(t, "aftersales_seq_tech")
|
||||||
|
defer database.DB.Unscoped().Delete(&user)
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
first, err := svc.Create(models.CreateAftersalesOrderDTO{
|
||||||
|
CompanyName: "SeqCo", CompanyAddress: "addr", ContactName: "A", ContactPhone: "13800002000",
|
||||||
|
ServiceType: "other", IssueDescription: "issue 1",
|
||||||
|
}, user.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
second, err := svc.Create(models.CreateAftersalesOrderDTO{
|
||||||
|
CompanyName: "SeqCo", CompanyAddress: "addr", ContactName: "A", ContactPhone: "13800002000",
|
||||||
|
ServiceType: "other", IssueDescription: "issue 2",
|
||||||
|
}, user.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotEqual(t, first.SerialNumber, second.SerialNumber)
|
||||||
|
// 第二单的序号应大于第一单(按月递增)
|
||||||
|
assert.True(t, second.SerialNumber > first.SerialNumber)
|
||||||
|
|
||||||
|
database.DB.Unscoped().Delete(first)
|
||||||
|
database.DB.Unscoped().Delete(second)
|
||||||
|
database.DB.Unscoped().Where("company_name = ?", "SeqCo").Delete(&models.Company{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_SubmitForConfirmation_OwnerOnly(t *testing.T) {
|
||||||
|
owner := seedTechnician(t, "aftersales_owner")
|
||||||
|
intruder := seedTechnician(t, "aftersales_intruder")
|
||||||
|
defer database.DB.Unscoped().Delete(&owner)
|
||||||
|
defer database.DB.Unscoped().Delete(&intruder)
|
||||||
|
|
||||||
|
order := createOrderFor(t, owner, "13800003000")
|
||||||
|
defer database.DB.Unscoped().Delete(order)
|
||||||
|
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
|
||||||
|
// 非负责人技术员不能提交
|
||||||
|
_, err := svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||||
|
ResolutionNote: "已重装系统",
|
||||||
|
}, intruder)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// 负责人可以提交
|
||||||
|
updated, err := svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||||
|
ResolutionNote: "已重装系统",
|
||||||
|
}, owner)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, WorkOrderStatusPendingConfirmation, updated.WorkOrderStatus)
|
||||||
|
assert.Equal(t, "已重装系统", updated.ResolutionNote)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_SubmitForConfirmation_RejectsClosed(t *testing.T) {
|
||||||
|
owner := seedTechnician(t, "aftersales_closed_owner")
|
||||||
|
defer database.DB.Unscoped().Delete(&owner)
|
||||||
|
|
||||||
|
order := createOrderFor(t, owner, "13800004000")
|
||||||
|
defer database.DB.Unscoped().Delete(order)
|
||||||
|
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
|
||||||
|
_, err := svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||||
|
ResolutionNote: "first submit",
|
||||||
|
}, owner)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// 已经 pending_confirmation 状态,不能再提交
|
||||||
|
_, err = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||||
|
ResolutionNote: "second submit",
|
||||||
|
}, owner)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_PublicQuery_MasksPhoneAndSetsScannedAt(t *testing.T) {
|
||||||
|
owner := seedTechnician(t, "aftersales_public_owner")
|
||||||
|
defer database.DB.Unscoped().Delete(&owner)
|
||||||
|
|
||||||
|
order := createOrderFor(t, owner, "13800005000")
|
||||||
|
defer database.DB.Unscoped().Delete(order)
|
||||||
|
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
view, err := svc.PublicQuery(order.SerialNumber)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, view)
|
||||||
|
assert.Equal(t, order.CompanyName, view.CompanyName)
|
||||||
|
assert.Equal(t, order.ContactName, view.ContactName)
|
||||||
|
|
||||||
|
// PublicView 不应该带电话字段(只有以下字段,通过类型保证)
|
||||||
|
// 验证 ScannedAt 已设置
|
||||||
|
var refreshed models.AftersalesOrder
|
||||||
|
database.DB.Where("serial_number = ?", order.SerialNumber).First(&refreshed)
|
||||||
|
assert.NotNil(t, refreshed.ScannedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_CustomerConfirm_Authorize(t *testing.T) {
|
||||||
|
owner := seedTechnician(t, "aftersales_auth_owner")
|
||||||
|
defer database.DB.Unscoped().Delete(&owner)
|
||||||
|
|
||||||
|
order := createOrderFor(t, owner, "13800006789")
|
||||||
|
defer database.DB.Unscoped().Delete(order)
|
||||||
|
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
_, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||||
|
ResolutionNote: "done",
|
||||||
|
}, owner)
|
||||||
|
|
||||||
|
view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
|
||||||
|
PhoneLast4: "6789",
|
||||||
|
Action: "authorize",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, view)
|
||||||
|
assert.Equal(t, WorkOrderStatusClosed, view.WorkOrderStatus)
|
||||||
|
assert.Equal(t, AuthorizationStatusAuthorized, view.AuthorizationStatus)
|
||||||
|
assert.NotNil(t, view.ConfirmedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_CustomerConfirm_PhoneMismatch(t *testing.T) {
|
||||||
|
owner := seedTechnician(t, "aftersales_phone_owner")
|
||||||
|
defer database.DB.Unscoped().Delete(&owner)
|
||||||
|
|
||||||
|
order := createOrderFor(t, owner, "13800007777")
|
||||||
|
defer database.DB.Unscoped().Delete(order)
|
||||||
|
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
_, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||||
|
ResolutionNote: "done",
|
||||||
|
}, owner)
|
||||||
|
|
||||||
|
_, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
|
||||||
|
PhoneLast4: "0000",
|
||||||
|
Action: "authorize",
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "手机号校验失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_CustomerConfirm_RejectIncrementsCount(t *testing.T) {
|
||||||
|
owner := seedTechnician(t, "aftersales_reject_owner")
|
||||||
|
defer database.DB.Unscoped().Delete(&owner)
|
||||||
|
|
||||||
|
order := createOrderFor(t, owner, "13800008888")
|
||||||
|
defer database.DB.Unscoped().Delete(order)
|
||||||
|
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
_, _ = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||||
|
ResolutionNote: "done",
|
||||||
|
}, owner)
|
||||||
|
|
||||||
|
view, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
|
||||||
|
PhoneLast4: "8888",
|
||||||
|
Action: "reject",
|
||||||
|
RejectReason: "没修好",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, WorkOrderStatusRejected, view.WorkOrderStatus)
|
||||||
|
assert.Equal(t, AuthorizationStatusUnauthorized, view.AuthorizationStatus)
|
||||||
|
|
||||||
|
var refreshed models.AftersalesOrder
|
||||||
|
database.DB.Where("serial_number = ?", order.SerialNumber).First(&refreshed)
|
||||||
|
assert.Equal(t, 1, refreshed.RejectCount)
|
||||||
|
assert.Contains(t, refreshed.ResolutionNote, "没修好")
|
||||||
|
|
||||||
|
// 退回后技术员可以再次提交
|
||||||
|
_, err = svc.SubmitForConfirmation(order.SerialNumber, models.SubmitForConfirmationDTO{
|
||||||
|
ResolutionNote: "re-do",
|
||||||
|
}, owner)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_CustomerConfirm_RejectsWrongStatus(t *testing.T) {
|
||||||
|
owner := seedTechnician(t, "aftersales_wrongstatus_owner")
|
||||||
|
defer database.DB.Unscoped().Delete(&owner)
|
||||||
|
|
||||||
|
order := createOrderFor(t, owner, "13800009999")
|
||||||
|
defer database.DB.Unscoped().Delete(order)
|
||||||
|
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
// 未提交客户确认,工单仍是 created,应该拒绝
|
||||||
|
_, err := svc.CustomerConfirm(order.SerialNumber, models.CustomerConfirmDTO{
|
||||||
|
PhoneLast4: "9999",
|
||||||
|
Action: "authorize",
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAftersalesService_ForceClose_AdminOverride(t *testing.T) {
|
||||||
|
owner := seedTechnician(t, "aftersales_force_owner")
|
||||||
|
defer database.DB.Unscoped().Delete(&owner)
|
||||||
|
|
||||||
|
order := createOrderFor(t, owner, "13800001111")
|
||||||
|
defer database.DB.Unscoped().Delete(order)
|
||||||
|
defer database.DB.Unscoped().Where("company_name = ?", order.CompanyName).Delete(&models.Company{})
|
||||||
|
|
||||||
|
svc := AftersalesService{}
|
||||||
|
updated, err := svc.ForceClose(order.SerialNumber)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, WorkOrderStatusClosed, updated.WorkOrderStatus)
|
||||||
|
assert.Equal(t, AuthorizationStatusAuthorized, updated.AuthorizationStatus)
|
||||||
|
}
|
||||||
@@ -317,6 +317,11 @@ func (s *CompaniesService) GetStats() (map[string]any, error) {
|
|||||||
return nil, errors.New("查询员工序列号统计失败")
|
return nil, errors.New("查询员工序列号统计失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var aftersales []models.AftersalesOrder
|
||||||
|
if err := database.DB.Preload("Technician").Order("created_at DESC").Find(&aftersales).Error; err != nil {
|
||||||
|
return nil, errors.New("查询售后工单统计失败")
|
||||||
|
}
|
||||||
|
|
||||||
companyCount := len(companies)
|
companyCount := len(companies)
|
||||||
serialCount := len(serials)
|
serialCount := len(serials)
|
||||||
employeeSerialCount := len(employeeSerials)
|
employeeSerialCount := len(employeeSerials)
|
||||||
@@ -392,6 +397,41 @@ func (s *CompaniesService) GetStats() (map[string]any, error) {
|
|||||||
recentSerials = recentSerials[:10]
|
recentSerials = recentSerials[:10]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
aftersalesTotal := len(aftersales)
|
||||||
|
aftersalesPending := 0
|
||||||
|
aftersalesClosed := 0
|
||||||
|
aftersalesRejected := 0
|
||||||
|
for _, o := range aftersales {
|
||||||
|
switch o.WorkOrderStatus {
|
||||||
|
case "pending_confirmation":
|
||||||
|
aftersalesPending++
|
||||||
|
case "closed":
|
||||||
|
aftersalesClosed++
|
||||||
|
case "rejected":
|
||||||
|
aftersalesRejected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recentAftersales := make([]map[string]any, 0)
|
||||||
|
for i, order := range aftersales {
|
||||||
|
if i >= 10 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
technicianName := ""
|
||||||
|
if order.Technician != nil {
|
||||||
|
technicianName = order.Technician.Name
|
||||||
|
}
|
||||||
|
recentAftersales = append(recentAftersales, map[string]any{
|
||||||
|
"serialNumber": order.SerialNumber,
|
||||||
|
"companyName": order.CompanyName,
|
||||||
|
"serviceType": order.ServiceType,
|
||||||
|
"workOrderStatus": order.WorkOrderStatus,
|
||||||
|
"authorizationStatus": order.AuthorizationStatus,
|
||||||
|
"technicianName": technicianName,
|
||||||
|
"createdAt": order.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"overview": map[string]any{
|
"overview": map[string]any{
|
||||||
"totalCompanies": companyCount,
|
"totalCompanies": companyCount,
|
||||||
@@ -399,10 +439,15 @@ func (s *CompaniesService) GetStats() (map[string]any, error) {
|
|||||||
"totalEmployeeSerials": employeeSerialCount,
|
"totalEmployeeSerials": employeeSerialCount,
|
||||||
"activeSerials": activeCount,
|
"activeSerials": activeCount,
|
||||||
"inactiveSerials": inactiveCount,
|
"inactiveSerials": inactiveCount,
|
||||||
|
"totalAftersales": aftersalesTotal,
|
||||||
|
"pendingConfirmation": aftersalesPending,
|
||||||
|
"closedAftersales": aftersalesClosed,
|
||||||
|
"rejectedAftersales": aftersalesRejected,
|
||||||
},
|
},
|
||||||
"monthlyStats": monthlyItems,
|
"monthlyStats": monthlyItems,
|
||||||
"recentCompanies": recentCompanies,
|
"recentCompanies": recentCompanies,
|
||||||
"recentSerials": recentSerials,
|
"recentSerials": recentSerials,
|
||||||
|
"recentAftersales": recentAftersales,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,5 +485,18 @@ func (s *CompaniesService) GetStatsOverview() (*models.CompanyStatsOverviewDTO,
|
|||||||
|
|
||||||
stats.RevokedEmployeeSerials = stats.TotalEmployeeSerials - stats.ActiveEmployeeSerials
|
stats.RevokedEmployeeSerials = stats.TotalEmployeeSerials - stats.ActiveEmployeeSerials
|
||||||
|
|
||||||
|
if err := database.DB.Model(&models.AftersalesOrder{}).Count(&stats.TotalAftersales).Error; err != nil {
|
||||||
|
return nil, errors.New("统计售后工单总数失败")
|
||||||
|
}
|
||||||
|
if err := database.DB.Model(&models.AftersalesOrder{}).Where("work_order_status = ?", "pending_confirmation").Count(&stats.PendingConfirmation).Error; err != nil {
|
||||||
|
return nil, errors.New("统计待客户确认工单失败")
|
||||||
|
}
|
||||||
|
if err := database.DB.Model(&models.AftersalesOrder{}).Where("work_order_status = ?", "closed").Count(&stats.ClosedAftersales).Error; err != nil {
|
||||||
|
return nil, errors.New("统计已完成工单失败")
|
||||||
|
}
|
||||||
|
if err := database.DB.Model(&models.AftersalesOrder{}).Where("work_order_status = ?", "rejected").Count(&stats.RejectedAftersales).Error; err != nil {
|
||||||
|
return nil, errors.New("统计已退回工单失败")
|
||||||
|
}
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func TestMain(m *testing.M) {
|
|||||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{})
|
database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{})
|
||||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{})
|
database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{})
|
||||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.EmployeeSerial{})
|
database.DB.Unscoped().Where("1 = 1").Delete(&models.EmployeeSerial{})
|
||||||
|
database.DB.Unscoped().Where("1 = 1").Delete(&models.AftersalesOrder{})
|
||||||
|
|
||||||
exitCode := m.Run()
|
exitCode := m.Run()
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ func TestMain(m *testing.M) {
|
|||||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{})
|
database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{})
|
||||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{})
|
database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{})
|
||||||
database.DB.Unscoped().Where("1 = 1").Delete(&models.EmployeeSerial{})
|
database.DB.Unscoped().Where("1 = 1").Delete(&models.EmployeeSerial{})
|
||||||
|
database.DB.Unscoped().Where("1 = 1").Delete(&models.AftersalesOrder{})
|
||||||
|
|
||||||
os.Exit(exitCode)
|
os.Exit(exitCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"git.beifan.cn/trace-system/backend-go/database"
|
||||||
|
"git.beifan.cn/trace-system/backend-go/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUsersService_Create_Success(t *testing.T) {
|
||||||
|
svc := UsersService{}
|
||||||
|
dto, err := svc.Create(models.CreateUserDTO{
|
||||||
|
Username: "users_create_ok",
|
||||||
|
Password: "password123",
|
||||||
|
Name: "新技术员",
|
||||||
|
Email: "new@example.com",
|
||||||
|
Role: "technician",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, dto)
|
||||||
|
assert.Equal(t, "users_create_ok", dto.Username)
|
||||||
|
assert.Equal(t, "technician", dto.Role)
|
||||||
|
|
||||||
|
database.DB.Unscoped().Where("username = ?", "users_create_ok").Delete(&models.User{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersService_Create_DuplicateUsername(t *testing.T) {
|
||||||
|
user := models.User{
|
||||||
|
Username: "users_create_dup",
|
||||||
|
Password: "x",
|
||||||
|
Name: "existing",
|
||||||
|
Role: "technician",
|
||||||
|
}
|
||||||
|
database.DB.Create(&user)
|
||||||
|
defer database.DB.Unscoped().Delete(&user)
|
||||||
|
|
||||||
|
svc := UsersService{}
|
||||||
|
_, err := svc.Create(models.CreateUserDTO{
|
||||||
|
Username: "users_create_dup",
|
||||||
|
Password: "password123",
|
||||||
|
Name: "duplicate",
|
||||||
|
Role: "technician",
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "用户名已存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersService_Update_BlocksSelfDemotion(t *testing.T) {
|
||||||
|
admin := models.User{
|
||||||
|
Username: "users_self_admin",
|
||||||
|
Password: "x",
|
||||||
|
Name: "self admin",
|
||||||
|
Role: "admin",
|
||||||
|
}
|
||||||
|
database.DB.Create(&admin)
|
||||||
|
defer database.DB.Unscoped().Delete(&admin)
|
||||||
|
|
||||||
|
svc := UsersService{}
|
||||||
|
_, err := svc.Update(admin.ID, models.UpdateUserDTO{
|
||||||
|
Role: "technician",
|
||||||
|
}, admin.ID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "不能修改自己的管理员角色")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) {
|
||||||
|
currentAdmin := models.User{
|
||||||
|
Username: "users_update_admin",
|
||||||
|
Password: "x",
|
||||||
|
Name: "current",
|
||||||
|
Role: "admin",
|
||||||
|
}
|
||||||
|
target := models.User{
|
||||||
|
Username: "users_update_target",
|
||||||
|
Password: "x",
|
||||||
|
Name: "target",
|
||||||
|
Role: "technician",
|
||||||
|
}
|
||||||
|
database.DB.Create(¤tAdmin)
|
||||||
|
database.DB.Create(&target)
|
||||||
|
defer database.DB.Unscoped().Delete(¤tAdmin)
|
||||||
|
defer database.DB.Unscoped().Delete(&target)
|
||||||
|
|
||||||
|
svc := UsersService{}
|
||||||
|
updated, err := svc.Update(target.ID, models.UpdateUserDTO{
|
||||||
|
Name: "新名字",
|
||||||
|
Role: "admin",
|
||||||
|
}, currentAdmin.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "新名字", updated.Name)
|
||||||
|
assert.Equal(t, "admin", updated.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersService_ResetPassword_ChangesHash(t *testing.T) {
|
||||||
|
hashed, _ := bcrypt.GenerateFromPassword([]byte("oldpass"), bcrypt.DefaultCost)
|
||||||
|
user := models.User{
|
||||||
|
Username: "users_reset_pwd",
|
||||||
|
Password: string(hashed),
|
||||||
|
Name: "reset",
|
||||||
|
Role: "technician",
|
||||||
|
}
|
||||||
|
database.DB.Create(&user)
|
||||||
|
defer database.DB.Unscoped().Delete(&user)
|
||||||
|
|
||||||
|
svc := UsersService{}
|
||||||
|
err := svc.ResetPassword(user.ID, "newpass456")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var refreshed models.User
|
||||||
|
database.DB.First(&refreshed, user.ID)
|
||||||
|
assert.NoError(t, bcrypt.CompareHashAndPassword([]byte(refreshed.Password), []byte("newpass456")))
|
||||||
|
assert.Error(t, bcrypt.CompareHashAndPassword([]byte(refreshed.Password), []byte("oldpass")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersService_Delete_BlocksSelf(t *testing.T) {
|
||||||
|
user := models.User{
|
||||||
|
Username: "users_delete_self",
|
||||||
|
Password: "x",
|
||||||
|
Name: "self",
|
||||||
|
Role: "admin",
|
||||||
|
}
|
||||||
|
database.DB.Create(&user)
|
||||||
|
defer database.DB.Unscoped().Delete(&user)
|
||||||
|
|
||||||
|
svc := UsersService{}
|
||||||
|
err := svc.Delete(user.ID, user.ID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "不能删除自己")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersService_Delete_BlocksLastAdmin(t *testing.T) {
|
||||||
|
// 清理可能存在的其他 admin(来自 seed 或前面测试)
|
||||||
|
database.DB.Unscoped().Where("role = ?", "admin").Delete(&models.User{})
|
||||||
|
|
||||||
|
last := models.User{
|
||||||
|
Username: "users_last_admin",
|
||||||
|
Password: "x",
|
||||||
|
Name: "last",
|
||||||
|
Role: "admin",
|
||||||
|
}
|
||||||
|
other := models.User{
|
||||||
|
Username: "users_other_admin_actor",
|
||||||
|
Password: "x",
|
||||||
|
Name: "actor",
|
||||||
|
Role: "admin",
|
||||||
|
}
|
||||||
|
database.DB.Create(&last)
|
||||||
|
database.DB.Create(&other)
|
||||||
|
|
||||||
|
svc := UsersService{}
|
||||||
|
// 当前调用者是 other(不是 last),但删除会让 last 变成最后一个 admin
|
||||||
|
// 删 last 时计数会变 0,所以拒绝
|
||||||
|
database.DB.Unscoped().Delete(&other)
|
||||||
|
err := svc.Delete(last.ID, 999999)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "不能删除最后一个管理员")
|
||||||
|
|
||||||
|
database.DB.Unscoped().Delete(&last)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersService_FindAssignable_ReturnsAdminAndTechnician(t *testing.T) {
|
||||||
|
a := models.User{Username: "assignable_admin", Password: "x", Name: "A", Role: "admin"}
|
||||||
|
tech := models.User{Username: "assignable_tech", Password: "x", Name: "T", Role: "technician"}
|
||||||
|
plain := models.User{Username: "assignable_user", Password: "x", Name: "U", Role: "user"}
|
||||||
|
database.DB.Create(&a)
|
||||||
|
database.DB.Create(&tech)
|
||||||
|
database.DB.Create(&plain)
|
||||||
|
defer database.DB.Unscoped().Delete(&a)
|
||||||
|
defer database.DB.Unscoped().Delete(&tech)
|
||||||
|
defer database.DB.Unscoped().Delete(&plain)
|
||||||
|
|
||||||
|
svc := UsersService{}
|
||||||
|
users, err := svc.FindAssignable()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
usernames := make(map[string]string)
|
||||||
|
for _, u := range users {
|
||||||
|
usernames[u.Username] = u.Role
|
||||||
|
}
|
||||||
|
assert.Equal(t, "admin", usernames["assignable_admin"])
|
||||||
|
assert.Equal(t, "technician", usernames["assignable_tech"])
|
||||||
|
_, hasPlain := usernames["assignable_user"]
|
||||||
|
assert.False(t, hasPlain, "plain user should not be assignable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUsersService_FindAll_FilterByRole(t *testing.T) {
|
||||||
|
tech1 := models.User{Username: "findall_tech1", Password: "x", Name: "T1", Role: "technician"}
|
||||||
|
tech2 := models.User{Username: "findall_tech2", Password: "x", Name: "T2", Role: "technician"}
|
||||||
|
user1 := models.User{Username: "findall_user1", Password: "x", Name: "U1", Role: "user"}
|
||||||
|
database.DB.Create(&tech1)
|
||||||
|
database.DB.Create(&tech2)
|
||||||
|
database.DB.Create(&user1)
|
||||||
|
defer database.DB.Unscoped().Delete(&tech1)
|
||||||
|
defer database.DB.Unscoped().Delete(&tech2)
|
||||||
|
defer database.DB.Unscoped().Delete(&user1)
|
||||||
|
|
||||||
|
svc := UsersService{}
|
||||||
|
results, _, _, err := svc.FindAll(1, 50, "technician", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
for _, u := range results {
|
||||||
|
assert.Equal(t, "technician", u.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user