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

1518 lines
62 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 const string EmployeeExportTemplateFileName = "员工导出模版.xlsx";
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingEmployeeService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询审核通过后生成的员工档案及维度统计。
/// </summary>
public async Task<OnboardingEmployeePageOutput> 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
};
}
/// <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();
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"
};
}
/// <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();
if (string.IsNullOrWhiteSpace(employee.EmployeeNo))
{
var snapshotEmployeeNo = contractSnapshots
.Select(item => item.EmployeeNo)
.FirstOrDefault(val => !string.IsNullOrWhiteSpace(val));
if (string.IsNullOrWhiteSpace(snapshotEmployeeNo))
{
snapshotEmployeeNo = contractSnapshots
.Select(item => ExtractEmployeeNoFromVariables(item.VariablesJson))
.FirstOrDefault(val => !string.IsNullOrWhiteSpace(val));
}
if (string.IsNullOrWhiteSpace(snapshotEmployeeNo) && contracts.Count > 0)
{
var contractIds = contracts.Select(c => c.Id).ToList();
snapshotEmployeeNo = await _db.Queryable<ObTaskContractVariable>()
.Where(item => contractIds.Contains(item.TaskContractId) && item.VariableCode == "hr.EmployeeNo" && item.VariableValue != null && item.VariableValue != "")
.OrderBy(item => item.SortCode)
.Select(item => item.VariableValue)
.FirstAsync();
}
if (!string.IsNullOrWhiteSpace(snapshotEmployeeNo))
{
employee.EmployeeNo = snapshotEmployeeNo;
await _db.Updateable<ObEmployee>()
.SetColumns(item => new ObEmployee { EmployeeNo = snapshotEmployeeNo })
.Where(item => item.Id == employee.Id && (item.EmployeeNo == null || item.EmployeeNo == ""))
.ExecuteCommandAsync();
}
}
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)
{
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<ObEmployee>().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<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)
{
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<ObEmployee>().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<long>())
.Where(item => item > 0)
.Distinct()
.ToList();
if (fileIds.Count > 10)
throw Oops.Bah("转正材料文件总数不能超过 10 份");
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)
{
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<ObEmployee>().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<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 Task EnsureEmployeeCanManage(ObEmployee employee)
{
return _dataPermission.EnsureCanManage(
employee.CreateUserId,
OnboardingPermissionCodes.EmployeeManageAll,
"员工档案");
}
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> BuildBaseEmployeeQuery(OnboardingEmployeePageInput input)
{
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 == 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);
}
/// <summary>
/// 构造列表和导出共用的员工筛选,保证页面看到的范围与导出范围一致。
/// </summary>
private ISugarQueryable<ObEmployee> 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);
}
/// <summary>
/// 计算员工档案的各项维度统计数量,受组织及关键字联动,但不受快捷状态筛选影响。
/// </summary>
private async Task<OnboardingEmployeeStatisticsOutput> 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<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)
};
WriteEmployeeTemplateWorksheet(worksheet, columns, rows);
}
private static void WriteEmployeeTemplateWorksheet(
ExcelWorksheet worksheet,
IReadOnlyList<ExportColumn<EmployeeExportContext>> columns,
IReadOnlyList<EmployeeExportContext> 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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["户口所在地"] = "户籍所在地"
};
var templateColumns = new List<(int Index, ExportColumn<EmployeeExportContext> Column)>();
var mappedHeaders = new HashSet<string>(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[2, 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<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 string ExtractEmployeeNoFromVariables(string variablesJson)
{
if (string.IsNullOrWhiteSpace(variablesJson))
return null;
try
{
using var doc = global::System.Text.Json.JsonDocument.Parse(variablesJson);
var root = doc.RootElement;
if (root.ValueKind == global::System.Text.Json.JsonValueKind.Object)
{
foreach (var prop in root.EnumerateObject())
{
if (string.Equals(prop.Name, "hr.EmployeeNo", StringComparison.OrdinalIgnoreCase))
{
if (prop.Value.ValueKind == global::System.Text.Json.JsonValueKind.Object
&& prop.Value.TryGetProperty("variableValue", out var valProp)
&& valProp.ValueKind == global::System.Text.Json.JsonValueKind.String)
{
return valProp.GetString();
}
if (prop.Value.ValueKind == global::System.Text.Json.JsonValueKind.String)
{
return prop.Value.GetString();
}
}
}
}
}
catch
{
// 忽略异常 JSON
}
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];
}
}
}