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];
}
}
}