Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕 - 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败 - 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影 - 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序 - 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列 - 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名 - 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
333 lines
13 KiB
Go
333 lines
13 KiB
Go
// 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),
|
||
// 无下级的店铺返回 0(map 缺省零值),不使用空值。
|
||
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:]
|
||
}
|