feat: 增加客户端采购员归属管理 (#54)
This commit is contained in:
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -252,6 +253,135 @@ func TestDeleteClients_批量删除(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 采购员归属(#54)──────────────────────────────────
|
||||
|
||||
func prepareClientAssignmentUsers(t *testing.T, db *sql.DB) (*model.User, *model.User, *model.User) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 8, 9, 8, 0, 0, 0, time.UTC)
|
||||
admin := prepareAdminUser(t, db, now)
|
||||
for _, username := range []string{"buyer-a", "buyer-b"} {
|
||||
if err := CreatePurchaser(db, admin, username, "buyer-password", "buyer-password", now); err != nil {
|
||||
t.Fatalf("准备采购员 %s 失败: %v", username, err)
|
||||
}
|
||||
}
|
||||
a, _ := repository.FindUserByUsername(db, "buyer-a")
|
||||
b, _ := repository.FindUserByUsername(db, "buyer-b")
|
||||
return admin, a, b
|
||||
}
|
||||
|
||||
func TestClientAssignment_一人多客户端并按采购员隔离列表(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
|
||||
RegisterClient(db, model.Client{ClientID: "c-1", Name: "一号机"}, true)
|
||||
RegisterClient(db, model.Client{ClientID: "c-2", Name: "二号机"}, true)
|
||||
now := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC)
|
||||
for _, clientID := range []string{"c-1", "c-2"} {
|
||||
changed, transferred, err := AssignClient(db, admin, clientID, buyerA.UserID, now)
|
||||
if err != nil || !changed || transferred {
|
||||
t.Fatalf("绑定 %s 失败: changed=%t transferred=%t err=%v", clientID, changed, transferred, err)
|
||||
}
|
||||
}
|
||||
|
||||
aRows, err := ListClientViewsForUser(db, buyerA, "", time.Minute)
|
||||
if err != nil || len(aRows) != 2 {
|
||||
t.Fatalf("采购员 A 应看到两台客户端,rows=%+v err=%v", aRows, err)
|
||||
}
|
||||
bRows, err := ListAssignableClients(db, buyerB, time.Minute)
|
||||
if err != nil || len(bRows) != 0 {
|
||||
t.Fatalf("采购员 B 不应看到 A 的客户端,rows=%+v err=%v", bRows, err)
|
||||
}
|
||||
adminRows, _ := ListClientViewsForUser(db, admin, "", time.Minute)
|
||||
if len(adminRows) != 2 || adminRows[0].AssignedUsername != "buyer-a" {
|
||||
t.Fatalf("管理员应看到全部客户端及负责人,实际 %+v", adminRows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAssignment_转交解绑保留审计且不改任务(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
|
||||
RegisterClient(db, model.Client{ClientID: "c-1", Name: "一号机"}, true)
|
||||
insertTask(t, db, "TASK-KEEP", "c-1")
|
||||
t1 := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC)
|
||||
t2 := t1.Add(time.Hour)
|
||||
t3 := t2.Add(time.Hour)
|
||||
if _, _, err := AssignClient(db, admin, "c-1", buyerA.UserID, t1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, transferred, err := AssignClient(db, admin, "c-1", buyerB.UserID, t2)
|
||||
if err != nil || !changed || !transferred {
|
||||
t.Fatalf("转交失败: changed=%t transferred=%t err=%v", changed, transferred, err)
|
||||
}
|
||||
if err := UnassignClient(db, admin, "c-1", t3); err != nil {
|
||||
t.Fatalf("解绑失败: %v", err)
|
||||
}
|
||||
|
||||
rows, err := db.Query(`SELECT user_id, started_at, ended_at, assigned_by_user_id,
|
||||
ended_by_user_id, end_reason FROM client_user_assignments
|
||||
WHERE client_id = 'c-1' ORDER BY started_at`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
type audit struct{ user, started, ended, assignedBy, endedBy, reason string }
|
||||
audits := make([]audit, 0, 2)
|
||||
for rows.Next() {
|
||||
var a audit
|
||||
if err := rows.Scan(&a.user, &a.started, &a.ended, &a.assignedBy, &a.endedBy, &a.reason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
audits = append(audits, a)
|
||||
}
|
||||
if len(audits) != 2 || audits[0].reason != "transfer" || audits[1].reason != "unbind" {
|
||||
t.Fatalf("归属历史不完整: %+v", audits)
|
||||
}
|
||||
for _, a := range audits {
|
||||
if a.assignedBy != admin.UserID || a.endedBy != admin.UserID || a.ended == "" {
|
||||
t.Errorf("操作管理员或结束时间未记录: %+v", a)
|
||||
}
|
||||
}
|
||||
var assignedClient string
|
||||
var status model.TaskStatus
|
||||
if err := db.QueryRow(`SELECT assigned_client, status FROM tasks WHERE task_id = 'TASK-KEEP'`).Scan(&assignedClient, &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assignedClient != "c-1" || status != model.TaskAssigned {
|
||||
t.Errorf("归属变化不应修改任务,client=%q status=%q", assignedClient, status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientAssignment_权限禁用账号唯一约束及重新登记(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
admin, buyerA, buyerB := prepareClientAssignmentUsers(t, db)
|
||||
RegisterClient(db, model.Client{ClientID: "c-1", Name: "一号机"}, true)
|
||||
now := time.Date(2026, 8, 9, 9, 0, 0, 0, time.UTC)
|
||||
if _, _, err := AssignClient(db, buyerA, "c-1", buyerB.UserID, now); !errors.Is(err, ErrAdminRequired) {
|
||||
t.Fatalf("采购员不应有绑定权限,实际 %v", err)
|
||||
}
|
||||
if err := ChangeUserStatus(db, admin, buyerB.UserID, model.UserDisabled, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := AssignClient(db, admin, "c-1", buyerB.UserID, now); !errors.Is(err, repository.ErrPurchaserNotActive) {
|
||||
t.Fatalf("禁用采购员不应成为目标,实际 %v", err)
|
||||
}
|
||||
if _, _, err := AssignClient(db, admin, "c-1", buyerA.UserID, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 数据库索引是最后一道并发保护,直接插入第二条当前归属必须失败。
|
||||
if _, err := db.Exec(`INSERT INTO client_user_assignments
|
||||
(assignment_id, client_id, user_id, started_at, assigned_by_user_id)
|
||||
VALUES ('duplicate', 'c-1', ?, ?, ?)`, buyerA.UserID, model.NowISO(), admin.UserID); err == nil {
|
||||
t.Fatal("同一客户端插入第二个当前负责人应该被唯一索引拒绝")
|
||||
}
|
||||
if n, err := DeleteClients(db, []string{"c-1"}); err != nil || n != 1 {
|
||||
t.Fatalf("删除客户端失败: n=%d err=%v", n, err)
|
||||
}
|
||||
RegisterClient(db, model.Client{ClientID: "c-1", Name: "重新登记的一号机"}, true)
|
||||
views, err := ListClientViewsForUser(db, buyerA, "", time.Minute)
|
||||
if err != nil || len(views) != 1 || views[0].AssignedUsername != "buyer-a" {
|
||||
t.Fatalf("同编号重新登记后应恢复当前归属,views=%+v err=%v", views, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 领取任务 ───────────────────────────────────────────
|
||||
|
||||
func TestClaimNextTask_没有任务返回nil(t *testing.T) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmautobuy/admin/model"
|
||||
@@ -118,12 +119,33 @@ func TouchClient(db *sql.DB, clientID string) error {
|
||||
// Status 是**算出来的**,数据库里没有这个字段。
|
||||
type ClientView struct {
|
||||
model.Client
|
||||
Status string
|
||||
Status string
|
||||
AssignedUserID string
|
||||
AssignedUsername string
|
||||
}
|
||||
|
||||
// ListClientViews 查客户端列表,并把在线状态算出来。
|
||||
func ListClientViews(db *sql.DB, keyword string, threshold time.Duration) ([]ClientView, error) {
|
||||
clients, err := repository.ListClients(db, keyword)
|
||||
return listClientViews(db, keyword, "", threshold)
|
||||
}
|
||||
|
||||
// ListClientViewsForUser 按网页登录身份限制可见范围:管理员全量,采购员只看自己。
|
||||
func ListClientViewsForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration) ([]ClientView, error) {
|
||||
if actor == nil {
|
||||
return nil, ErrUnauthenticated
|
||||
}
|
||||
visibleUserID := ""
|
||||
if !actor.IsAdmin() {
|
||||
if actor.Role != model.RolePurchaser {
|
||||
return nil, ErrAdminRequired
|
||||
}
|
||||
visibleUserID = actor.UserID
|
||||
}
|
||||
return listClientViews(db, keyword, visibleUserID, threshold)
|
||||
}
|
||||
|
||||
func listClientViews(db *sql.DB, keyword, visibleUserID string, threshold time.Duration) ([]ClientView, error) {
|
||||
clients, err := repository.ListClientsForUser(db, keyword, visibleUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -132,13 +154,62 @@ func ListClientViews(db *sql.DB, keyword string, threshold time.Duration) ([]Cli
|
||||
views := make([]ClientView, 0, len(clients))
|
||||
for _, c := range clients {
|
||||
views = append(views, ClientView{
|
||||
Client: c,
|
||||
Status: c.StatusText(now, threshold),
|
||||
Client: c.Client,
|
||||
Status: c.StatusText(now, threshold),
|
||||
AssignedUserID: c.AssignedUserID,
|
||||
AssignedUsername: c.AssignedUsername,
|
||||
})
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// ListAssignableClients 返回当前用户在采购任务页面可选择的客户端。
|
||||
// 当前创建页面尚未实现,本函数固定未来入口也必须沿用相同权限边界。
|
||||
func ListAssignableClients(db *sql.DB, actor *model.User, threshold time.Duration) ([]ClientView, error) {
|
||||
return ListClientViewsForUser(db, actor, "", threshold)
|
||||
}
|
||||
|
||||
// ListActivePurchasers 返回管理员可选择的绑定目标。
|
||||
func ListActivePurchasers(db *sql.DB, actor *model.User) ([]model.User, error) {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return nil, ErrAdminRequired
|
||||
}
|
||||
return repository.ListActivePurchasers(db)
|
||||
}
|
||||
|
||||
// AssignClient 由管理员完成首次绑定或转交。
|
||||
func AssignClient(db *sql.DB, actor *model.User, clientID, purchaserUserID string, now time.Time) (bool, bool, error) {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return false, false, ErrAdminRequired
|
||||
}
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
purchaserUserID = strings.TrimSpace(purchaserUserID)
|
||||
if clientID == "" || purchaserUserID == "" {
|
||||
return false, false, invalidInput("客户端和采购员都不能为空")
|
||||
}
|
||||
id, err := randomID("CA-", 16)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
at := now.UTC().Format(model.TimeLayout)
|
||||
return repository.AssignClient(db, model.ClientUserAssignment{
|
||||
AssignmentID: id, ClientID: clientID, UserID: purchaserUserID,
|
||||
StartedAt: at, AssignedByUserID: actor.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
// UnassignClient 由管理员结束当前归属,不改动任何任务。
|
||||
func UnassignClient(db *sql.DB, actor *model.User, clientID string, now time.Time) error {
|
||||
if actor == nil || !actor.IsAdmin() {
|
||||
return ErrAdminRequired
|
||||
}
|
||||
clientID = strings.TrimSpace(clientID)
|
||||
if clientID == "" {
|
||||
return invalidInput("客户端不能为空")
|
||||
}
|
||||
return repository.UnassignClient(db, clientID, actor.UserID, now.UTC().Format(model.TimeLayout))
|
||||
}
|
||||
|
||||
// DeleteClients 批量删除,返回实际删除条数。
|
||||
func DeleteClients(db *sql.DB, clientIDs []string) (int64, error) {
|
||||
return repository.DeleteClients(db, clientIDs)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"cmautobuy/admin/repository"
|
||||
)
|
||||
|
||||
var ErrAdminRequired = errors.New("只有管理员可以管理用户")
|
||||
var ErrAdminRequired = errors.New("只有管理员可以执行此管理操作")
|
||||
|
||||
// UserListResult 是用户管理页的分页结果。
|
||||
type UserListResult struct {
|
||||
|
||||
Reference in New Issue
Block a user