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(); } } }