Files
junhong_cmp_fiber/internal/query/shop/business_owner.go
break c7f9e005af
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1h43m42s
feat(业务用户组): AUG26-003 业务用户组与店铺负责人分组导入
- 迁移 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 验证证据链。
2026-09-14 16:51:44 +08:00

294 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package shop 提供店铺业务员归属与资金概况读取投影。
package shop
import (
"context"
"strings"
"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/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// BusinessOwnerQuery 提供店铺业务员归属读取能力。
type BusinessOwnerQuery struct {
db *gorm.DB
}
// NewBusinessOwnerQuery 创建店铺业务员归属 Query。
func NewBusinessOwnerQuery(db *gorm.DB) *BusinessOwnerQuery {
return &BusinessOwnerQuery{db: db}
}
// List 查询调用者数据范围内的店铺,并批量投影上级和业务员摘要。
func (q *BusinessOwnerQuery) List(ctx context.Context, request dto.ShopListRequest) ([]*dto.ShopResponse, int64, error) {
base := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
base = applyShopFilters(base, request)
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺总数失败")
}
var shops []*model.Shop
offset := (request.Page - 1) * request.PageSize
if err := base.Order("created_at DESC, id DESC").Offset(offset).Limit(request.PageSize).Find(&shops).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺列表失败")
}
responses, err := q.project(ctx, shops)
if err != nil {
return nil, 0, err
}
return responses, total, nil
}
// Detail 查询调用者数据范围内的一家店铺详情。
func (q *BusinessOwnerQuery) Detail(ctx context.Context, shopID uint) (*dto.ShopResponse, error) {
if shopID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var shop model.Shop
db := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
if err := db.Where("id = ?", shopID).First(&shop).Error; err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
responses, err := q.project(ctx, []*model.Shop{&shop})
if err != nil {
return nil, err
}
return responses[0], nil
}
// Candidates 查询当前可人工绑定的平台业务员最小投影。
func (q *BusinessOwnerQuery) Candidates(ctx context.Context, request dto.ShopBusinessOwnerCandidateRequest) ([]dto.ShopBusinessOwnerCandidate, int64, int, int, error) {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return nil, 0, 0, 0, errors.New(errors.CodeForbidden, "无权限查询业务员候选")
}
page, pageSize := request.Page, request.PageSize
if page == 0 {
page = constants.DefaultPage
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
base := q.db.WithContext(ctx).Model(&model.Account{}).
Where("user_type = ? AND status = ?", constants.UserTypePlatform, constants.StatusEnabled)
if request.Keyword != "" {
keyword := "%" + request.Keyword + "%"
base = base.Where("username ILIKE ? OR phone ILIKE ?", keyword, keyword)
}
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员候选总数失败")
}
var accounts []model.Account
if err := base.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&accounts).Error; err != nil {
return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员候选失败")
}
items := make([]dto.ShopBusinessOwnerCandidate, 0, len(accounts))
for _, account := range accounts {
items = append(items, dto.ShopBusinessOwnerCandidate{
ID: account.ID, Username: account.Username, PhoneSummary: maskPhone(account.Phone),
})
}
return items, total, page, pageSize, nil
}
func applyShopFilters(db *gorm.DB, request dto.ShopListRequest) *gorm.DB {
if request.ShopName != "" {
db = db.Where("shop_name LIKE ?", "%"+request.ShopName+"%")
}
if request.ShopCode != "" {
db = db.Where("shop_code = ?", request.ShopCode)
}
if request.ContactPhone != "" {
db = db.Where("contact_phone = ?", request.ContactPhone)
}
if request.BusinessOwnerAccountID != nil {
db = db.Where("business_owner_account_id = ?", *request.BusinessOwnerAccountID)
}
if request.ParentID != nil {
db = db.Where("parent_id = ?", *request.ParentID)
}
if request.Level != nil {
db = db.Where("level = ?", *request.Level)
}
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
}
func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) ([]*dto.ShopResponse, error) {
parentIDs, ownerIDs := collectProjectionIDs(shops)
parentNames, err := q.loadParentNames(ctx, parentIDs)
if err != nil {
return nil, err
}
owners, err := q.loadBusinessOwners(ctx, ownerIDs)
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{
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode,
DistributionCode: shop.DistributionCode, ParentID: shop.ParentID,
BusinessOwnerAccountID: shop.BusinessOwnerAccountID, Level: shop.Level,
ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province,
City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status,
ClientLoginDisabled: shop.ClientLoginDisabled,
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if shop.ParentID != nil {
response.ParentShopName = parentNames[*shop.ParentID]
}
if shop.BusinessOwnerAccountID != nil {
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
}
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)
}
return responses, nil
}
func collectProjectionIDs(shops []*model.Shop) ([]uint, []uint) {
parents := make(map[uint]struct{})
owners := make(map[uint]struct{})
for _, shop := range shops {
if shop.ParentID != nil {
parents[*shop.ParentID] = struct{}{}
}
if shop.BusinessOwnerAccountID != nil {
owners[*shop.BusinessOwnerAccountID] = struct{}{}
}
}
return mapKeys(parents), mapKeys(owners)
}
func (q *BusinessOwnerQuery) loadParentNames(ctx context.Context, ids []uint) (map[uint]string, error) {
result := make(map[uint]string, len(ids))
if len(ids) == 0 {
return result, nil
}
var shops []model.Shop
db := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
if err := db.Select("id", "shop_name").Where("id IN ?", ids).Find(&shops).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺摘要失败")
}
for _, shop := range shops {
result[shop.ID] = shop.ShopName
}
return result, nil
}
func (q *BusinessOwnerQuery) loadBusinessOwners(ctx context.Context, ids []uint) (map[uint]model.Account, error) {
result := make(map[uint]model.Account, len(ids))
if len(ids) == 0 {
return result, nil
}
var accounts []model.Account
if err := q.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&accounts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员摘要失败")
}
for _, account := range accounts {
result[account.ID] = account
}
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 {
keys = append(keys, key)
}
return keys
}
func maskPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < 7 {
return ""
}
return phone[:3] + "****" + phone[len(phone)-4:]
}