Files
OnboardXAdmin/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs
T
2026-09-09 14:57:41 +08:00

1251 lines
50 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using OfficeOpenXml;
using OfficeOpenXml.Style;
using SixLabors.ImageSharp;
using System.Text.Json;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现员工档案查询、人事状态维护和明细导出能力。
/// </summary>
public class OnboardingEmployeeService : IOnboardingEmployeeService
{
private readonly SqlSugarScope _db;
public OnboardingEmployeeService()
{
_db = DbContext.DB;
}
/// <summary>
/// 分页查询审核通过后生成的员工档案。
/// </summary>
public async Task<SqlSugarPagedList<ObEmployee>> Page(OnboardingEmployeePageInput input)
{
var result = await BuildEmployeeQuery(input)
.OrderByDescending(item => item.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
if (result.List != null && result.List.Any())
await FillEmployeeMetadata(result.List);
return result;
}
/// <inheritdoc />
public async Task<OnboardingEmployeeExportFile> 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<ObOnboardingTask>()
.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<ObTaskEmployeeInfo>()
: await _db.Queryable<ObTaskEmployeeInfo>()
.Where(item => taskIds.Contains(item.TaskId))
.ToListAsync();
var snapshots = await _db.Queryable<ObEmployeeContractSnapshot>()
.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<ObEmployeeContractSnapshot>();
return new EmployeeExportContext
{
Employee = employee,
EmployeeInfo = info,
PrimaryContract = SelectPrimaryContract(employeeSnapshots)
};
}).ToList();
using var package = new ExcelPackage();
package.Workbook.Properties.Title = "员工明细";
package.Workbook.Properties.Subject = "员工基本、人事状态及主要合同信息";
package.Workbook.Properties.Created = DateTime.Now;
WriteEmployeeSheet(package.Workbook.Worksheets.Add("员工明细"), employeeRows);
return new OnboardingEmployeeExportFile
{
FileBytes = package.GetAsByteArray(),
FileName = $"员工明细_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx"
};
}
/// <summary>
/// 查询正式员工及其来源任务、合同和审核记录。
/// </summary>
public async Task<OnboardingEmployeeDetailOutput> Detail(BaseIdInput input)
{
var employee = await _db.Queryable<ObEmployee>().InSingleAsync(input.Id)
?? throw Oops.Bah("员工档案不存在");
var task = await _db.Queryable<ObOnboardingTask>().FirstAsync(x => x.EmployeeId == employee.Id);
await FillEmployeeMetadata(new[] { employee });
var employeeInfo = task == null
? null
: await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == task.Id);
if (employeeInfo == null && !string.IsNullOrWhiteSpace(employee.ExtraInfoJson))
{
try
{
employeeInfo = global::System.Text.Json.JsonSerializer.Deserialize<ObTaskEmployeeInfo>(
employee.ExtraInfoJson);
}
catch (global::System.Text.Json.JsonException)
{
// 兼容历史异常快照:员工主档仍可正常查看,其余信息显示为空。
}
}
var contracts = task == null
? new List<ObTaskContract>()
: await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
var contractSnapshots = await _db.Queryable<ObEmployeeContractSnapshot>()
.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<ObElectronicSignProcess>()
.Where(item => item.TaskId == task.Id && item.ArchiveFileId.HasValue)
.OrderByDescending(item => item.Id)
.FirstAsync();
var audits = task == null
? new List<ObTaskAudit>()
: await _db.Queryable<ObTaskAudit>()
.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
};
}
/// <inheritdoc />
public async Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input)
{
ValidateEmploymentType(input.EmploymentType);
ValidateSocialInsuranceStatus(input.SocialInsuranceStatus);
var employee = await _db.Queryable<ObEmployee>().InSingleAsync(input.Id)
?? throw Oops.Bah("员工档案不存在");
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<ObEmployee>()
.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;
}
}
/// <inheritdoc />
public async Task Regularize(OnboardingEmployeeRegularizeInput input)
{
if (!input.RegularizationDate.HasValue)
throw Oops.Bah("请选择转正日期");
var employee = await _db.Queryable<ObEmployee>().InSingleAsync(input.Id)
?? throw Oops.Bah("员工档案不存在");
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<long>())
.Where(item => item > 0)
.Distinct()
.ToList();
var files = fileIds.Count == 0
? new List<SysFile>()
: await _db.Queryable<SysFile>()
.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<ObEmployee>()
.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;
}
}
/// <inheritdoc />
public async Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input)
{
if (string.IsNullOrWhiteSpace(input.Remark))
throw Oops.Bah("请填写撤销原因");
var employee = await _db.Queryable<ObEmployee>().InSingleAsync(input.Id)
?? throw Oops.Bah("员工档案不存在");
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<ObEmployee>()
.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;
}
}
/// <summary>
/// 查询员工来源任务中上传的全部材料,并补充原始文件名、后缀和文件大小。
/// </summary>
private async Task<List<OnboardingTaskMaterialDetailOutput>> GetEmployeeMaterials(long? taskId)
{
if (!taskId.HasValue)
return new List<OnboardingTaskMaterialDetailOutput>();
var materials = await _db.Queryable<ObTaskMaterial>()
.Where(item => item.TaskId == taskId.Value)
.ToListAsync();
if (materials.Count == 0)
return new List<OnboardingTaskMaterialDetailOutput>();
var fileIds = materials
.Select(item => item.FileId)
.Distinct()
.ToList();
var files = await _db.Queryable<SysFile>()
.Where(item => fileIds.Contains(item.Id))
.ToListAsync();
var fileMap = files.ToDictionary(item => item.Id);
var requirements = await _db.Queryable<ObTaskMaterialRequirement>()
.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<OnboardingTaskMaterialDetailOutput>();
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();
}
/// <summary>
/// 查询员工人事变更记录,并补充每条记录关联材料的文件信息。
/// </summary>
private async Task<List<ObEmployeeChangeRecord>> GetEmployeeChangeRecords(long employeeId)
{
var records = await _db.Queryable<ObEmployeeChangeRecord>()
.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<ObEmployeeAttachment>()
.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<SysFile>()
.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<ObEmployeeAttachment>();
}
return records;
}
private async Task<ObEmployeeChangeRecord> 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("社保状态不正确");
}
}
/// <summary>
/// 批量补充员工所属公司、部门和岗位名称,避免列表逐行查询数据库。
/// </summary>
private async Task FillEmployeeMetadata(IEnumerable<ObEmployee> 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<ObCompany>()
.Where(item => companyIds.Contains(item.Id))
.ToListAsync();
var departments = departmentIds.Count == 0
? new List<ObDepartment>()
: await _db.Queryable<ObDepartment>()
.Where(item => departmentIds.Contains(item.Id))
.ToListAsync();
var positions = await _db.Queryable<ObPosition>()
.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);
}
/// <summary>
/// 构造列表和导出共用的员工筛选,保证页面看到的范围与导出范围一致。
/// </summary>
private ISugarQueryable<ObEmployee> BuildEmployeeQuery(OnboardingEmployeePageInput input)
{
input ??= new OnboardingEmployeePageInput();
return _db.Queryable<ObEmployee>()
.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, item => item.CompanyId == input.CompanyId.Value)
.WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value)
.WhereIF(input.PositionId.HasValue, item => item.PositionId == input.PositionId.Value)
.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);
}
private static void WriteEmployeeSheet(
ExcelWorksheet worksheet,
IReadOnlyList<EmployeeExportContext> rows)
{
ExportColumn<EmployeeExportContext> TextColumn(
string header,
Func<EmployeeExportContext, object> value,
double? width = null) => CreateTextColumn(header, value, width);
ExportColumn<EmployeeExportContext> DateColumn(
string header,
Func<EmployeeExportContext, object> value,
string format = "yyyy-mm-dd") => CreateDateColumn(header, value, format);
ExportColumn<EmployeeExportContext> NumberColumn(
string header,
Func<EmployeeExportContext, object> value,
string format = "#,##0.00") => CreateNumberColumn(header, value, format);
var columns = new List<ExportColumn<EmployeeExportContext>>
{
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)
};
WriteWorksheet(worksheet, columns, rows);
}
private static void WriteContractSheet(
ExcelWorksheet worksheet,
IReadOnlyList<ContractExportContext> rows)
{
ExportColumn<ContractExportContext> TextColumn(
string header,
Func<ContractExportContext, object> value,
double? width = null) => CreateTextColumn(header, value, width);
ExportColumn<ContractExportContext> DateColumn(
string header,
Func<ContractExportContext, object> value,
string format = "yyyy-mm-dd") => CreateDateColumn(header, value, format);
ExportColumn<ContractExportContext> NumberColumn(
string header,
Func<ContractExportContext, object> value,
string format = "#,##0.00") => CreateNumberColumn(header, value, format);
var columns = new List<ExportColumn<ContractExportContext>>
{
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<T>(
ExcelWorksheet worksheet,
IReadOnlyList<ExportColumn<T>> columns,
IReadOnlyList<T> 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<T> CreateTextColumn<T>(
string header,
Func<T, object> value,
double? preferredWidth = null)
{
return new ExportColumn<T>(header, value, ExportCellKind.Text, "@", preferredWidth);
}
private static ExportColumn<T> CreateDateColumn<T>(
string header,
Func<T, object> value,
string numberFormat = "yyyy-mm-dd")
{
return new ExportColumn<T>(header, value, ExportCellKind.Date, numberFormat, 13);
}
private static ExportColumn<T> CreateNumberColumn<T>(
string header,
Func<T, object> value,
string numberFormat = "#,##0.00")
{
return new ExportColumn<T>(header, value, ExportCellKind.Number, numberFormat, 14);
}
private static ObEmployeeContractSnapshot SelectPrimaryContract(
IEnumerable<ObEmployeeContractSnapshot> 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<ObTaskEmployeeInfo>(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<string>();
foreach (var item in document.RootElement.EnumerateArray())
{
if (item.ValueKind != JsonValueKind.Object)
continue;
var values = new List<string>();
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<string, ContractVariableExportValue> ParseContractVariables(string json)
{
var result = new Dictionary<string, ContractVariableExportValue>(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<string, ContractVariableExportValue> 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<T>
{
public ExportColumn(
string header,
Func<T, object> value,
ExportCellKind kind,
string numberFormat,
double? preferredWidth)
{
Header = header;
Value = value;
Kind = kind;
NumberFormat = numberFormat;
PreferredWidth = preferredWidth;
}
public string Header { get; }
public Func<T, object> Value { get; }
public ExportCellKind Kind { get; }
public string NumberFormat { get; }
public double? PreferredWidth { get; }
}
private enum ExportCellKind
{
Text,
Date,
Number
}
/// <summary>
/// 兼容已建档但入职日期为空的历史员工,从来源任务的合同开始日期进行只读回填。
/// 新审核通过的员工会直接持久化该日期,不会进入此兼容分支。
/// </summary>
private async Task FillMissingEntryDates(List<ObEmployee> 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<ObOnboardingTask>()
.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<ObTaskContract>()
.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<ObTaskContractVariable>()
.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];
}
}
}