using System.IO;
using FoodLabeling.Application.Contracts.Dtos.Common;
using FoodLabeling.Application.Contracts.Dtos.TeamMember;
using FoodLabeling.Application.Contracts.IServices;
using FoodLabeling.Application.Helpers;
using FoodLabeling.Application.Options;
using FoodLabeling.Application.Services.DbModels;
using FoodLabeling.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Entities;
using Volo.Abp.Guids;
using Yi.Framework.Rbac.Domain.Entities;
using Yi.Framework.Rbac.Domain.Entities.ValueObjects;
using Yi.Framework.Rbac.Domain.Helpers;
using Yi.Framework.Rbac.Domain.Managers;
using Yi.Framework.SqlSugarCore.Abstractions;
namespace FoodLabeling.Application.Services;
///
/// 成员(Team Member)服务,对外仅在 food-labeling-us 暴露
///
public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
{
private readonly ISqlSugarRepository _userRepository;
private readonly UserManager _userManager;
private readonly ISqlSugarDbContext _dbContext;
private readonly IGuidGenerator _guidGenerator;
private readonly IOptionsSnapshot _batchImportOptions;
public TeamMemberAppService(
ISqlSugarRepository userRepository,
UserManager userManager,
ISqlSugarDbContext dbContext,
IGuidGenerator guidGenerator,
IOptionsSnapshot batchImportOptions)
{
_userRepository = userRepository;
_userManager = userManager;
_dbContext = dbContext;
_guidGenerator = guidGenerator;
_batchImportOptions = batchImportOptions;
}
///
public async Task> GetListAsync(TeamMemberGetListInputVo input)
{
var pageIndex = PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
var pageSize = input.MaxResultCount;
RefAsync total = 0;
var scopeLocationIds = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync(
CurrentUser,
_dbContext,
input.PartnerId,
input.GroupId,
input.LocationId);
var query = await BuildFilteredUserQueryAsync(input, scopeLocationIds);
var users = await query
.OrderByIF(!string.IsNullOrWhiteSpace(input.Sorting), input.Sorting!)
.OrderByDescending(u => u.CreationTime)
.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
var items = await MapUsersToOutputAsync(
users,
scopeLocationIds,
restrictAssignedLocationsToFilter: scopeLocationIds is not null);
var totalCount = (long)total;
return new PagedResultWithPageDto
{
PageIndex = pageIndex,
PageSize = pageSize,
TotalCount = totalCount,
TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
Items = items
};
}
///
public async Task GetAsync(Guid id)
{
var user = await _userRepository.GetByIdAsync(id);
if (user is null || user.IsDeleted)
{
throw new UserFriendlyException("成员不存在");
}
var userIdString = id.ToString();
var links = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted && x.UserId == userIdString)
.ToListAsync();
var locationIds = links.Select(x => x.LocationId).Distinct().ToList();
var locations = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted)
.WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
.Select(x => new { x.Id, x.LocationCode, x.LocationName })
.ToListAsync();
var assigned = locations.Select(x => new TeamMemberAssignedLocationDto
{
Id = x.Id.ToString(),
LocationCode = x.LocationCode,
LocationName = x.LocationName
}).ToList();
var role = await _dbContext.SqlSugarClient.Queryable().FirstAsync(x => x.UserId == id);
var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
_dbContext.SqlSugarClient, locationIds);
var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
_dbContext.SqlSugarClient, locationIds);
if (await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, role?.RoleId) &&
partnerIds.Count > 0)
{
(regionIds, assigned) = await ApplyCompanyAdminDisplayScopeAsync(
role?.RoleId, partnerIds, regionIds, assigned);
locationIds = assigned.Select(x => x.Id).ToList();
}
return new TeamMemberGetOutputDto
{
Id = user.Id,
FullName = user.Name ?? string.Empty,
UserName = user.UserName,
Email = user.Email,
Phone = user.Phone,
State = user.State,
RoleId = role?.RoleId,
PartnerIds = partnerIds,
RegionIds = regionIds,
GroupIds = regionIds,
LocationIds = locationIds,
AssignedLocations = assigned
};
}
///
public async Task CreateAsync(TeamMemberCreateInputVo input)
{
var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input, input.RoleId);
var user = new UserAggregateRoot
{
UserName = input.UserName.Trim(),
Name = input.FullName.Trim(),
Nick = input.FullName.Trim(),
Email = input.Email?.Trim(),
Phone = input.Phone,
State = input.State,
EncryPassword = new EncryPasswordValueObject(input.Password.Trim())
};
EntityHelper.TrySetId(user, _guidGenerator.Create);
user.BuildPassword();
await _userManager.CreateAsync(user);
if (input.RoleId != null)
{
await _userManager.GiveUserSetRoleAsync(new List { user.Id }, new List { input.RoleId.Value });
}
await UpsertUserLocationsAsync(user.Id, mergedLocationIds);
return await GetAsync(user.Id);
}
///
public async Task UpdateAsync(Guid id, TeamMemberUpdateInputVo input)
{
var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input);
var user = await _userRepository.GetByIdAsync(id);
if (user is null || user.IsDeleted)
{
throw new UserFriendlyException("成员不存在");
}
user.Name = input.FullName.Trim();
user.UserName = input.UserName.Trim();
user.Email = input.Email?.Trim();
user.Phone = input.Phone;
user.State = input.State;
var passwordChanged = false;
if (!string.IsNullOrWhiteSpace(input.Password))
{
UserPasswordHelper.ApplyPlainPassword(user, input.Password);
passwordChanged = true;
}
await _userRepository.UpdateAsync(user);
if (passwordChanged)
{
await UserPasswordHelper.EnsurePasswordColumnsPersistedAsync(
_userRepository,
user.Id,
user.EncryPassword.Password,
user.EncryPassword.Salt);
}
if (input.RoleId != null)
{
await _userManager.GiveUserSetRoleAsync(new List { id }, new List { input.RoleId.Value });
}
else
{
await _userManager.GiveUserSetRoleAsync(new List { id }, new List());
}
await UpsertUserLocationsAsync(id, mergedLocationIds);
return await GetAsync(id);
}
///
public async Task DeleteAsync(Guid id)
{
var user = await _userRepository.GetByIdAsync(id);
if (user is null || user.IsDeleted)
{
return;
}
user.IsDeleted = true;
await _userRepository.UpdateAsync(user);
var userIdString = id.ToString();
var currentUserId = CurrentUser?.Id?.ToString();
await _dbContext.SqlSugarClient.Updateable()
.SetColumns(x => new UserLocationDbEntity
{
IsDeleted = true,
LastModificationTime = DateTime.Now,
LastModifierId = currentUserId
})
.Where(x => x.UserId == userIdString && !x.IsDeleted)
.ExecuteCommandAsync();
}
///
public Task DownloadTeamMemberImportTemplateAsync()
{
var opt = _batchImportOptions.Value;
var fileName = opt.TeamMemberTemplateFileName?.Trim();
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = "Team-Member-批量导入模板.xlsx";
}
var stream = TeamMemberBatchExcelHelper.BuildImportTemplateWorkbook();
const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
return Task.FromResult(new FileStreamResult(stream, contentType)
{
FileDownloadName = fileName
});
}
///
public async Task ExportTeamMembersPdfAsync([FromQuery] TeamMemberGetListInputVo input)
{
QuestPDF.Settings.License = LicenseType.Community;
var scopeLocationIds = await TeamMemberListScopeHelper.ResolveListLocationIdsAsync(
CurrentUser,
_dbContext,
input.PartnerId,
input.GroupId,
input.LocationId);
var query = await BuildFilteredUserQueryAsync(input, scopeLocationIds);
var users = await query
.OrderByIF(!string.IsNullOrWhiteSpace(input.Sorting), input.Sorting!)
.OrderByDescending(u => u.CreationTime)
.ToListAsync();
var rows = await MapUsersToOutputAsync(
users,
scopeLocationIds,
restrictAssignedLocationsToFilter: scopeLocationIds is not null);
var regionNameMap = await LoadRegionNameMapAsync(
rows.SelectMany(r => r.RegionIds).Distinct(StringComparer.Ordinal));
var fileName = $"team-members_{Clock.Now:yyyy-MM-dd_HH-mm-ss}.pdf";
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Margin(22);
page.DefaultTextStyle(x => x.FontSize(8));
page.Header().Text("Team Members").SemiBold().FontSize(16);
page.Content().PaddingTop(8).Table(table =>
{
table.ColumnsDefinition(c =>
{
c.RelativeColumn(1.3f);
c.RelativeColumn(1.5f);
c.RelativeColumn(1.0f);
c.RelativeColumn(1.0f);
c.RelativeColumn(1.4f);
c.RelativeColumn(2.0f);
c.RelativeColumn(0.7f);
});
static IContainer CellHeader(IContainer c) =>
c.Background(Colors.Grey.Lighten3).Padding(4).DefaultTextStyle(x => x.SemiBold());
table.Cell().Element(CellHeader).Text("Name");
table.Cell().Element(CellHeader).Text("Email");
table.Cell().Element(CellHeader).Text("Phone");
table.Cell().Element(CellHeader).Text("Role");
table.Cell().Element(CellHeader).Text("Region");
table.Cell().Element(CellHeader).Text("Assigned Locations");
table.Cell().Element(CellHeader).Text("Status");
foreach (var e in rows)
{
var regionText = e.RegionIds.Count == 0
? "无"
: string.Join("; ",
e.RegionIds.Select(id =>
regionNameMap.TryGetValue(id, out var name) && !string.IsNullOrWhiteSpace(name)
? name
: id));
var locText = e.AssignedLocations.Count == 0
? "无"
: string.Join("; ",
e.AssignedLocations.Select(a =>
$"{a.LocationCode} - {a.LocationName}"));
var status = e.State ? "Active" : "Inactive";
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
.Text(e.FullName ?? string.Empty);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
.Text(e.Email ?? "无");
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
.Text(e.Phone?.ToString() ?? "无");
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
.Text(string.IsNullOrWhiteSpace(e.RoleName) ? "无" : e.RoleName);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
.Text(regionText);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
.Text(locText);
table.Cell().BorderBottom(0.5f).BorderColor(Colors.Grey.Lighten2).Padding(3)
.Text(status);
}
});
});
});
var stream = new MemoryStream();
document.GeneratePdf(stream);
stream.Position = 0;
return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
}
///
public async Task ImportTeamMembersBatchAsync(
[FromForm] TeamMemberBatchImportInputVo input)
{
if (input?.File is null || input.File.Length == 0)
{
throw new UserFriendlyException("请上传 Excel 文件(form 字段名:file)");
}
var opt = _batchImportOptions.Value;
if (input.File.Length > opt.MaxUploadBytes)
{
throw new UserFriendlyException($"文件过大,最大允许 {opt.MaxUploadBytes / 1024 / 1024} MB");
}
var ext = Path.GetExtension(input.File.FileName)?.ToLowerInvariant();
if (ext != ".xlsx")
{
throw new UserFriendlyException("仅支持 .xlsx 格式的 Excel 文件");
}
var roleMap = await BuildRoleNameToIdMapAsync();
await using var uploadStream = input.File.OpenReadStream();
var parseErrors = new List();
var rows = TeamMemberBatchExcelHelper.ParseImportWorkbook(
uploadStream,
opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows,
roleMap,
opt.TeamMemberImportDefaultPassword?.Trim() ?? string.Empty,
out var headerErrors);
parseErrors.AddRange(headerErrors);
var result = new TeamMemberBatchImportResultDto();
if (rows.Count == 0 && parseErrors.Count > 0)
{
result.Errors = parseErrors;
result.FailCount = parseErrors.Count;
return result;
}
foreach (var (rowNum, vo) in rows)
{
try
{
if (vo.RegionIds is { Count: > 0 })
{
vo.RegionIds = await ResolveRegionIdsFromImportTokensAsync(vo.RegionIds);
}
if (vo.LocationIds is { Count: > 0 })
{
vo.LocationIds = await ResolveLocationIdsFromImportTokensAsync(vo.LocationIds);
}
await CreateAsync(vo);
result.SuccessCount++;
}
catch (UserFriendlyException ex)
{
result.FailCount++;
result.Errors.Add(new TeamMemberBatchImportErrorDto
{
RowNumber = rowNum,
UserName = vo.UserName,
Message = ex.Message
});
}
}
result.Errors.InsertRange(0, parseErrors);
return result;
}
///
public async Task UpdateTeamMembersBulkAsync(
[FromBody] TeamMemberBulkUpdateInputVo input)
{
if (input?.Items is null || input.Items.Count == 0)
{
throw new UserFriendlyException("请至少提交一条编辑数据(items 不能为空)");
}
var opt = _batchImportOptions.Value;
var maxItems = opt.MaxBulkUpdateItems <= 0 ? 500 : opt.MaxBulkUpdateItems;
if (input.Items.Count > maxItems)
{
throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交");
}
var effectiveCount = input.Items.Count(static x => x is not null && x.Id != Guid.Empty);
if (effectiveCount == 0)
{
throw new UserFriendlyException("没有有效的成员 Id(请为待保存行填写 id)");
}
var result = new TeamMemberBulkUpdateResultDto();
for (var i = 0; i < input.Items.Count; i++)
{
var item = input.Items[i];
if (item is null || item.Id == Guid.Empty)
{
continue;
}
try
{
await UpdateAsync(item.Id, item);
result.SuccessCount++;
}
catch (UserFriendlyException ex)
{
result.FailCount++;
result.Errors.Add(new TeamMemberBulkUpdateErrorDto
{
RowNumber = i + 1,
Id = item.Id,
Message = ex.Message
});
}
}
return result;
}
private async Task> BuildRoleNameToIdMapAsync()
{
var roles = await _dbContext.SqlSugarClient.Queryable()
.Where(r => !r.IsDeleted)
.Select(r => new { r.Id, r.RoleName })
.ToListAsync();
return roles
.Where(r => !string.IsNullOrWhiteSpace(r.RoleName))
.GroupBy(r => TeamMemberBatchExcelHelper.NormalizeRoleKey(r.RoleName!))
.ToDictionary(g => g.Key, g => g.First().Id);
}
private async Task> ResolveLocationIdsFromImportTokensAsync(List tokens)
{
var result = new List();
foreach (var raw in tokens)
{
var s = raw.Trim();
if (string.IsNullOrEmpty(s))
{
continue;
}
var idx = s.IndexOf(" -", StringComparison.Ordinal);
var key = idx > 0 ? s[..idx].Trim() : s.Trim();
if (Guid.TryParse(key, out var gid))
{
var byId = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted && x.Id == gid)
.FirstAsync();
if (byId is null)
{
throw new UserFriendlyException($"无效门店 Id:{key}");
}
result.Add(byId.Id.ToString());
continue;
}
var byCode = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted && x.LocationCode == key)
.FirstAsync();
if (byCode is null)
{
throw new UserFriendlyException($"未找到门店 LocationCode:{key}(亦可填 location.Id Guid)");
}
result.Add(byCode.Id.ToString());
}
return result.Distinct().ToList();
}
private async Task> ResolveRegionIdsFromImportTokensAsync(List tokens)
{
var result = new List();
foreach (var raw in tokens)
{
var s = raw.Trim();
if (string.IsNullOrEmpty(s))
{
continue;
}
var idx = s.IndexOf(" -", StringComparison.Ordinal);
var key = idx > 0 ? s[..idx].Trim() : s.Trim();
if (Guid.TryParse(key, out _))
{
var byId = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted && x.Id == key)
.FirstAsync();
if (byId is null)
{
throw new UserFriendlyException($"无效 Region Id:{key}");
}
result.Add(byId.Id.Trim());
continue;
}
var matches = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted)
.ToListAsync();
matches = matches
.Where(x => string.Equals(x.GroupName?.Trim(), key, StringComparison.OrdinalIgnoreCase))
.ToList();
if (matches.Count == 0)
{
throw new UserFriendlyException($"未找到 Region:{key}(可填 fl_group.Id 或 GroupName)");
}
if (matches.Count > 1)
{
throw new UserFriendlyException(
$"Region 名称「{key}」存在多条记录,请改用 fl_group.Id(Guid)");
}
result.Add(matches[0].Id.Trim());
}
return result.Distinct(StringComparer.Ordinal).ToList();
}
private async Task> LoadRegionNameMapAsync(IEnumerable regionIds)
{
var ids = regionIds
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
if (ids.Count == 0)
{
return new Dictionary(StringComparer.Ordinal);
}
var groups = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted && ids.Contains(x.Id))
.Select(x => new { x.Id, x.GroupName })
.ToListAsync();
return groups
.Where(x => !string.IsNullOrWhiteSpace(x.Id))
.ToDictionary(
x => x.Id.Trim(),
x => x.GroupName?.Trim() ?? x.Id.Trim(),
StringComparer.Ordinal);
}
private async Task> BuildFilteredUserQueryAsync(
TeamMemberGetListInputVo input,
List? scopeLocationIds)
{
var keyword = input.Keyword?.Trim();
var query = _userRepository._DbQueryable
.Where(u => !u.IsDeleted)
.WhereIF(!string.IsNullOrWhiteSpace(keyword),
u => (u.Name != null && u.Name.Contains(keyword!)) ||
u.UserName.Contains(keyword!) ||
(u.Email != null && u.Email.Contains(keyword!)) ||
(u.Phone != null && u.Phone.ToString()!.Contains(keyword!)))
.WhereIF(input.State != null, u => u.State == input.State);
if (input.RoleId != null)
{
var userIds = await _dbContext.SqlSugarClient.Queryable()
.Where(ur => ur.RoleId == input.RoleId.Value)
.Select(ur => ur.UserId)
.ToListAsync();
query = query.Where(u => userIds.Contains(u.Id));
}
if (scopeLocationIds is not null)
{
if (scopeLocationIds.Count == 0)
{
query = query.Where(_ => false);
}
else
{
var scopeGuidSet = TeamMemberListScopeHelper.ParseGuidHashSet(scopeLocationIds);
var userLocationLinks = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted)
.Select(x => new { x.UserId, x.LocationId })
.ToListAsync();
var allowedUserGuids = userLocationLinks
.Where(x =>
Guid.TryParse(x.LocationId, out var locGuid) && scopeGuidSet.Contains(locGuid))
.Select(x => x.UserId)
.Select(TeamMemberListScopeHelper.NormalizeScopeKey)
.Where(x => !string.IsNullOrEmpty(x))
.Select(x => Guid.Parse(x))
.Distinct()
.ToList();
if (allowedUserGuids.Count == 0)
{
query = query.Where(_ => false);
}
else
{
query = query.Where(u => allowedUserGuids.Contains(u.Id));
}
}
}
return query;
}
private async Task> MapUsersToOutputAsync(
List users,
List? scopeLocationIds,
bool restrictAssignedLocationsToFilter)
{
if (users.Count == 0)
{
return new List();
}
var userIds = users.Select(x => x.Id).ToList();
var userGuidKeys = userIds.Select(TeamMemberListScopeHelper.UserKey).ToHashSet(StringComparer.Ordinal);
var userRolePairs = await _dbContext.SqlSugarClient.Queryable((ur, r) => ur.RoleId == r.Id)
.Where(ur => userIds.Contains(ur.UserId))
.Select((ur, r) => new { ur.UserId, r.Id, r.RoleName })
.ToListAsync();
var roleIdByUser = userRolePairs
.GroupBy(x => x.UserId)
.ToDictionary(g => g.Key, g => (Guid?)g.First().Id);
var allUserLocations = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted)
.Select(x => new { x.UserId, x.LocationId })
.ToListAsync();
var scopeGuidSet = scopeLocationIds is { Count: > 0 }
? TeamMemberListScopeHelper.ParseGuidHashSet(scopeLocationIds)
: null;
var userLocations = allUserLocations
.Where(x => userGuidKeys.Contains(TeamMemberListScopeHelper.NormalizeScopeKey(x.UserId)))
.Where(x =>
scopeGuidSet is null ||
(Guid.TryParse(x.LocationId, out var locGuid) && scopeGuidSet.Contains(locGuid)))
.ToList();
var locationIds = userLocations.Select(x => x.LocationId).Distinct().ToList();
var locationGuidList = TeamMemberListScopeHelper.ParseGuidList(locationIds);
var locations = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted)
.WhereIF(locationGuidList.Count > 0, x => locationGuidList.Contains(x.Id))
.Select(x => new { x.Id, x.LocationCode, x.LocationName })
.ToListAsync();
var locationMap = locations.ToDictionary(
x => TeamMemberListScopeHelper.UserKey(x.Id),
x => x);
var assignedMap = userLocations
.GroupBy(x => TeamMemberListScopeHelper.NormalizeScopeKey(x.UserId))
.ToDictionary(
g => g.Key,
g => g.Select(x =>
{
var locKey = TeamMemberListScopeHelper.NormalizeScopeKey(x.LocationId);
if (locationMap.TryGetValue(locKey, out var loc))
{
return new TeamMemberAssignedLocationDto
{
Id = loc.Id.ToString(),
LocationCode = loc.LocationCode,
LocationName = loc.LocationName
};
}
if (locationMap.TryGetValue(x.LocationId, out loc))
{
return new TeamMemberAssignedLocationDto
{
Id = loc.Id.ToString(),
LocationCode = loc.LocationCode,
LocationName = loc.LocationName
};
}
return null;
}).Where(x => x != null).Cast().ToList());
var scopeIdsMap = await BuildTeamMemberScopeIdsMapAsync(assignedMap, roleIdByUser);
var items = new List();
foreach (var u in users)
{
roleIdByUser.TryGetValue(u.Id, out var listRoleId);
var roleName = userRolePairs.FirstOrDefault(x => x.UserId == u.Id)?.RoleName;
var userKey = TeamMemberListScopeHelper.UserKey(u.Id);
assignedMap.TryGetValue(userKey, out var assigned);
scopeIdsMap.TryGetValue(userKey, out var scopeIds);
var partnerIds = scopeIds?.PartnerIds ?? new List();
var regionIds = scopeIds?.RegionIds ?? new List();
var assignedLocations = assigned ?? new List();
(regionIds, assignedLocations) = await ApplyCompanyAdminDisplayScopeAsync(
listRoleId, partnerIds, regionIds, assignedLocations);
var locationIdList = assignedLocations
.Select(x => x.Id)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
items.Add(new TeamMemberGetListOutputDto
{
Id = u.Id,
FullName = u.Name ?? string.Empty,
UserName = u.UserName,
Email = u.Email,
Phone = u.Phone,
State = u.State,
RoleId = listRoleId,
RoleName = TeamMemberRoleHelper.FormatDisplayRoleName(roleName),
PartnerIds = partnerIds,
RegionIds = regionIds,
LocationIds = locationIdList,
AssignedLocations = assignedLocations
});
}
return items;
}
private async Task> BuildTeamMemberScopeIdsMapAsync(
Dictionary> assignedMap,
Dictionary roleIdByUser)
{
var result = new Dictionary(StringComparer.Ordinal);
foreach (var (userId, assigned) in assignedMap)
{
var locationIds = assigned
.Select(x => x.Id)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
var partnerIds = locationIds.Count == 0
? new List()
: await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(
_dbContext.SqlSugarClient, locationIds);
var regionIds = locationIds.Count == 0
? new List()
: await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
_dbContext.SqlSugarClient, locationIds);
if (Guid.TryParse(userId, out var userGuid) &&
roleIdByUser.TryGetValue(userGuid, out var roleId) &&
await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, roleId) &&
partnerIds.Count > 0)
{
var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(
_dbContext.SqlSugarClient, partnerIds);
if (allRegions.Count > 0)
{
regionIds = allRegions;
}
}
result[userId] = new TeamMemberScopeIds
{
PartnerIds = partnerIds,
RegionIds = regionIds
};
}
return result;
}
///
/// Company Admin 列表/详情:展示所选 Company 下全部 Region 与门店。
///
private async Task<(List RegionIds, List AssignedLocations)>
ApplyCompanyAdminDisplayScopeAsync(
Guid? roleId,
List partnerIds,
List regionIds,
List assigned)
{
if (!await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, roleId) ||
partnerIds.Count == 0)
{
return (regionIds, assigned);
}
var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(
_dbContext.SqlSugarClient, partnerIds);
var allLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(
_dbContext.SqlSugarClient, partnerIds);
var allAssigned = await BuildAssignedLocationDtosAsync(allLocationIds);
return (
allRegions.Count > 0 ? allRegions : regionIds,
allAssigned.Count > 0 ? allAssigned : assigned);
}
private async Task> BuildAssignedLocationDtosAsync(
IReadOnlyList locationIds)
{
var ids = LocationScopeBindingHelper.NormalizeIds(locationIds);
if (ids.Count == 0)
{
return new List();
}
var guidList = ids
.Select(x => Guid.TryParse(x, out var g) ? g : (Guid?)null)
.Where(x => x.HasValue)
.Select(x => x!.Value)
.ToList();
if (guidList.Count == 0)
{
return new List();
}
var locations = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted && guidList.Contains(x.Id))
.Select(x => new { x.Id, x.LocationCode, x.LocationName })
.ToListAsync();
return locations
.OrderBy(x => x.LocationCode)
.Select(x => new TeamMemberAssignedLocationDto
{
Id = x.Id.ToString(),
LocationCode = x.LocationCode,
LocationName = x.LocationName
})
.ToList();
}
private sealed class TeamMemberScopeIds
{
public List PartnerIds { get; init; } = new();
public List RegionIds { get; init; } = new();
}
private Task> ResolveTeamMemberLocationIdsForSaveAsync(TeamMemberUpdateInputVo input) =>
ResolveTeamMemberLocationIdsForSaveAsync(new TeamMemberCreateInputVo
{
PartnerId = input.PartnerId,
PartnerIds = input.PartnerIds,
RegionIds = input.RegionIds,
GroupIds = input.GroupIds,
LocationIds = input.LocationIds
}, input.RoleId);
private async Task> ResolveTeamMemberLocationIdsForSaveAsync(
TeamMemberCreateInputVo input,
Guid? roleId)
{
var partnerIds = NormalizePartnerIds(input);
var regionIds = NormalizeRegionIds(input);
var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds);
var isCompanyAdmin = await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(
_dbContext.SqlSugarClient, roleId);
if (isCompanyAdmin && partnerIds.Count > 0 &&
regionIds.Count == 0 && explicitLocationIds.Count == 0)
{
var fromPartner = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
_dbContext.SqlSugarClient, partnerIds, null, null);
if (fromPartner.Count == 0)
{
throw new UserFriendlyException("Company Admin 需绑定公司下门店,所选公司下暂无可用门店");
}
await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, fromPartner);
return fromPartner;
}
var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
_dbContext.SqlSugarClient, partnerIds, regionIds, input.LocationIds);
if (merged.Count == 0)
{
throw new UserFriendlyException(
isCompanyAdmin
? "Company Admin 必须选择 Company,或指定 Region / 门店"
: "成员必须至少分配一个门店(公司/区域/门店至少选一项)");
}
await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged);
return merged;
}
private static List NormalizePartnerIds(TeamMemberCreateInputVo input)
{
var merged = new HashSet(StringComparer.Ordinal);
if (!string.IsNullOrWhiteSpace(input.PartnerId))
{
merged.Add(input.PartnerId.Trim());
}
foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.PartnerIds))
{
merged.Add(id);
}
return merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
}
private static List NormalizeRegionIds(TeamMemberCreateInputVo input)
{
var merged = new HashSet(StringComparer.Ordinal);
foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.RegionIds))
{
merged.Add(id);
}
foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.GroupIds))
{
merged.Add(id);
}
return merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
}
private async Task UpsertUserLocationsAsync(Guid userId, List locationIds)
{
var now = DateTime.Now;
var userIdString = userId.ToString();
var wanted = locationIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList();
var currentUserId = CurrentUser?.Id?.ToString();
var validCount = await _dbContext.SqlSugarClient.Queryable()
.Where(x => !x.IsDeleted)
.Where(x => wanted.Contains(x.Id.ToString()))
.CountAsync();
if (validCount != wanted.Count)
{
throw new UserFriendlyException("存在无效门店,请刷新后重试");
}
var existing = await _dbContext.SqlSugarClient.Queryable()
.Where(x => x.UserId == userIdString)
.ToListAsync();
var existingActive = existing.Where(x => !x.IsDeleted).ToList();
var existingActiveSet = existingActive.Select(x => x.LocationId).ToHashSet();
var toDelete = existingActive.Where(x => !wanted.Contains(x.LocationId)).ToList();
if (toDelete.Count > 0)
{
var ids = toDelete.Select(x => x.Id).ToList();
await _dbContext.SqlSugarClient.Updateable()
.SetColumns(x => new UserLocationDbEntity
{
IsDeleted = true,
LastModificationTime = now,
LastModifierId = currentUserId
})
.Where(x => ids.Contains(x.Id))
.ExecuteCommandAsync();
}
var toInsert = wanted.Where(x => !existingActiveSet.Contains(x)).ToList();
if (toInsert.Count > 0)
{
var rows = toInsert.Select(locationId => new UserLocationDbEntity
{
Id = _guidGenerator.Create().ToString(),
IsDeleted = false,
CreationTime = now,
CreatorId = currentUserId,
UserId = userIdString,
LocationId = locationId,
ConcurrencyStamp = string.Empty
}).ToList();
await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync();
}
}
}