using OfficeOpenXml; using OfficeOpenXml.Style; using SixLabors.ImageSharp; using System.Text.Json; namespace SimpleAdmin.Application; /// /// 实现员工档案查询、人事状态维护和明细导出能力。 /// public class OnboardingEmployeeService : IOnboardingEmployeeService { private const string EmployeeExportTemplateFileName = "员工导出模版.xlsx"; private readonly SqlSugarScope _db; private readonly IOnboardingDataPermissionService _dataPermission; public OnboardingEmployeeService(IOnboardingDataPermissionService dataPermission) { _db = DbContext.DB; _dataPermission = dataPermission; } /// /// 分页查询审核通过后生成的员工档案及维度统计。 /// public async Task Page(OnboardingEmployeePageInput input) { var list = await BuildEmployeeQuery(input) .OrderByDescending(item => item.CreateTime) .ToPagedListAsync(input.PageNum, input.PageSize); if (list.List != null && list.List.Any()) await FillEmployeeMetadata(list.List); var stats = await CalculateStatistics(input); return new OnboardingEmployeePageOutput { PageNum = list.PageNum, PageSize = list.PageSize, Total = list.Total, Pages = list.Pages, List = list.List, HasPrevPages = list.HasPrevPages, HasNextPages = list.HasNextPages, Statistics = stats }; } /// public async Task Export(OnboardingEmployeePageInput input) { input ??= new OnboardingEmployeePageInput(); var employees = await BuildEmployeeQuery(input) .OrderBy(item => item.CompanyId) .OrderBy(item => item.DepartmentId) .OrderBy(item => item.PositionId) .OrderBy(item => item.EmployeeName) .ToListAsync(); if (employees.Count == 0) throw Oops.Bah("当前筛选条件没有可导出的员工"); await FillEmployeeMetadata(employees); var employeeIds = employees.Select(item => item.Id).ToList(); var tasks = await _db.Queryable() .Where(item => item.EmployeeId.HasValue && employeeIds.Contains(item.EmployeeId.Value)) .OrderByDescending(item => item.CreateTime) .ToListAsync(); var taskIds = tasks.Select(item => item.Id).Distinct().ToList(); var employeeInfos = taskIds.Count == 0 ? new List() : await _db.Queryable() .Where(item => taskIds.Contains(item.TaskId)) .ToListAsync(); var snapshots = await _db.Queryable() .Where(item => employeeIds.Contains(item.EmployeeId)) .OrderBy(item => item.EmployeeId) .OrderBy(item => item.SnapshotAt) .OrderBy(item => item.Id) .ToListAsync(); var taskByEmployeeId = tasks .Where(item => item.EmployeeId.HasValue) .GroupBy(item => item.EmployeeId.Value) .ToDictionary(group => group.Key, group => group.First()); var employeeInfoByTaskId = employeeInfos .GroupBy(item => item.TaskId) .ToDictionary(group => group.Key, group => group.First()); var snapshotsByEmployeeId = snapshots .GroupBy(item => item.EmployeeId) .ToDictionary(group => group.Key, group => group.ToList()); var employeeRows = employees.Select(employee => { var info = DeserializeEmployeeInfo(employee.ExtraInfoJson); if (info == null && taskByEmployeeId.TryGetValue(employee.Id, out var task) && employeeInfoByTaskId.TryGetValue(task.Id, out var taskInfo)) { info = taskInfo; } var employeeSnapshots = snapshotsByEmployeeId.GetValueOrDefault(employee.Id) ?? new List(); return new EmployeeExportContext { Employee = employee, EmployeeInfo = info, PrimaryContract = SelectPrimaryContract(employeeSnapshots) }; }).ToList(); var templatePath = ResolveEmployeeExportTemplatePath(); using var package = new ExcelPackage(new FileInfo(templatePath)); package.Workbook.Properties.Title = "员工明细"; package.Workbook.Properties.Subject = "员工基本、人事状态及主要合同信息"; package.Workbook.Properties.Created = DateTime.Now; var worksheet = package.Workbook.Worksheets.FirstOrDefault(item => item.Dimension != null && string.Equals(item.Cells[1, 1].Text.Trim(), "姓名", StringComparison.OrdinalIgnoreCase)) ?? throw Oops.Bah("员工导出模板未找到以“姓名”开头的表头行,请检查模板内容"); WriteEmployeeSheet(worksheet, employeeRows); return new OnboardingEmployeeExportFile { FileBytes = package.GetAsByteArray(), FileName = $"员工明细_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx" }; } /// /// 查询正式员工及其来源任务、合同和审核记录。 /// public async Task Detail(BaseIdInput input) { var employee = await _db.Queryable().InSingleAsync(input.Id) ?? throw Oops.Bah("员工档案不存在"); var task = await _db.Queryable().FirstAsync(x => x.EmployeeId == employee.Id); await FillEmployeeMetadata(new[] { employee }); var employeeInfo = task == null ? null : await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id); if (employeeInfo == null && !string.IsNullOrWhiteSpace(employee.ExtraInfoJson)) { try { employeeInfo = global::System.Text.Json.JsonSerializer.Deserialize( employee.ExtraInfoJson); } catch (global::System.Text.Json.JsonException) { // 兼容历史异常快照:员工主档仍可正常查看,其余信息显示为空。 } } var contracts = task == null ? new List() : await _db.Queryable() .Where(item => item.TaskId == task.Id) .ToListAsync(); var contractSnapshots = await _db.Queryable() .Where(item => item.EmployeeId == employee.Id) .OrderBy(item => item.CreateTime) .OrderBy(item => item.Id) .ToListAsync(); var materials = await GetEmployeeMaterials(task?.Id); var electronicSignProcess = task == null ? null : await _db.Queryable() .Where(item => item.TaskId == task.Id && item.ArchiveFileId.HasValue) .OrderByDescending(item => item.Id) .FirstAsync(); var audits = task == null ? new List() : await _db.Queryable() .Where(item => item.TaskId == task.Id) .OrderByDescending(item => item.AuditedAt) .ToListAsync(); var changeRecords = await GetEmployeeChangeRecords(employee.Id); return new OnboardingEmployeeDetailOutput { Employee = employee, Task = task, EmployeeInfo = employeeInfo, Contracts = contracts, ContractSnapshots = contractSnapshots, Materials = materials, ElectronicSignArchiveFileId = electronicSignProcess?.ArchiveFileId, Audits = audits, ChangeRecords = changeRecords }; } /// public async Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input) { await OnboardingConcurrency.RunEmployee(_db, input.Id, () => UpdateHrInfoCore(input)); } private async Task UpdateHrInfoCore(OnboardingEmployeeHrUpdateInput input) { ValidateEmploymentType(input.EmploymentType); ValidateSocialInsuranceStatus(input.SocialInsuranceStatus); var employee = await _db.Queryable().InSingleAsync(input.Id) ?? throw Oops.Bah("员工档案不存在"); await EnsureEmployeeCanManage(employee); var before = CreateHrState(employee); var regularizationStatus = employee.RegularizationStatus; var regularizationDate = employee.RegularizationDate; if (input.EmploymentType == EmployeeEmploymentType.PartTime) { regularizationStatus = EmployeeRegularizationStatus.NotApplicable; regularizationDate = null; } else if (employee.EmploymentType == EmployeeEmploymentType.PartTime || string.IsNullOrWhiteSpace(regularizationStatus) || regularizationStatus == EmployeeRegularizationStatus.NotApplicable) { regularizationStatus = EmployeeRegularizationStatus.Probation; regularizationDate = null; } var contractNature = NormalizeOptionalText(input.ContractNature); var socialInsuranceCompany = NormalizeOptionalText(input.SocialInsuranceCompany); var noChanges = employee.EmploymentType == input.EmploymentType && employee.RegularizationStatus == regularizationStatus && employee.RegularizationDate == regularizationDate && string.Equals(employee.ContractNature, contractNature, StringComparison.Ordinal) && employee.SocialInsuranceStatus == input.SocialInsuranceStatus && string.Equals( employee.SocialInsuranceCompany, socialInsuranceCompany, StringComparison.Ordinal); if (noChanges) return; var after = new { employmentType = input.EmploymentType, regularizationStatus, regularizationDate, contractNature, socialInsuranceStatus = input.SocialInsuranceStatus, socialInsuranceCompany }; await _db.Ado.BeginTranAsync(); try { await _db.Updateable() .SetColumns(item => new ObEmployee { EmploymentType = input.EmploymentType, RegularizationStatus = regularizationStatus, RegularizationDate = regularizationDate, ContractNature = contractNature, SocialInsuranceStatus = input.SocialInsuranceStatus, SocialInsuranceCompany = socialInsuranceCompany, UpdateTime = DateTime.Now, UpdateUserId = UserManager.UserId, UpdateUser = UserManager.Name }) .Where(item => item.Id == employee.Id) .ExecuteCommandAsync(); await InsertChangeRecord( employee.Id, EmployeeChangeType.HrInfoUpdate, null, before, after, "编辑员工人事信息"); await _db.Ado.CommitTranAsync(); } catch { await _db.Ado.RollbackTranAsync(); throw; } } /// public async Task Regularize(OnboardingEmployeeRegularizeInput input) { await OnboardingConcurrency.RunEmployee(_db, input.Id, () => RegularizeCore(input)); } private async Task RegularizeCore(OnboardingEmployeeRegularizeInput input) { if (!input.RegularizationDate.HasValue) throw Oops.Bah("请选择转正日期"); var employee = await _db.Queryable().InSingleAsync(input.Id) ?? throw Oops.Bah("员工档案不存在"); await EnsureEmployeeCanManage(employee); if (employee.EmploymentType == EmployeeEmploymentType.PartTime) throw Oops.Bah("兼职员工不适用转正流程,请先调整用工类型"); if (employee.RegularizationStatus == EmployeeRegularizationStatus.Regular) throw Oops.Bah("该员工已经转正,请勿重复办理"); var regularizationDate = input.RegularizationDate.Value.Date; if (employee.EntryDate.HasValue && regularizationDate < employee.EntryDate.Value.Date) throw Oops.Bah("转正日期不能早于入职日期"); var fileIds = (input.FileIds ?? new List()) .Where(item => item > 0) .Distinct() .ToList(); var files = fileIds.Count == 0 ? new List() : await _db.Queryable() .Where(item => fileIds.Contains(item.Id)) .ToListAsync(); if (files.Count != fileIds.Count) throw Oops.Bah("部分转正材料不存在或已被删除,请重新上传"); var before = CreateHrState(employee); var after = new { employee.EmploymentType, regularizationStatus = EmployeeRegularizationStatus.Regular, regularizationDate, employee.ContractNature, employee.SocialInsuranceStatus, employee.SocialInsuranceCompany }; await _db.Ado.BeginTranAsync(); try { var changedRows = await _db.Updateable() .SetColumns(item => new ObEmployee { RegularizationStatus = EmployeeRegularizationStatus.Regular, RegularizationDate = regularizationDate, UpdateTime = DateTime.Now, UpdateUserId = UserManager.UserId, UpdateUser = UserManager.Name }) .Where(item => item.Id == employee.Id && item.RegularizationStatus != EmployeeRegularizationStatus.Regular) .ExecuteCommandAsync(); if (changedRows != 1) throw Oops.Bah("该员工状态已发生变化,请刷新后重试"); var changeRecord = await InsertChangeRecord( employee.Id, EmployeeChangeType.Regularization, regularizationDate, before, after, NormalizeOptionalText(input.Remark)); if (files.Count > 0) { var now = DateTime.Now; var attachments = files.Select(file => new ObEmployeeAttachment { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), EmployeeId = employee.Id, ChangeRecordId = changeRecord.Id, BusinessType = EmployeeAttachmentBusinessType.Regularization, FileId = file.Id, FileName = file.Name, UploadedAt = now, Status = CommonStatusConst.ENABLE, CreateTime = now, CreateUserId = UserManager.UserId, CreateUser = UserManager.Name }).ToList(); await _db.Insertable(attachments).ExecuteCommandAsync(); } await _db.Ado.CommitTranAsync(); } catch { await _db.Ado.RollbackTranAsync(); throw; } } /// public async Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input) { await OnboardingConcurrency.RunEmployee(_db, input.Id, () => RevokeRegularizationCore(input)); } private async Task RevokeRegularizationCore(OnboardingEmployeeRegularizationRevokeInput input) { if (string.IsNullOrWhiteSpace(input.Remark)) throw Oops.Bah("请填写撤销原因"); var employee = await _db.Queryable().InSingleAsync(input.Id) ?? throw Oops.Bah("员工档案不存在"); await EnsureEmployeeCanManage(employee); if (employee.RegularizationStatus != EmployeeRegularizationStatus.Regular) throw Oops.Bah("该员工当前不是已转正状态"); var before = CreateHrState(employee); var after = new { employee.EmploymentType, regularizationStatus = EmployeeRegularizationStatus.Probation, regularizationDate = (DateTime?)null, employee.ContractNature, employee.SocialInsuranceStatus, employee.SocialInsuranceCompany }; await _db.Ado.BeginTranAsync(); try { var changedRows = await _db.Updateable() .SetColumns(item => new ObEmployee { RegularizationStatus = EmployeeRegularizationStatus.Probation, RegularizationDate = null, UpdateTime = DateTime.Now, UpdateUserId = UserManager.UserId, UpdateUser = UserManager.Name }) .Where(item => item.Id == employee.Id && item.RegularizationStatus == EmployeeRegularizationStatus.Regular) .ExecuteCommandAsync(); if (changedRows != 1) throw Oops.Bah("该员工状态已发生变化,请刷新后重试"); await InsertChangeRecord( employee.Id, EmployeeChangeType.RegularizationRevoke, DateTime.Now.Date, before, after, input.Remark.Trim()); await _db.Ado.CommitTranAsync(); } catch { await _db.Ado.RollbackTranAsync(); throw; } } /// /// 查询员工来源任务中上传的全部材料,并补充原始文件名、后缀和文件大小。 /// private Task EnsureEmployeeCanManage(ObEmployee employee) { return _dataPermission.EnsureCanManage( employee.CreateUserId, OnboardingPermissionCodes.EmployeeManageAll, "员工档案"); } private async Task> GetEmployeeMaterials(long? taskId) { if (!taskId.HasValue) return new List(); var materials = await _db.Queryable() .Where(item => item.TaskId == taskId.Value) .ToListAsync(); if (materials.Count == 0) return new List(); var fileIds = materials .Select(item => item.FileId) .Distinct() .ToList(); var files = await _db.Queryable() .Where(item => fileIds.Contains(item.Id)) .ToListAsync(); var fileMap = files.ToDictionary(item => item.Id); var requirements = await _db.Queryable() .Where(item => item.TaskId == taskId.Value) .ToListAsync(); var sortMap = requirements .GroupBy(item => item.MaterialTypeId) .ToDictionary(group => group.Key, group => group.Min(item => item.SortCode)); return materials .Select(material => { var output = material.Adapt(); if (fileMap.TryGetValue(material.FileId, out var file)) { output.FileName = file.Name; output.FileSuffix = file.Suffix; output.FileSizeInfo = file.SizeInfo; } return output; }) .OrderBy(item => sortMap.GetValueOrDefault(item.MaterialTypeId, int.MaxValue)) .ThenBy(item => item.UploadedAt) .ToList(); } /// /// 查询员工人事变更记录,并补充每条记录关联材料的文件信息。 /// private async Task> GetEmployeeChangeRecords(long employeeId) { var records = await _db.Queryable() .Where(item => item.EmployeeId == employeeId) .OrderByDescending(item => item.CreateTime) .OrderByDescending(item => item.Id) .ToListAsync(); if (records.Count == 0) return records; var recordIds = records.Select(item => item.Id).ToList(); var attachments = await _db.Queryable() .Where(item => item.EmployeeId == employeeId && item.ChangeRecordId.HasValue && recordIds.Contains(item.ChangeRecordId.Value)) .OrderBy(item => item.UploadedAt) .ToListAsync(); if (attachments.Count > 0) { var fileIds = attachments.Select(item => item.FileId).Distinct().ToList(); var files = await _db.Queryable() .Where(item => fileIds.Contains(item.Id)) .ToListAsync(); var fileMap = files.ToDictionary(item => item.Id); foreach (var attachment in attachments) { if (!fileMap.TryGetValue(attachment.FileId, out var file)) continue; attachment.FileName = string.IsNullOrWhiteSpace(attachment.FileName) ? file.Name : attachment.FileName; attachment.FileSuffix = file.Suffix; attachment.FileSizeInfo = file.SizeInfo; } var attachmentMap = attachments .Where(item => item.ChangeRecordId.HasValue) .GroupBy(item => item.ChangeRecordId.Value) .ToDictionary(group => group.Key, group => group.ToList()); foreach (var record in records) record.Attachments = attachmentMap.GetValueOrDefault(record.Id) ?? new List(); } return records; } private async Task InsertChangeRecord( long employeeId, string changeType, DateTime? effectiveDate, object before, object after, string remark) { var now = DateTime.Now; var record = new ObEmployeeChangeRecord { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), EmployeeId = employeeId, ChangeType = changeType, EffectiveDate = effectiveDate, BeforeJson = JsonSerializer.Serialize(before), AfterJson = JsonSerializer.Serialize(after), Remark = remark, Status = CommonStatusConst.ENABLE, CreateTime = now, CreateUserId = UserManager.UserId, CreateUser = UserManager.Name }; await _db.Insertable(record).ExecuteCommandAsync(); return record; } private static object CreateHrState(ObEmployee employee) { return new { employee.EmploymentType, employee.RegularizationStatus, employee.RegularizationDate, employee.ContractNature, employee.SocialInsuranceStatus, employee.SocialInsuranceCompany }; } private static string NormalizeOptionalText(string value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static void ValidateEmploymentType(string value) { if (value != EmployeeEmploymentType.FullTime && value != EmployeeEmploymentType.PartTime) { throw Oops.Bah("用工类型不正确"); } } private static void ValidateSocialInsuranceStatus(string value) { if (value != EmployeeSocialInsuranceStatus.Unknown && value != EmployeeSocialInsuranceStatus.NotPurchased && value != EmployeeSocialInsuranceStatus.Purchased && value != EmployeeSocialInsuranceStatus.Stopped && value != EmployeeSocialInsuranceStatus.NotApplicable) { throw Oops.Bah("社保状态不正确"); } } /// /// 批量补充员工所属公司、部门和岗位名称,避免列表逐行查询数据库。 /// private async Task FillEmployeeMetadata(IEnumerable employees) { var employeeList = employees.ToList(); if (employeeList.Count == 0) return; var companyIds = employeeList.Select(item => item.CompanyId).Distinct().ToList(); var departmentIds = employeeList .Where(item => item.DepartmentId.HasValue) .Select(item => item.DepartmentId.Value) .Distinct() .ToList(); var positionIds = employeeList.Select(item => item.PositionId).Distinct().ToList(); var companies = await _db.Queryable() .Where(item => companyIds.Contains(item.Id)) .ToListAsync(); var departments = departmentIds.Count == 0 ? new List() : await _db.Queryable() .Where(item => departmentIds.Contains(item.Id)) .ToListAsync(); var positions = await _db.Queryable() .Where(item => positionIds.Contains(item.Id)) .ToListAsync(); var companyMap = companies.ToDictionary(item => item.Id, item => item.CompanyName); var departmentMap = departments.ToDictionary(item => item.Id, item => item.DepartmentName); var positionMap = positions.ToDictionary(item => item.Id, item => item.PositionName); foreach (var employee in employeeList) { if (companyMap.TryGetValue(employee.CompanyId, out var companyName)) employee.CompanyName = companyName; if (employee.DepartmentId.HasValue && departmentMap.TryGetValue(employee.DepartmentId.Value, out var departmentName)) { employee.DepartmentName = departmentName; } if (positionMap.TryGetValue(employee.PositionId, out var positionName)) employee.PositionName = positionName; } await FillMissingEntryDates(employeeList); } /// /// 构造基础员工查询(包含组织、岗位和姓名/手机号搜索),供列表、统计和导出复用。 /// private ISugarQueryable BuildBaseEmployeeQuery(OnboardingEmployeePageInput input) { return _db.Queryable() .WhereIF( !string.IsNullOrWhiteSpace(input?.EmployeeName), item => item.EmployeeName.Contains(input.EmployeeName) || item.IdCardNumber.Contains(input.EmployeeName)) .WhereIF(!string.IsNullOrWhiteSpace(input?.Phone), item => item.Phone.Contains(input.Phone)) .WhereIF(input?.CompanyId.HasValue == true, item => item.CompanyId == input.CompanyId.Value) .WhereIF(input?.DepartmentId.HasValue == true, item => item.DepartmentId == input.DepartmentId.Value) .WhereIF(input?.PositionId.HasValue == true, item => item.PositionId == input.PositionId.Value); } /// /// 构造列表和导出共用的员工筛选,保证页面看到的范围与导出范围一致。 /// private ISugarQueryable BuildEmployeeQuery(OnboardingEmployeePageInput input) { input ??= new OnboardingEmployeePageInput(); var expiringThreshold = DateTime.Today.AddDays(30).Date.AddDays(1).AddSeconds(-1); return BuildBaseEmployeeQuery(input) .WhereIF( !string.IsNullOrWhiteSpace(input.EmploymentType), item => item.EmploymentType == input.EmploymentType) .WhereIF( !string.IsNullOrWhiteSpace(input.RegularizationStatus), item => item.RegularizationStatus == input.RegularizationStatus) .WhereIF( !string.IsNullOrWhiteSpace(input.SocialInsuranceStatus), item => item.SocialInsuranceStatus == input.SocialInsuranceStatus) .WhereIF( input.ContractExpiringSoon == true, item => item.ContractEndDate != null && item.ContractEndDate <= expiringThreshold); } /// /// 计算员工档案的各项维度统计数量,受组织及关键字联动,但不受快捷状态筛选影响。 /// private async Task CalculateStatistics(OnboardingEmployeePageInput input) { var expiringThreshold = DateTime.Today.AddDays(30).Date.AddDays(1).AddSeconds(-1); var total = await BuildBaseEmployeeQuery(input).CountAsync(); if (total == 0) { return new OnboardingEmployeeStatisticsOutput(); } var fullTime = await BuildBaseEmployeeQuery(input) .Where(item => item.EmploymentType == EmployeeEmploymentType.FullTime) .CountAsync(); var partTime = await BuildBaseEmployeeQuery(input) .Where(item => item.EmploymentType == EmployeeEmploymentType.PartTime) .CountAsync(); var probation = await BuildBaseEmployeeQuery(input) .Where(item => item.RegularizationStatus == EmployeeRegularizationStatus.Probation) .CountAsync(); var socialPurchased = await BuildBaseEmployeeQuery(input) .Where(item => item.SocialInsuranceStatus == EmployeeSocialInsuranceStatus.Purchased) .CountAsync(); var expiringContract = await BuildBaseEmployeeQuery(input) .Where(item => item.ContractEndDate != null && item.ContractEndDate <= expiringThreshold) .CountAsync(); return new OnboardingEmployeeStatisticsOutput { Total = total, FullTime = fullTime, PartTime = partTime, Probation = probation, SocialPurchased = socialPurchased, ExpiringContract = expiringContract }; } private static void WriteEmployeeSheet( ExcelWorksheet worksheet, IReadOnlyList rows) { ExportColumn TextColumn( string header, Func value, double? width = null) => CreateTextColumn(header, value, width); ExportColumn DateColumn( string header, Func value, string format = "yyyy-mm-dd") => CreateDateColumn(header, value, format); ExportColumn NumberColumn( string header, Func value, string format = "#,##0.00") => CreateNumberColumn(header, value, format); var columns = new List> { TextColumn("姓名", row => row.Employee.EmployeeName), TextColumn("所属公司", row => row.Employee.CompanyName, 22), TextColumn("部门", row => row.Employee.DepartmentName), TextColumn("岗位", row => row.Employee.PositionName), TextColumn("用工类型", row => EmploymentTypeText(row.Employee.EmploymentType)), TextColumn("性别", row => row.Employee.Gender), TextColumn("手机号", row => row.Employee.Phone), TextColumn("身份证号码", row => row.Employee.IdCardNumber), TextColumn("院校", row => FirstNotBlank(row.Employee.College, row.EmployeeInfo?.GraduatedSchool)), TextColumn("学历", row => FirstNotBlank(row.Employee.Education, row.EmployeeInfo?.Education)), TextColumn("专业", row => FirstNotBlank(row.Employee.Major, row.EmployeeInfo?.Major)), DateColumn("出生日期", row => row.Employee.BirthDate ?? ParseDate(row.EmployeeInfo?.Birthday)), NumberColumn("年龄", row => CalculateAge(row.Employee.BirthDate ?? ParseDate(row.EmployeeInfo?.Birthday)), "0"), TextColumn("户籍所在地", row => FirstNotBlank(row.Employee.RegisteredResidence, row.EmployeeInfo?.RegisteredResidence)), TextColumn("民族", row => FirstNotBlank(row.Employee.Nation, row.EmployeeInfo?.Nation)), TextColumn("通讯地址", row => FirstNotBlank(row.Employee.ContactAddress, row.EmployeeInfo?.CurrentAddress), 24), DateColumn("入职时间", row => row.Employee.EntryDate), TextColumn("司龄", row => FormatTenure(row.Employee.EntryDate)), DateColumn("合同开始日期", row => row.PrimaryContract?.ContractStartDate ?? row.Employee.ContractStartDate), DateColumn("合同终止日期", row => row.PrimaryContract?.ContractEndDate ?? row.Employee.ContractEndDate), TextColumn("试用期", row => row.PrimaryContract?.ProbationTerm), NumberColumn("试用薪资", row => row.PrimaryContract?.ProbationSalary), DateColumn("转正日期", row => row.Employee.RegularizationDate), TextColumn("是否转正", row => RegularizationText(row.Employee)), NumberColumn("转正薪资", row => row.PrimaryContract?.RegularSalary), TextColumn("是否购买社保", row => SocialInsuranceText(row.Employee.SocialInsuranceStatus)), TextColumn("合同社保单位", row => row.Employee.SocialInsuranceCompany, 22), TextColumn("合同性质", row => row.Employee.ContractNature), TextColumn("工资卡账号", row => row.Employee.BankCardNumber), TextColumn("工资卡开户行", row => row.Employee.BankName, 22) }; WriteEmployeeTemplateWorksheet(worksheet, columns, rows); } private static void WriteEmployeeTemplateWorksheet( ExcelWorksheet worksheet, IReadOnlyList> columns, IReadOnlyList rows) { var templateColumnCount = worksheet.Dimension?.End.Column ?? 0; if (templateColumnCount == 0) throw Oops.Bah("员工导出模板没有配置表头"); var columnsByHeader = columns.ToDictionary( item => item.Header, item => item, StringComparer.OrdinalIgnoreCase); var headerAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["户口所在地"] = "户籍所在地" }; var templateColumns = new List<(int Index, ExportColumn Column)>(); var mappedHeaders = new HashSet(StringComparer.OrdinalIgnoreCase); for (var columnIndex = 1; columnIndex <= templateColumnCount; columnIndex++) { var templateHeader = worksheet.Cells[1, columnIndex].Text?.Trim(); if (string.IsNullOrWhiteSpace(templateHeader)) throw Oops.Bah($"员工导出模板第 {columnIndex} 列表头为空,请补充后重新导出"); var exportHeader = headerAliases.GetValueOrDefault(templateHeader, templateHeader); if (!columnsByHeader.TryGetValue(exportHeader, out var exportColumn)) throw Oops.Bah($"员工导出模板包含无法识别的表头“{templateHeader}”,请检查模板"); if (!mappedHeaders.Add(exportHeader)) throw Oops.Bah($"员工导出模板表头“{templateHeader}”重复,请检查模板"); templateColumns.Add((columnIndex, exportColumn)); } var missingHeaders = columns .Where(item => !mappedHeaders.Contains(item.Header)) .Select(item => item.Header) .ToList(); if (missingHeaders.Count > 0) throw Oops.Bah($"员工导出模板缺少表头:{string.Join("、", missingHeaders)}"); var existingLastRow = worksheet.Dimension?.End.Row ?? 1; for (var rowIndex = 2; rowIndex <= existingLastRow; rowIndex++) { for (var columnIndex = 1; columnIndex <= templateColumnCount; columnIndex++) worksheet.Cells[rowIndex, columnIndex].Value = null; } for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) { var excelRow = rowIndex + 2; foreach (var templateColumn in templateColumns) { var cell = worksheet.Cells[excelRow, templateColumn.Index]; var value = templateColumn.Column.Value(rows[rowIndex]); if (value != null) cell.Value = value; var templateNumberFormat = worksheet.Cells[1, templateColumn.Index] .Style.Numberformat.Format; cell.Style.Numberformat.Format = string.IsNullOrWhiteSpace(templateNumberFormat) || string.Equals( templateNumberFormat, "General", StringComparison.OrdinalIgnoreCase) ? templateColumn.Column.NumberFormat : templateNumberFormat; } } } private static string ResolveEmployeeExportTemplatePath() { var currentDirectory = Directory.GetCurrentDirectory(); var candidates = new[] { Path.Combine(AppContext.BaseDirectory, "Upload", EmployeeExportTemplateFileName), Path.Combine(currentDirectory, "Upload", EmployeeExportTemplateFileName), Path.Combine(currentDirectory, "SimpleAdmin.Web.Core", "Upload", EmployeeExportTemplateFileName), Path.GetFullPath(Path.Combine( currentDirectory, "..", "SimpleAdmin.Web.Core", "Upload", EmployeeExportTemplateFileName)) }; var templatePath = candidates.FirstOrDefault(File.Exists); return templatePath ?? throw Oops.Bah("员工导出模板文件不存在,请检查部署目录中的 Upload/员工导出模版.xlsx"); } private static void WriteContractSheet( ExcelWorksheet worksheet, IReadOnlyList rows) { ExportColumn TextColumn( string header, Func value, double? width = null) => CreateTextColumn(header, value, width); ExportColumn DateColumn( string header, Func value, string format = "yyyy-mm-dd") => CreateDateColumn(header, value, format); ExportColumn NumberColumn( string header, Func value, string format = "#,##0.00") => CreateNumberColumn(header, value, format); var columns = new List> { TextColumn("姓名", row => row.Employee.EmployeeName), TextColumn("员工编号", row => FirstNotBlank(row.Employee.EmployeeNo, row.Snapshot.EmployeeNo)), TextColumn("所属公司", row => row.Employee.CompanyName, 22), TextColumn("所属部门", row => row.Employee.DepartmentName), TextColumn("所属岗位", row => row.Employee.PositionName), TextColumn("手机号码", row => row.Employee.Phone), TextColumn("身份证号码", row => row.Employee.IdCardNumber), TextColumn("入职任务编号", row => row.TaskNo, 22), TextColumn("合同名称", row => row.Snapshot.ContractName, 24), TextColumn("合同类型", row => row.Snapshot.ContractType), DateColumn("合同开始日期", row => row.Snapshot.ContractStartDate), DateColumn("合同结束日期", row => row.Snapshot.ContractEndDate), TextColumn("合同期限", row => row.Snapshot.ContractTerm), DateColumn("试用期开始日期", row => row.Snapshot.ProbationStartDate), DateColumn("试用期结束日期", row => row.Snapshot.ProbationEndDate), TextColumn("试用期期限", row => row.Snapshot.ProbationTerm), NumberColumn("试用薪资(元)", row => row.Snapshot.ProbationSalary), NumberColumn("基本工资(元)", row => row.Snapshot.BaseSalary), NumberColumn("岗位工资(元)", row => row.Snapshot.PositionSalary), NumberColumn("绩效津贴(元)", row => row.Snapshot.PerformanceAllowance), NumberColumn("全勤奖(元)", row => row.Snapshot.AttendanceBonus), NumberColumn("转正后薪资(元)", row => row.Snapshot.RegularSalary), TextColumn("工作地点", row => row.Snapshot.WorkCity), TextColumn("合同岗位名称", row => row.Snapshot.PositionName), DateColumn("快照生成时间", row => row.Snapshot.SnapshotAt, "yyyy-mm-dd hh:mm:ss") }; var variableColumns = rows .SelectMany(row => row.Variables.Values) .GroupBy(item => item.Code, StringComparer.OrdinalIgnoreCase) .Select(group => new { Code = group.Key, Name = group.Select(item => item.Name).FirstOrDefault(name => !string.IsNullOrWhiteSpace(name)), SortCode = group.Min(item => item.SortCode) }) .OrderBy(item => item.SortCode) .ThenBy(item => item.Code, StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var variable in variableColumns) { var code = variable.Code; var header = string.IsNullOrWhiteSpace(variable.Name) ? code : $"{variable.Name}\n({code})"; columns.Add(TextColumn( header, row => row.Variables.TryGetValue(code, out var value) ? value.Value : null, 24)); } WriteWorksheet(worksheet, columns, rows); } private static void WriteWorksheet( ExcelWorksheet worksheet, IReadOnlyList> columns, IReadOnlyList rows) { worksheet.View.ShowGridLines = false; worksheet.View.FreezePanes(2, 1); for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++) { var column = columns[columnIndex]; var cell = worksheet.Cells[1, columnIndex + 1]; cell.Value = column.Header; cell.Style.WrapText = true; } for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) { var excelRow = rowIndex + 2; for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++) { var column = columns[columnIndex]; var cell = worksheet.Cells[excelRow, columnIndex + 1]; var value = column.Value(rows[rowIndex]); if (value != null) cell.Value = value; if (!string.IsNullOrWhiteSpace(column.NumberFormat)) cell.Style.Numberformat.Format = column.NumberFormat; cell.Style.HorizontalAlignment = column.Kind == ExportCellKind.Text ? ExcelHorizontalAlignment.Left : ExcelHorizontalAlignment.Right; } if (rowIndex % 2 == 1) { worksheet.Cells[excelRow, 1, excelRow, columns.Count].Style.Fill.PatternType = ExcelFillStyle.Solid; worksheet.Cells[excelRow, 1, excelRow, columns.Count].Style.Fill.BackgroundColor.SetColor( Color.FromRgb(247, 249, 252)); } } var lastRow = Math.Max(rows.Count + 1, 1); var allCells = worksheet.Cells[1, 1, lastRow, columns.Count]; allCells.Style.Font.Name = "Microsoft YaHei"; allCells.Style.Font.Size = 10; allCells.Style.VerticalAlignment = ExcelVerticalAlignment.Center; var header = worksheet.Cells[1, 1, 1, columns.Count]; header.Style.Fill.PatternType = ExcelFillStyle.Solid; header.Style.Fill.BackgroundColor.SetColor(Color.FromRgb(31, 78, 121)); header.Style.Font.Color.SetColor(Color.White); header.Style.Font.Bold = true; header.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; header.Style.VerticalAlignment = ExcelVerticalAlignment.Center; header.AutoFilter = true; worksheet.Row(1).Height = 34; if (rows.Count > 0) { var body = worksheet.Cells[2, 1, lastRow, columns.Count]; body.Style.VerticalAlignment = ExcelVerticalAlignment.Top; body.Style.Border.Bottom.Style = ExcelBorderStyle.Hair; body.Style.Border.Bottom.Color.SetColor(Color.FromRgb(224, 229, 236)); } allCells.AutoFitColumns(10, 36); for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++) { var preferredWidth = columns[columnIndex].PreferredWidth; if (preferredWidth.HasValue) { worksheet.Column(columnIndex + 1).Width = preferredWidth.Value; if (rows.Count > 0) worksheet.Cells[2, columnIndex + 1, lastRow, columnIndex + 1].Style.WrapText = true; } } } private static ExportColumn CreateTextColumn( string header, Func value, double? preferredWidth = null) { return new ExportColumn(header, value, ExportCellKind.Text, "@", preferredWidth); } private static ExportColumn CreateDateColumn( string header, Func value, string numberFormat = "yyyy-mm-dd") { return new ExportColumn(header, value, ExportCellKind.Date, numberFormat, 13); } private static ExportColumn CreateNumberColumn( string header, Func value, string numberFormat = "#,##0.00") { return new ExportColumn(header, value, ExportCellKind.Number, numberFormat, 14); } private static ObEmployeeContractSnapshot SelectPrimaryContract( IEnumerable snapshots) { return snapshots .OrderByDescending(item => ContainsLaborContract(item.ContractName) || ContainsLaborContract(item.ContractType)) .ThenByDescending(CountCommonContractValues) .ThenBy(item => item.SnapshotAt) .ThenBy(item => item.Id) .FirstOrDefault(); } private static bool ContainsLaborContract(string value) { return !string.IsNullOrWhiteSpace(value) && value.Contains("劳动合同", StringComparison.OrdinalIgnoreCase); } private static int CountCommonContractValues(ObEmployeeContractSnapshot snapshot) { return new object[] { snapshot.ContractStartDate, snapshot.ContractEndDate, snapshot.ContractTerm, snapshot.ProbationStartDate, snapshot.ProbationEndDate, snapshot.ProbationTerm, snapshot.ProbationSalary, snapshot.BaseSalary, snapshot.PositionSalary, snapshot.PerformanceAllowance, snapshot.AttendanceBonus, snapshot.RegularSalary, snapshot.WorkCity, snapshot.PositionName, snapshot.EmployeeNo }.Count(value => value != null && !string.IsNullOrWhiteSpace(value.ToString())); } private static ObTaskEmployeeInfo DeserializeEmployeeInfo(string json) { if (string.IsNullOrWhiteSpace(json)) return null; try { return JsonSerializer.Deserialize(json); } catch (JsonException) { return null; } } private static DateTime? ParseDate(string value) { return DateTime.TryParse(value, out var date) ? date.Date : null; } private static string FirstNotBlank(params string[] values) { return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); } private static int? CalculateAge(DateTime? birthDate) { if (!birthDate.HasValue || birthDate.Value.Date > DateTime.Today) return null; var birthday = birthDate.Value.Date; var age = DateTime.Today.Year - birthday.Year; if (birthday.AddYears(age) > DateTime.Today) age--; return age < 0 ? null : age; } private static string FormatTenure(DateTime? entryDate) { if (!entryDate.HasValue || entryDate.Value.Date > DateTime.Today) return null; var start = entryDate.Value.Date; var today = DateTime.Today; var months = (today.Year - start.Year) * 12 + today.Month - start.Month; if (today.Day < start.Day) months--; months = Math.Max(months, 0); var years = months / 12; var remainingMonths = months % 12; if (years > 0 && remainingMonths > 0) return $"{years}年{remainingMonths}个月"; if (years > 0) return $"{years}年"; return $"{remainingMonths}个月"; } private static string EmploymentTypeText(string status) { return status == EmployeeEmploymentType.PartTime ? "兼职" : "正式"; } private static string RegularizationText(ObEmployee employee) { if (employee.EmploymentType == EmployeeEmploymentType.PartTime || employee.RegularizationStatus == EmployeeRegularizationStatus.NotApplicable) { return "不适用"; } if (employee.RegularizationStatus == EmployeeRegularizationStatus.Regular || employee.RegularizationDate.HasValue) { return "是"; } return "否"; } private static string SocialInsuranceText(string status) { return status switch { EmployeeSocialInsuranceStatus.Purchased => "是", EmployeeSocialInsuranceStatus.NotPurchased => "否", EmployeeSocialInsuranceStatus.Stopped => "已停保", EmployeeSocialInsuranceStatus.NotApplicable => "不适用", _ => null }; } private static string FormatJsonRows( string json, params (string Key, string Label)[] fields) { if (string.IsNullOrWhiteSpace(json)) return null; try { using var document = JsonDocument.Parse(json); if (document.RootElement.ValueKind != JsonValueKind.Array) return json; var rows = new List(); foreach (var item in document.RootElement.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object) continue; var values = new List(); foreach (var field in fields) { if (!item.TryGetProperty(field.Key, out var property)) continue; var value = JsonValueToString(property); if (!string.IsNullOrWhiteSpace(value)) values.Add($"{field.Label}:{value}"); } if (values.Count > 0) rows.Add(string.Join(",", values)); } return rows.Count == 0 ? null : string.Join(";", rows); } catch (JsonException) { return json; } } private static string FormatJsonObject(string json) { if (string.IsNullOrWhiteSpace(json)) return null; try { using var document = JsonDocument.Parse(json); if (document.RootElement.ValueKind != JsonValueKind.Object) return json; return string.Join( ";", document.RootElement.EnumerateObject() .Select(property => $"{property.Name}:{JsonValueToString(property.Value)}")); } catch (JsonException) { return json; } } private static Dictionary ParseContractVariables(string json) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); if (string.IsNullOrWhiteSpace(json)) return result; try { using var document = JsonDocument.Parse(json); if (document.RootElement.ValueKind != JsonValueKind.Object) return result; foreach (var property in document.RootElement.EnumerateObject()) { var item = property.Value; if (item.ValueKind != JsonValueKind.Object) continue; result[property.Name] = new ContractVariableExportValue { Code = property.Name, Name = TryGetJsonString(item, "variableName"), Value = TryGetJsonString(item, "variableValue"), SortCode = TryGetJsonInt(item, "sortCode") }; } } catch (JsonException) { // 单份历史快照异常时保留其固定字段,跳过无法解析的动态变量。 } return result; } private static string TryGetJsonString(JsonElement element, string propertyName) { return element.TryGetProperty(propertyName, out var property) ? JsonValueToString(property) : null; } private static int TryGetJsonInt(JsonElement element, string propertyName) { return element.TryGetProperty(propertyName, out var property) && property.TryGetInt32(out var value) ? value : int.MaxValue; } private static string JsonValueToString(JsonElement element) { return element.ValueKind switch { JsonValueKind.String => element.GetString(), JsonValueKind.Null => null, JsonValueKind.Undefined => null, _ => element.ToString() }; } private sealed class EmployeeExportContext { public ObEmployee Employee { get; set; } public ObTaskEmployeeInfo EmployeeInfo { get; set; } public ObEmployeeContractSnapshot PrimaryContract { get; set; } public int ContractCount { get; set; } } private sealed class ContractExportContext { public ObEmployee Employee { get; set; } public ObEmployeeContractSnapshot Snapshot { get; set; } public string TaskNo { get; set; } public Dictionary Variables { get; set; } } private sealed class ContractVariableExportValue { public string Code { get; set; } public string Name { get; set; } public string Value { get; set; } public int SortCode { get; set; } } private sealed class ExportColumn { public ExportColumn( string header, Func value, ExportCellKind kind, string numberFormat, double? preferredWidth) { Header = header; Value = value; Kind = kind; NumberFormat = numberFormat; PreferredWidth = preferredWidth; } public string Header { get; } public Func Value { get; } public ExportCellKind Kind { get; } public string NumberFormat { get; } public double? PreferredWidth { get; } } private enum ExportCellKind { Text, Date, Number } /// /// 兼容已建档但入职日期为空的历史员工,从来源任务的合同开始日期进行只读回填。 /// 新审核通过的员工会直接持久化该日期,不会进入此兼容分支。 /// private async Task FillMissingEntryDates(List employees) { var missingEmployees = employees .Where(item => !item.EntryDate.HasValue || !item.ContractStartDate.HasValue) .ToList(); if (missingEmployees.Count == 0) return; var employeeIds = missingEmployees.Select(item => item.Id).ToList(); var tasks = await _db.Queryable() .Where(item => item.EmployeeId.HasValue && employeeIds.Contains(item.EmployeeId.Value)) .ToListAsync(); if (tasks.Count == 0) return; var taskIds = tasks.Select(item => item.Id).ToList(); var contracts = await _db.Queryable() .Where(item => taskIds.Contains(item.TaskId)) .ToListAsync(); if (contracts.Count == 0) return; var contractIds = contracts.Select(item => item.Id).ToList(); var startDateVariables = await _db.Queryable() .Where(item => contractIds.Contains(item.TaskContractId) && item.VariableCode == "hr.ContractStartDate") .ToListAsync(); var employeeIdByTaskId = tasks .Where(item => item.EmployeeId.HasValue) .ToDictionary(item => item.Id, item => item.EmployeeId.Value); var taskIdByContractId = contracts.ToDictionary(item => item.Id, item => item.TaskId); var datesByEmployeeId = startDateVariables .Where(item => !string.IsNullOrWhiteSpace(item.VariableValue) && taskIdByContractId.ContainsKey(item.TaskContractId)) .Select(item => new { EmployeeId = employeeIdByTaskId[taskIdByContractId[item.TaskContractId]], EntryDate = DateTime.TryParse(item.VariableValue, out var value) ? value.Date : (DateTime?)null }) .Where(item => item.EntryDate.HasValue) .GroupBy(item => item.EmployeeId) .ToDictionary( group => group.Key, group => group.Select(item => item.EntryDate.Value).Distinct().ToList()); foreach (var employee in missingEmployees) { if (!datesByEmployeeId.TryGetValue(employee.Id, out var dates) || dates.Count != 1) continue; employee.EntryDate ??= dates[0]; employee.ContractStartDate ??= dates[0]; } } }