namespace SimpleAdmin.Application;
///
/// 实现基于入职模板创建任务及冻结配置快照的业务能力。
///
public class OnboardingTaskService : IOnboardingTaskService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _configService;
private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskService(
IConfigService configService,
IOnboardingContractSnapshotService snapshotService,
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_configService = configService;
_snapshotService = snapshotService;
_dataPermission = dataPermission;
}
///
/// 校验公司、岗位和入职模板后创建任务,并冻结合同及材料要求。
///
public async Task Create(OnboardingTaskCreateInput input)
{
if (string.IsNullOrWhiteSpace(input.Remark))
throw Oops.Bah("请填写人事备注");
ValidateEmploymentType(input.EmploymentType);
var companyEnabled = await _db.Queryable()
.AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable");
if (!companyEnabled)
throw Oops.Bah("公司不存在或未启用");
var departmentEnabled = await _db.Queryable()
.AnyAsync(item => item.Id == input.DepartmentId && item.CompanyId == input.CompanyId && item.Status == "enable");
if (!departmentEnabled)
throw Oops.Bah("部门不存在、未启用或不属于该公司");
var positionEnabled = await _db.Queryable()
.AnyAsync(item => item.Id == input.PositionId
&& item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == "enable");
if (!positionEnabled)
throw Oops.Bah("岗位不存在、未启用或不属于该部门/公司");
var onboardingTemplate = await _db.Queryable()
.FirstAsync(item => item.Id == input.OnboardingTemplateId
&& item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == "enable")
?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司、部门");
var templateSupportsPosition = await _db.Queryable()
.AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id
&& item.PositionId == input.PositionId);
if (!templateSupportsPosition)
throw Oops.Bah("所选入职模板不适用于该岗位");
var contractConfigs = await _db.Queryable()
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
if (contractConfigs.Count == 0)
throw Oops.Bah("入职模板尚未配置合同文件");
var materialConfigs = await _db.Queryable()
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var templateIds = contractConfigs.Select(item => item.ContractTemplateId).Distinct().ToList();
var contractTemplates = await _db.Queryable()
.Where(item => templateIds.Contains(item.Id))
.ToListAsync();
if (contractTemplates.Count != templateIds.Count
|| contractTemplates.Any(item => item.CompanyId != input.CompanyId
|| item.DepartmentId != input.DepartmentId
|| !string.Equals(
item.Status,
"enable",
global::System.StringComparison.OrdinalIgnoreCase)))
{
throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板");
}
var contractPositionCount = await _db.Queryable()
.CountAsync(item => templateIds.Contains(item.ContractTemplateId)
&& item.PositionId == input.PositionId);
if (contractPositionCount != templateIds.Count)
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
var versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).Distinct().ToList();
var enabledVersionCount = await _db.Queryable()
.CountAsync(item => versionIds.Contains(item.Id)
&& item.VersionStatus == ContractTemplateVersionStatus.Enabled);
if (enabledVersionCount != versionIds.Count)
throw Oops.Bah("入职模板包含未启用的合同版本,请先调整模板配置");
var materialIds = materialConfigs.Select(item => item.MaterialTypeId).Distinct().ToList();
var materialTypes = materialIds.Count == 0
? new List()
: await _db.Queryable().Where(item => materialIds.Contains(item.Id)).ToListAsync();
if (materialTypes.Count != materialIds.Count)
throw Oops.Bah("入职模板包含不存在的材料类型,请先调整模板配置");
var expiryConfig = await _configService.GetByConfigKey(
CateGoryConst.CONFIG_BIZ_DEFINE,
"onboard.link.expireHours");
if (expiryConfig == null
|| !int.TryParse(expiryConfig.ConfigValue, out var expiryHours)
|| expiryHours <= 0)
{
throw Oops.Bah("请在系统配置中设置 onboard.link.expireHours(正整数小时)");
}
var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var expiresAt = DateTime.Now.AddHours(expiryHours);
var token = Guid.NewGuid().ToString("N");
var task = new ObOnboardingTask
{
Id = taskId,
TaskNo = $"OB{DateTime.Now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}",
CompanyId = input.CompanyId,
DepartmentId = input.DepartmentId,
PositionId = input.PositionId,
EmploymentType = input.EmploymentType,
OnboardingTemplateId = onboardingTemplate.Id,
AssignedReviewerUserId = input.AssignedReviewerUserId,
LinkExpireAt = expiresAt,
TaskStatus = OnboardingTaskStatus.PendingFill,
Remark = input.Remark.Trim()
};
ValidateInitialContractDateRanges(input.ContractVariables);
await _db.Ado.BeginTranAsync();
try
{
await _db.Insertable(task).ExecuteCommandAsync();
await _db.Insertable(new ObTaskLink
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
PlainToken = token,
TokenHash = Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(
global::System.Text.Encoding.UTF8.GetBytes(token))),
ExpiresAt = expiresAt
}).ExecuteCommandAsync();
// 初始化空白的员工入职登记表
await _db.Insertable(new ObTaskEmployeeInfo
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
Status = CommonStatusConst.ENABLE,
CreateTime = DateTime.Now
}).ExecuteCommandAsync();
var contracts = contractConfigs.Select(config =>
{
var template = contractTemplates.First(item => item.Id == config.ContractTemplateId);
return new ObTaskContract
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
TemplateId = config.ContractTemplateId,
TemplateVersionId = config.ContractTemplateVersionId,
ContractName = template.TemplateName,
ContractType = template.ContractType,
Required = config.Required,
PrintCopies = config.PrintCopies
};
}).ToList();
await _db.Insertable(contracts).ExecuteCommandAsync();
if (materialConfigs.Count > 0)
{
var materialSnapshots = materialConfigs.Select(config =>
{
var material = materialTypes.First(item => item.Id == config.MaterialTypeId);
return new ObTaskMaterialRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
MaterialTypeId = material.Id,
MaterialCode = material.MaterialCode,
MaterialName = material.MaterialName,
AllowedExtensions = material.AllowedExtensions,
MaxFileCount = material.MaxFileCount,
MaxFileSizeMb = material.MaxFileSizeMb,
Required = config.Required,
SortCode = config.SortCode
};
}).ToList();
await _db.Insertable(materialSnapshots).ExecuteCommandAsync();
}
await _snapshotService.Build(taskId);
await ApplyInitialContractVariables(input.ContractVariables, contracts);
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
return new OnboardingTaskCreatedOutput
{
TaskId = taskId,
TaskNo = task.TaskNo,
LinkToken = token,
ExpiresAt = expiresAt
};
}
///
/// 复制原任务锁定的业务配置与人事参数,并原子化创建多条独立任务。
///
public async Task Copy(OnboardingTaskCopyInput input)
{
const int maxCopyCount = 50;
if (input.Count < 1 || input.Count > maxCopyCount)
throw Oops.Bah($"复制数量必须在1到{maxCopyCount}之间");
input.Items ??= new List();
if (input.Items.Count != input.Count)
throw Oops.Bah("复制数量与人事备注数量不一致");
var missingRemarkIndex = input.Items.FindIndex(item =>
string.IsNullOrWhiteSpace(item.Remark));
if (missingRemarkIndex >= 0)
throw Oops.Bah($"请填写第{missingRemarkIndex + 1}份任务的人事备注");
var sourceTask = await _db.Queryable()
.InSingleAsync(input.SourceTaskId)
?? throw Oops.Bah("原入职任务不存在");
await _dataPermission.EnsureCanManage(
sourceTask.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
if (!sourceTask.DepartmentId.HasValue)
throw Oops.Bah("原入职任务未设置部门,不能复制");
var companyEnabled = await _db.Queryable()
.AnyAsync(item => item.Id == sourceTask.CompanyId && item.Status == "enable");
if (!companyEnabled)
throw Oops.Bah("原任务所属公司不存在或未启用");
var departmentEnabled = await _db.Queryable()
.AnyAsync(item => item.Id == sourceTask.DepartmentId.Value
&& item.CompanyId == sourceTask.CompanyId
&& item.Status == "enable");
if (!departmentEnabled)
throw Oops.Bah("原任务所属部门不存在或未启用");
var positionEnabled = await _db.Queryable()
.AnyAsync(item => item.Id == sourceTask.PositionId
&& item.CompanyId == sourceTask.CompanyId
&& item.DepartmentId == sourceTask.DepartmentId.Value
&& item.Status == "enable");
if (!positionEnabled)
throw Oops.Bah("原任务所属岗位不存在或未启用");
var onboardingTemplateEnabled = await _db.Queryable()
.AnyAsync(item => item.Id == sourceTask.OnboardingTemplateId
&& item.CompanyId == sourceTask.CompanyId
&& item.DepartmentId == sourceTask.DepartmentId.Value
&& item.Status == "enable");
if (!onboardingTemplateEnabled)
throw Oops.Bah("原任务使用的入职模板不存在或未启用");
var sourceContracts = await _db.Queryable()
.Where(item => item.TaskId == sourceTask.Id)
.ToListAsync();
if (sourceContracts.Count == 0)
throw Oops.Bah("原入职任务没有合同快照,不能复制");
var versionIds = sourceContracts
.Select(item => item.TemplateVersionId)
.Distinct()
.ToList();
var enabledVersionCount = await _db.Queryable()
.CountAsync(item => versionIds.Contains(item.Id)
&& item.VersionStatus == ContractTemplateVersionStatus.Enabled);
if (enabledVersionCount != versionIds.Count)
throw Oops.Bah("原任务包含已停用的合同版本,不能继续复制");
var sourceMaterialRequirements = await _db.Queryable()
.Where(item => item.TaskId == sourceTask.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var sourceContractIds = sourceContracts.Select(item => item.Id).ToList();
var sourceManualVariables = await _db.Queryable()
.Where(item => sourceContractIds.Contains(item.TaskContractId)
&& item.SourceType == ContractVariableSourceType.HrInput
&& item.IsManualOverride)
.ToListAsync();
var initialContractVariables = (
from contract in sourceContracts
join variable in sourceManualVariables
on contract.Id equals variable.TaskContractId
select new OnboardingInitialContractVariableInput
{
ContractTemplateVersionId = contract.TemplateVersionId,
VariableCode = variable.VariableCode,
VariableValue = variable.VariableValue
}).ToList();
ValidateInitialContractDateRanges(initialContractVariables);
var expiryConfig = await _configService.GetByConfigKey(
CateGoryConst.CONFIG_BIZ_DEFINE,
"onboard.link.expireHours");
if (expiryConfig == null
|| !int.TryParse(expiryConfig.ConfigValue, out var expiryHours)
|| expiryHours <= 0)
{
throw Oops.Bah("请在系统配置中设置 onboard.link.expireHours(正整数小时)");
}
var outputs = new List();
await _db.Ado.BeginTranAsync();
try
{
foreach (var item in input.Items)
{
var output = await CreateCopiedTask(
sourceTask,
sourceContracts,
sourceMaterialRequirements,
initialContractVariables,
expiryHours,
item.Remark.Trim());
outputs.Add(output);
}
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
return new OnboardingTaskCopyOutput
{
Tasks = outputs
};
}
///
/// 根据原任务快照创建一条全新的待填写任务,不复制任何候选人业务数据。
///
private async Task CreateCopiedTask(
ObOnboardingTask sourceTask,
List sourceContracts,
List sourceMaterialRequirements,
List initialContractVariables,
int expiryHours,
string remark)
{
var now = DateTime.Now;
var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var expiresAt = now.AddHours(expiryHours);
var token = Guid.NewGuid().ToString("N");
var task = new ObOnboardingTask
{
Id = taskId,
TaskNo = $"OB{now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}",
CompanyId = sourceTask.CompanyId,
DepartmentId = sourceTask.DepartmentId,
PositionId = sourceTask.PositionId,
EmploymentType = string.IsNullOrWhiteSpace(sourceTask.EmploymentType)
? EmployeeEmploymentType.FullTime
: sourceTask.EmploymentType,
OnboardingTemplateId = sourceTask.OnboardingTemplateId,
AssignedReviewerUserId = sourceTask.AssignedReviewerUserId,
LinkExpireAt = expiresAt,
TaskStatus = OnboardingTaskStatus.PendingFill,
Remark = remark
};
await _db.Insertable(task).ExecuteCommandAsync();
await _db.Insertable(new ObTaskLink
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
PlainToken = token,
TokenHash = Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(
global::System.Text.Encoding.UTF8.GetBytes(token))),
ExpiresAt = expiresAt
}).ExecuteCommandAsync();
await _db.Insertable(new ObTaskEmployeeInfo
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
Status = CommonStatusConst.ENABLE,
CreateTime = now
}).ExecuteCommandAsync();
var copiedContracts = sourceContracts.Select(source => new ObTaskContract
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
TemplateId = source.TemplateId,
TemplateVersionId = source.TemplateVersionId,
ContractName = source.ContractName,
ContractType = source.ContractType,
Required = source.Required,
PrintCopies = source.PrintCopies
}).ToList();
await _db.Insertable(copiedContracts).ExecuteCommandAsync();
if (sourceMaterialRequirements.Count > 0)
{
var copiedRequirements = sourceMaterialRequirements.Select(source =>
new ObTaskMaterialRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
MaterialTypeId = source.MaterialTypeId,
MaterialCode = source.MaterialCode,
MaterialName = source.MaterialName,
AllowedExtensions = source.AllowedExtensions,
MaxFileCount = source.MaxFileCount,
MaxFileSizeMb = source.MaxFileSizeMb,
Required = source.Required,
SortCode = source.SortCode
}).ToList();
await _db.Insertable(copiedRequirements).ExecuteCommandAsync();
}
// 重新生成公司、系统和空员工变量,再覆盖原任务的人事参数。
await _snapshotService.Build(taskId);
await ApplyInitialContractVariables(initialContractVariables, copiedContracts);
return new OnboardingTaskCreatedOutput
{
TaskId = taskId,
TaskNo = task.TaskNo,
LinkToken = token,
ExpiresAt = expiresAt
};
}
///
/// 校验并保存任务创建时填写的人事合同变量。
///
private async Task ApplyInitialContractVariables(
List inputs,
List contracts)
{
inputs ??= new List();
var versionIds = contracts.Select(item => item.TemplateVersionId).Distinct().ToList();
var definitions = await _db.Queryable()
.Where(item => versionIds.Contains(item.TemplateVersionId)
&& item.HrEditable
&& (item.VariableType == "hr"
|| item.VariableType == "contract"
|| item.VariableType == "company"
|| item.VariableType == ContractVariableType.Contract
|| item.VariableType == ContractVariableType.Company
|| item.VariableCode.StartsWith("hr.")))
.ToListAsync();
var duplicateInput = inputs
.GroupBy(item => new { item.ContractTemplateVersionId, item.VariableCode })
.FirstOrDefault(group => group.Count() > 1);
if (duplicateInput != null)
throw Oops.Bah($"合同变量“{duplicateInput.Key.VariableCode}”重复提交");
foreach (var input in inputs)
{
var definitionExists = definitions.Any(item =>
item.TemplateVersionId == input.ContractTemplateVersionId
&& item.VariableCode == input.VariableCode);
if (!definitionExists)
throw Oops.Bah($"合同变量“{input.VariableCode}”不存在或不允许人事填写");
}
var missingRequired = definitions.FirstOrDefault(definition =>
{
if (!definition.Required)
return false;
var input = inputs.FirstOrDefault(item =>
item.ContractTemplateVersionId == definition.TemplateVersionId
&& item.VariableCode == definition.VariableCode);
return string.IsNullOrWhiteSpace(input?.VariableValue ?? definition.DefaultValue);
});
if (missingRequired != null)
throw Oops.Bah($"请填写合同变量“{missingRequired.VariableName}”");
if (inputs.Count == 0)
return;
var duplicateContractVersion = contracts
.GroupBy(item => item.TemplateVersionId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateContractVersion != null)
throw Oops.Bah("入职模板中存在重复的合同版本,请重新保存入职模板后再创建任务");
var contractByVersion = contracts.ToDictionary(item => item.TemplateVersionId);
var contractIds = contracts.Select(item => item.Id).ToList();
var rows = await _db.Queryable()
.Where(item => contractIds.Contains(item.TaskContractId))
.ToListAsync();
foreach (var input in inputs)
{
var contract = contractByVersion[input.ContractTemplateVersionId];
var row = rows.FirstOrDefault(item =>
item.TaskContractId == contract.Id && item.VariableCode == input.VariableCode)
?? throw Oops.Bah($"合同变量快照“{input.VariableCode}”不存在");
row.VariableValue = input.VariableValue;
row.SourceType = ContractVariableSourceType.HrInput;
row.IsManualOverride = true;
}
var changedRows = rows.Where(item => item.IsManualOverride).ToList();
if (changedRows.Count > 0)
await _db.Updateable(changedRows).ExecuteCommandAsync();
}
///
/// 校验人事创建任务时填写的合同日期先后关系。
///
internal static void ValidateInitialContractDateRanges(
List inputs)
{
inputs ??= new List();
foreach (var group in inputs.GroupBy(item => item.ContractTemplateVersionId))
{
ValidateDateRange(group, "hr.ContractStartDate", "hr.ContractEndDate", "合同");
ValidateDateRange(group, "hr.ProbationStartDate", "hr.ProbationEndDate", "试用期");
}
}
///
/// 校验同一合同版本内一组开始、结束日期。
///
private static void ValidateDateRange(
IEnumerable inputs,
string startCode,
string endCode,
string rangeName)
{
var startValue = inputs.FirstOrDefault(item =>
string.Equals(item.VariableCode, startCode, StringComparison.OrdinalIgnoreCase))?.VariableValue;
var endValue = inputs.FirstOrDefault(item =>
string.Equals(item.VariableCode, endCode, StringComparison.OrdinalIgnoreCase))?.VariableValue;
if (string.IsNullOrWhiteSpace(startValue) || string.IsNullOrWhiteSpace(endValue))
return;
if (!DateTime.TryParse(startValue, out var startDate))
throw Oops.Bah($"{rangeName}开始日期格式不正确");
if (!DateTime.TryParse(endValue, out var endDate))
throw Oops.Bah($"{rangeName}结束日期格式不正确");
if (endDate.Date < startDate.Date)
throw Oops.Bah($"{rangeName}结束日期不能早于开始日期");
}
private static void ValidateEmploymentType(string employmentType)
{
if (employmentType != EmployeeEmploymentType.FullTime
&& employmentType != EmployeeEmploymentType.PartTime)
{
throw Oops.Bah("用工类型不正确,请选择正式员工或兼职员工");
}
}
}