Files
junhong_cmp_fiber/internal/query/shop/business_owner.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

333 lines
13 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
}
downstreamCounts, err := q.loadDownstreamAgentCounts(ctx, shops)
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),
// 无下级的店铺返回 0map 缺省零值),不使用空值。
DownstreamAgentCount: downstreamCounts[shop.ID],
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
}
// loadDownstreamAgentCounts 按当页店铺批量聚合直接下级代理数量。
//
// 口径未删除且上级店铺标识等于该店铺的店铺数一次分组查询完成MUST NOT 逐店查询。
// 该数量只读,不影响上下级关系、佣金关系、通知范围或任何既有写入语义。
// 数据范围由调用方的列表与详情过滤保证:范围外店铺不进入结果,其下级数量自然不泄露。
func (q *BusinessOwnerQuery) loadDownstreamAgentCounts(ctx context.Context, shops []*model.Shop) (map[uint]int64, error) {
result := make(map[uint]int64, len(shops))
shopIDs := make([]uint, 0, len(shops))
for _, shop := range shops {
if shop != nil && shop.ID > 0 {
shopIDs = append(shopIDs, shop.ID)
}
}
if len(shopIDs) == 0 {
return result, nil
}
var rows []struct {
ParentID uint
Total int64
}
if err := q.db.WithContext(ctx).Model(&model.Shop{}).
Select("parent_id, COUNT(*) AS total").
Where("parent_id IN ?", shopIDs).
Group("parent_id").Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计店铺下级代理数量失败")
}
for _, row := range rows {
result[row.ParentID] = row.Total
}
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:]
}