feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s
- 迁移 000221:新增 tb_business_user_group、tb_business_user_group_member、tb_shop_business_owner_import_task,成员一账号一行由部分唯一索引保证,店铺所属组按当前负责人实时推导,不回填历史分组。 - 用户组 CRUD、成员改组/清空归属、店铺批量交接(原子失败不部分写入)。 - 店铺负责人 CSV 导入任务:逐行独立事务、逐行明细、任务级与行级失败分离。 - 读侧推导与筛选:未分组、业务线、停用组可筛出并带停用标记。 - 补齐操作审计动作与资源、openapi 清单、发布门禁巡检表清单。 - 归档 add-shop-salesperson-groups 变更并同步 openspec/specs/business-user-group,补齐 AUG26-003 验证证据链。
This commit is contained in:
@@ -294,6 +294,7 @@ func isAsynqTaskResource(resourceType string) bool {
|
||||
switch resourceType {
|
||||
case constants.AuditResourceDeviceBatchTask, constants.AuditResourceIotCardImportTask,
|
||||
constants.AuditResourceDeviceImportTask, constants.AuditResourceAssetPackageBatchOrderTask,
|
||||
constants.AuditResourceShopBusinessOwnerImportTask,
|
||||
constants.AuditResourceOrderPackageInvalidateTask, constants.AuditResourceExportTask:
|
||||
return true
|
||||
default:
|
||||
|
||||
116
internal/query/businessusergroup/query.go
Normal file
116
internal/query/businessusergroup/query.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Package businessusergroup 提供业务用户组及其成员规模的只读投影。
|
||||
// Query 只做筛选、分页与 DTO 投影,不修改任何状态。
|
||||
package businessusergroup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// Query 查询业务用户组。
|
||||
type Query struct {
|
||||
db *gorm.DB
|
||||
store *postgres.BusinessUserGroupStore
|
||||
}
|
||||
|
||||
// NewQuery 创建业务用户组查询。
|
||||
func NewQuery(db *gorm.DB, groupStore *postgres.BusinessUserGroupStore) *Query {
|
||||
return &Query{db: db, store: groupStore}
|
||||
}
|
||||
|
||||
// List 分页查询业务用户组,供维护与筛选下拉使用。
|
||||
func (q *Query) List(ctx context.Context, request dto.BusinessUserGroupListRequest) (*dto.BusinessUserGroupPageResult, error) {
|
||||
if q == nil || q.db == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "业务用户组查询尚未配置")
|
||||
}
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
status := enabledToStatus(request.Enabled)
|
||||
query := q.db.WithContext(ctx).Model(&model.BusinessUserGroup{})
|
||||
if status != nil {
|
||||
query = query.Where("status = ?", *status)
|
||||
}
|
||||
if request.BusinessLine != nil {
|
||||
query = query.Where("business_line = ?", *request.BusinessLine)
|
||||
}
|
||||
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("name LIKE ? OR code LIKE ?", like, like)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组总数失败")
|
||||
}
|
||||
var groups []*model.BusinessUserGroup
|
||||
if err := query.Order("sort_order ASC, id ASC").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).Find(&groups).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组列表失败")
|
||||
}
|
||||
items := make([]*dto.BusinessUserGroupResponse, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
items = append(items, toResponse(group))
|
||||
}
|
||||
return &dto.BusinessUserGroupPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
|
||||
}
|
||||
|
||||
// Detail 查询单个业务用户组详情。
|
||||
func (q *Query) Detail(ctx context.Context, groupID uint) (*dto.BusinessUserGroupResponse, error) {
|
||||
if q == nil || q.store == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "业务用户组查询尚未配置")
|
||||
}
|
||||
if groupID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
group, err := q.store.GetByID(ctx, groupID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "业务用户组不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
|
||||
}
|
||||
return toResponse(group), nil
|
||||
}
|
||||
|
||||
// toResponse 将业务用户组投影为对外响应。
|
||||
func toResponse(group *model.BusinessUserGroup) *dto.BusinessUserGroupResponse {
|
||||
return &dto.BusinessUserGroupResponse{
|
||||
ID: group.ID, Code: group.Code, Name: group.Name,
|
||||
BusinessLine: group.BusinessLine, BusinessLineName: constants.GetBusinessLineName(group.BusinessLine),
|
||||
Sort: group.SortOrder, Enabled: group.Status == constants.StatusEnabled, Remark: group.Remark,
|
||||
CreatedAt: group.CreatedAt.Format(time.RFC3339), UpdatedAt: group.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
// enabledToStatus 把对外启停布尔映射为既有状态值;未提交时返回 nil 表示不过滤。
|
||||
func enabledToStatus(enabled *bool) *int {
|
||||
if enabled == nil {
|
||||
return nil
|
||||
}
|
||||
status := constants.StatusDisabled
|
||||
if *enabled {
|
||||
status = constants.StatusEnabled
|
||||
}
|
||||
return &status
|
||||
}
|
||||
|
||||
// normalizePage 归一化分页参数,执行 DefaultPageSize 与 MaxPageSize 上限。
|
||||
func normalizePage(page, pageSize int) (int, int) {
|
||||
if page <= 0 {
|
||||
page = constants.DefaultPage
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
@@ -119,6 +119,27 @@ func applyShopFilters(db *gorm.DB, request dto.ShopListRequest) *gorm.DB {
|
||||
if request.Status != nil {
|
||||
db = db.Where("status = ?", *request.Status)
|
||||
}
|
||||
// 店铺所属组由当前负责人实时推导,因此筛选一律用存在性子查询;
|
||||
// 主查询保持先 Count 再 Find 的同一 SQL,改用 JOIN 会放大计数行并引入列歧义。
|
||||
if request.BusinessUserGroupID != nil {
|
||||
db = db.Where(
|
||||
"EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL AND m.business_user_group_id = ?)",
|
||||
*request.BusinessUserGroupID)
|
||||
}
|
||||
if request.BusinessLine != nil {
|
||||
db = db.Where(
|
||||
"EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL AND EXISTS (SELECT 1 FROM tb_business_user_group g WHERE g.id = m.business_user_group_id AND g.deleted_at IS NULL AND g.business_line = ?))",
|
||||
*request.BusinessLine)
|
||||
}
|
||||
if request.Ungrouped != nil {
|
||||
// 未分组只包含无负责人与负责人无未删除成员关系两种情形;停用组的负责人仍属于已分组。
|
||||
const ungroupedCondition = "(tb_shop.business_owner_account_id IS NULL OR NOT EXISTS (SELECT 1 FROM tb_business_user_group_member m WHERE m.account_id = tb_shop.business_owner_account_id AND m.deleted_at IS NULL))"
|
||||
if *request.Ungrouped {
|
||||
db = db.Where(ungroupedCondition)
|
||||
} else {
|
||||
db = db.Where("NOT " + ungroupedCondition)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -132,6 +153,11 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 在既有 loadBusinessOwners 之上追加两次批量查询即完成组推导,避免 N+1。
|
||||
groups, err := q.loadBusinessUserGroups(ctx, ownerIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responses := make([]*dto.ShopResponse, 0, len(shops))
|
||||
for _, shop := range shops {
|
||||
response := &dto.ShopResponse{
|
||||
@@ -151,7 +177,16 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
|
||||
if owner, exists := owners[*shop.BusinessOwnerAccountID]; exists {
|
||||
response.BusinessOwnerUsername = owner.Username
|
||||
response.BusinessOwnerPhoneSummary = maskPhone(owner.Phone)
|
||||
response.BusinessOwnerAvailable = owner.UserType == constants.UserTypePlatform && owner.Status == constants.StatusEnabled && !owner.DeletedAt.Valid
|
||||
response.BusinessOwnerAvailable = owner.UserType == constants.UserTypePlatform &&
|
||||
owner.Status == constants.StatusEnabled && !owner.DeletedAt.Valid
|
||||
}
|
||||
if group, exists := groups[*shop.BusinessOwnerAccountID]; exists {
|
||||
groupID := group.ID
|
||||
response.BusinessUserGroupID = &groupID
|
||||
response.BusinessUserGroupCode = group.Code
|
||||
response.BusinessUserGroupName = group.Name
|
||||
response.BusinessUserGroupEnabled = group.Status == constants.StatusEnabled
|
||||
response.BusinessUserGroupBusinessLine = group.BusinessLine
|
||||
}
|
||||
}
|
||||
responses = append(responses, response)
|
||||
@@ -204,6 +239,43 @@ func (q *BusinessOwnerQuery) loadBusinessOwners(ctx context.Context, ids []uint)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadBusinessUserGroups 按负责人账号批量推导当前所属业务用户组。
|
||||
// 一账号至多一条未删除成员关系,因此先在成员上按 account_id 定位、再按组 ID 回表,
|
||||
// 停用组与负责人为已软删账号的成员关系都照常返回,保证展示与筛选口径一致。
|
||||
func (q *BusinessOwnerQuery) loadBusinessUserGroups(ctx context.Context, accountIDs []uint) (map[uint]model.BusinessUserGroup, error) {
|
||||
result := make(map[uint]model.BusinessUserGroup, len(accountIDs))
|
||||
if len(accountIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var members []model.BusinessUserGroupMember
|
||||
if err := q.db.WithContext(ctx).Model(&model.BusinessUserGroupMember{}).
|
||||
Where("account_id IN ?", accountIDs).Find(&members).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询负责人业务用户组归属失败")
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
groupIDs := make(map[uint]struct{}, len(members))
|
||||
for _, member := range members {
|
||||
groupIDs[member.BusinessUserGroupID] = struct{}{}
|
||||
}
|
||||
var groups []model.BusinessUserGroup
|
||||
if err := q.db.WithContext(ctx).Model(&model.BusinessUserGroup{}).
|
||||
Where("id IN ?", mapKeys(groupIDs)).Find(&groups).Error; err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务用户组失败")
|
||||
}
|
||||
groupByID := make(map[uint]model.BusinessUserGroup, len(groups))
|
||||
for _, group := range groups {
|
||||
groupByID[group.ID] = group
|
||||
}
|
||||
for _, member := range members {
|
||||
if group, exists := groupByID[member.BusinessUserGroupID]; exists {
|
||||
result[member.AccountID] = group
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mapKeys(values map[uint]struct{}) []uint {
|
||||
keys := make([]uint, 0, len(values))
|
||||
for key := range values {
|
||||
|
||||
Reference in New Issue
Block a user