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 LocationScopeBindingHelper.ResolveFilteredLocationIdsForListAsync( _dbContext.SqlSugarClient, 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); 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); 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 dir = opt.TemplateDirectory?.Trim(); if (string.IsNullOrWhiteSpace(dir)) { throw new UserFriendlyException("未配置批量导入模板目录 FoodLabeling:BatchImport:TemplateDirectory"); } var fileName = opt.TeamMemberTemplateFileName?.Trim(); if (string.IsNullOrWhiteSpace(fileName)) { throw new UserFriendlyException("未配置模板文件名 FoodLabeling:BatchImport:TeamMemberTemplateFileName"); } var fullPath = Path.Combine(dir, fileName); if (!File.Exists(fullPath)) { throw new UserFriendlyException($"模板文件不存在:{fullPath}"); } var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); 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 LocationScopeBindingHelper.ResolveFilteredLocationIdsForListAsync( _dbContext.SqlSugarClient, 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 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.4f); c.RelativeColumn(1.6f); c.RelativeColumn(1.1f); c.RelativeColumn(1.1f); c.RelativeColumn(2.2f); 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("Assigned Locations"); table.Cell().Element(CellHeader).Text("Status"); foreach (var e in rows) { 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(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 { 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($"未找到门店编码:{key}"); } result.Add(byCode.Id.ToString()); } return result.Distinct().ToList(); } 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 scopeSet = new HashSet(scopeLocationIds, StringComparer.Ordinal); var userIdStrs = await _dbContext.SqlSugarClient.Queryable() .Where(x => !x.IsDeleted && scopeSet.Contains(x.LocationId)) .Select(x => x.UserId) .ToListAsync(); var allowed = new HashSet(userIdStrs); query = query.Where(u => allowed.Contains(u.Id.ToString())); } } 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 userIdStrings = userIds.Select(x => x.ToString()).ToList(); 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 roleMap = userRolePairs .GroupBy(x => x.UserId) .ToDictionary(g => g.Key, g => g.FirstOrDefault()); var userLocQuery = _dbContext.SqlSugarClient.Queryable() .Where(x => !x.IsDeleted) .Where(x => userIdStrings.Contains(x.UserId)); if (restrictAssignedLocationsToFilter && scopeLocationIds is { Count: > 0 }) { var scopeSet = new HashSet(scopeLocationIds, StringComparer.Ordinal); userLocQuery = userLocQuery.Where(x => scopeSet.Contains(x.LocationId)); } var userLocations = await userLocQuery.ToListAsync(); var locationIds = userLocations.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 locationMap = locations.ToDictionary(x => x.Id.ToString(), x => x); var assignedMap = userLocations .GroupBy(x => x.UserId) .ToDictionary( g => g.Key, g => g.Select(x => { if (locationMap.TryGetValue(x.LocationId, out var 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); return users.Select(u => { roleMap.TryGetValue(u.Id, out var role); assignedMap.TryGetValue(u.Id.ToString(), out var assigned); scopeIdsMap.TryGetValue(u.Id.ToString(), out var scopeIds); return new TeamMemberGetListOutputDto { Id = u.Id, FullName = u.Name ?? string.Empty, UserName = u.UserName, Email = u.Email, Phone = u.Phone, State = u.State, RoleId = role?.Id, RoleName = role?.RoleName, PartnerIds = scopeIds?.PartnerIds ?? new List(), RegionIds = scopeIds?.RegionIds ?? new List(), AssignedLocations = assigned ?? new List() }; }).ToList(); } private async Task> BuildTeamMemberScopeIdsMapAsync( Dictionary> assignedMap) { 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(); if (locationIds.Count == 0) { result[userId] = new TeamMemberScopeIds(); continue; } var partnerIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); var regionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync( _dbContext.SqlSugarClient, locationIds); result[userId] = new TeamMemberScopeIds { PartnerIds = partnerIds, RegionIds = regionIds }; } return result; } 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 }); private async Task> ResolveTeamMemberLocationIdsForSaveAsync(TeamMemberCreateInputVo input) { var partnerIds = NormalizePartnerIds(input); var regionIds = NormalizeRegionIds(input); var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync( _dbContext.SqlSugarClient, partnerIds, regionIds, input.LocationIds); if (merged.Count == 0) { throw new UserFriendlyException("成员必须至少分配一个门店(公司/区域/门店至少选一项)"); } 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(); } } }