using System.Globalization; using ClosedXML.Excel; using FoodLabeling.Application.Contracts.Dtos.TeamMember; namespace FoodLabeling.Application.Helpers; /// /// Team Member 批量导入 Excel(列名与 Account Management 表格对齐,兼容常见别名) /// public static class TeamMemberBatchExcelHelper { /// 导入/下载模板表头(与 PDF 导出 Region 列对齐) public static readonly string[] ImportTemplateHeaders = { "Name", "User Name", "Password", "Email", "Phone", "Role Id", "Role Name", "Region", "Assigned Location Ids", "Status" }; /// 生成批量导入模板 xlsx(含 Region 可选列说明行) public static MemoryStream BuildImportTemplateWorkbook() { var ms = new MemoryStream(); using var wb = new XLWorkbook(); var ws = wb.AddWorksheet("TeamMembers"); for (var i = 0; i < ImportTemplateHeaders.Length; i++) { ws.Cell(1, i + 1).Value = ImportTemplateHeaders[i]; ws.Cell(1, i + 1).Style.Font.Bold = true; } ws.Cell(2, 1).Value = "John Doe"; ws.Cell(2, 2).Value = "john.doe"; ws.Cell(2, 3).Value = "ChangeMe123!"; ws.Cell(2, 4).Value = "john@example.com"; ws.Cell(2, 5).Value = "789654444"; ws.Cell(2, 6).Value = ""; ws.Cell(2, 7).Value = "Staff"; ws.Cell(2, 8).Value = ""; ws.Cell(2, 9).Value = "LOC001;LOC002"; ws.Cell(2, 10).Value = "TRUE"; ws.Cell(3, 7).Value = "(Role Name 与系统角色名一致;Company Admin 可只填 Region 或留空由 Company 规则处理)"; ws.Cell(4, 8).Value = "(可选)Region 名称或 fl_group.Id,多个用 ; 分隔"; ws.Cell(5, 9).Value = "(可选)门店 LocationCode 或 location.Id(Guid),多个用 ; 分隔;与 Region 至少填一项"; ws.Columns().AdjustToContents(); wb.SaveAs(ms); ms.Position = 0; return ms; } /// /// 从上传的 Excel 解析为创建入参列表(行号从 2 起为数据行)。 /// /// xlsx 流 /// 最多数据行 /// 角色名(忽略大小写、去空白)到角色 Id /// 未填 Password 列时使用 /// 表头或解析错误 public static List<(int RowNumber, TeamMemberCreateInputVo Input)> ParseImportWorkbook( Stream stream, int maxRows, IReadOnlyDictionary roleNameToId, string defaultPassword, out List parseErrors) { parseErrors = new List(); var result = new List<(int, TeamMemberCreateInputVo)>(); if (string.IsNullOrWhiteSpace(defaultPassword)) { parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = 0, Message = "未配置默认导入密码 FoodLabeling:BatchImport:TeamMemberImportDefaultPassword" }); return result; } using var wb = new XLWorkbook(stream); var ws = wb.Worksheets.FirstOrDefault(); if (ws is null) { parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = 0, Message = "Excel 中无工作表" }); return result; } var headerRow = ws.Row(1); if (!headerRow.CellsUsed().Any()) { parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = 1, Message = "表头为空" }); return result; } var colMap = BuildHeaderColumnMap(headerRow); if (!colMap.ContainsKey("fullname") || !colMap.ContainsKey("email")) { parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = 1, Message = "未找到「Name」与「Email」列(或同义表头),请使用官方模板" }); return result; } var lastRow = ws.LastRowUsed()?.RowNumber() ?? 1; var dataRowCount = 0; for (var rowNum = 2; rowNum <= lastRow; rowNum++) { if (dataRowCount >= maxRows) { parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = rowNum, Message = $"已超过单次导入上限 {maxRows} 行,后续行已忽略" }); break; } var fullName = GetCellByField(colMap, ws, rowNum, "fullname"); var email = GetCellByField(colMap, ws, rowNum, "email"); if (string.IsNullOrWhiteSpace(fullName) && string.IsNullOrWhiteSpace(email) && IsRowEmpty(colMap, ws, rowNum)) { continue; } dataRowCount++; var errPrefix = $"第 {rowNum} 行"; try { var input = BuildCreateInputFromRow( colMap, ws, rowNum, roleNameToId, defaultPassword, out var rowErrs, out var userNameHint); if (rowErrs.Count > 0) { foreach (var e in rowErrs) { parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = rowNum, UserName = userNameHint, Message = $"{errPrefix}:{e}" }); } continue; } result.Add((rowNum, input!)); } catch (Exception ex) { parseErrors.Add(new TeamMemberBatchImportErrorDto { RowNumber = rowNum, Message = $"{errPrefix}:{ex.Message}" }); } } return result; } private static Dictionary BuildHeaderColumnMap(IXLRow headerRow) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var cell in headerRow.CellsUsed()) { var key = NormalizeHeaderKey(cell.GetString()); if (string.IsNullOrEmpty(key)) { continue; } var field = MapHeaderToField(key); if (field is null) { continue; } if (!map.ContainsKey(field)) { map[field] = cell.Address.ColumnNumber; } } return map; } private static string? MapHeaderToField(string normalizedHeader) { return normalizedHeader switch { "name" or "fullname" or "姓名" or "成员姓名" => "fullname", "email" or "邮箱" or "e-mail" => "email", "username" or "login" or "userid" or "账号" or "用户名" => "username", "password" or "pwd" or "密码" => "password", "phone" or "mobile" or "电话" or "手机" => "phone", "role" or "rolename" or "角色" => "rolename", "roleid" or "角色id" => "roleid", "region" or "regions" or "group" or "groupname" or "groupid" or "区域" => "regions", "assignedlocations" or "assignedlocationids" or "locationids" or "locationcodes" or "locations" or "location" or "分配门店" or "门店" or "门店id" => "locations", "status" or "active" or "state" or "启用" => "status", _ => null }; } private static string NormalizeHeaderKey(string raw) { var s = raw.Trim(); if (s.Length > 0 && s[0] == '\uFEFF') { s = s.TrimStart('\uFEFF'); } s = s.Trim().TrimStart('*'); return string.Concat(s.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant(); } private static bool IsRowEmpty(Dictionary colMap, IXLWorksheet ws, int rowNum) { foreach (var col in colMap.Values) { var t = ws.Cell(rowNum, col).GetString().Trim(); if (!string.IsNullOrEmpty(t)) { return false; } } return true; } private static string GetCellByField(Dictionary colMap, IXLWorksheet ws, int row, string field) { if (!colMap.TryGetValue(field, out var col)) { return string.Empty; } return ws.Cell(row, col).GetString().Trim(); } private static TeamMemberCreateInputVo? BuildCreateInputFromRow( Dictionary colMap, IXLWorksheet ws, int rowNum, IReadOnlyDictionary roleNameToId, string defaultPassword, out List errors, out string? userNameHint) { errors = new List(); userNameHint = null; var fullName = GetCellByField(colMap, ws, rowNum, "fullname"); var email = GetCellByField(colMap, ws, rowNum, "email"); var userName = GetCellByField(colMap, ws, rowNum, "username"); var password = GetCellByField(colMap, ws, rowNum, "password"); var phoneStr = GetCellByField(colMap, ws, rowNum, "phone"); var roleIdCell = GetCellByField(colMap, ws, rowNum, "roleid"); var roleName = GetCellByField(colMap, ws, rowNum, "rolename"); var regionsCell = GetCellByField(colMap, ws, rowNum, "regions"); var locationsCell = GetCellByField(colMap, ws, rowNum, "locations"); var statusStr = GetCellByField(colMap, ws, rowNum, "status"); if (string.IsNullOrWhiteSpace(fullName)) { errors.Add("Name 不能为空"); } if (string.IsNullOrWhiteSpace(email)) { errors.Add("Email 不能为空"); } var login = string.IsNullOrWhiteSpace(userName) ? email.Trim() : userName.Trim(); userNameHint = login; if (string.IsNullOrWhiteSpace(login)) { errors.Add("登录账号不能为空(可填 UserName 列,否则使用 Email)"); } var pwd = string.IsNullOrWhiteSpace(password) ? defaultPassword : password.Trim(); if (string.IsNullOrWhiteSpace(pwd)) { errors.Add("Password 不能为空且未配置默认密码"); } long? phone = null; if (!string.IsNullOrWhiteSpace(phoneStr)) { if (!long.TryParse(RegexDigitsOnly(phoneStr), NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) { errors.Add("Phone 格式不正确(需为数字)"); } else { phone = p; } } Guid? roleIdResolved = null; if (Guid.TryParse(roleIdCell?.Trim(), out var roleGuid)) { roleIdResolved = roleGuid; } else if (string.IsNullOrWhiteSpace(roleName)) { errors.Add("Role Name 不能为空(或填写有效的 Role Id Guid)"); } else if (!roleNameToId.TryGetValue(NormalizeRoleKey(roleName), out var rid)) { errors.Add($"未找到角色「{roleName.Trim()}」,请与系统角色名称一致"); } else { roleIdResolved = rid; } var regionTokens = SplitMultiValueTokens(regionsCell); var locationTokens = SplitMultiValueTokens(locationsCell); var isCompanyAdmin = TeamMemberRoleHelper.IsCompanyAdminRoleName(roleName); if (regionTokens.Count == 0 && locationTokens.Count == 0 && !isCompanyAdmin) { errors.Add("Region 与 Assigned Location Ids 至少填一项(均可留空时仅适用于 Company Admin 且已在 Web 端配置 Company)"); } if (errors.Count > 0) { return null; } var state = ParseBool(statusStr, defaultValue: true); return new TeamMemberCreateInputVo { FullName = fullName.Trim(), Email = string.IsNullOrWhiteSpace(email) ? null : email.Trim(), UserName = login, Password = pwd, Phone = phone, RoleId = roleIdResolved, RegionIds = regionTokens, LocationIds = locationTokens, State = state }; } public static string NormalizeRoleKey(string roleName) { return string.Concat(roleName.Trim().Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant(); } /// /// 拆分单元格为多个 token(门店/区域等;后续由服务层解析为 Id)。 /// public static List SplitMultiValueTokens(string cell) { if (string.IsNullOrWhiteSpace(cell)) { return new List(); } return cell .Split(new[] { ';', '|', '\n', '\r', ',' }, StringSplitOptions.RemoveEmptyEntries) .Select(x => x.Trim()) .Where(x => !string.IsNullOrEmpty(x)) .ToList(); } /// /// 兼容旧调用。 /// public static List SplitLocationTokens(string locationsCell) => SplitMultiValueTokens(locationsCell); private static string RegexDigitsOnly(string s) { return new string(s.Where(char.IsDigit).ToArray()); } private static bool ParseBool(string? raw, bool defaultValue) { if (string.IsNullOrWhiteSpace(raw)) { return defaultValue; } var s = raw.Trim(); if (bool.TryParse(s, out var b)) { return b; } if (int.TryParse(s, out var n)) { return n != 0; } if (string.Equals(s, "active", StringComparison.OrdinalIgnoreCase) || string.Equals(s, "是", StringComparison.Ordinal) || string.Equals(s, "Y", StringComparison.OrdinalIgnoreCase) || string.Equals(s, "Yes", StringComparison.OrdinalIgnoreCase)) { return true; } if (string.Equals(s, "inactive", StringComparison.OrdinalIgnoreCase) || string.Equals(s, "否", StringComparison.Ordinal) || string.Equals(s, "N", StringComparison.OrdinalIgnoreCase) || string.Equals(s, "No", StringComparison.OrdinalIgnoreCase)) { return false; } return defaultValue; } }