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 now = DateTime.Now; var taskVersions = await _db.Queryable() .Where(item => versionIds.Contains(item.Id)) .ToListAsync(); if (taskVersions.Count != versionIds.Count || taskVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now))) 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 employeeFieldRequirements = await OnboardingEmployeeFieldRequirementSnapshot.BuildFromCurrentDefinitions(_db, taskId); if (employeeFieldRequirements.Count > 0) await _db.Insertable(employeeFieldRequirements).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 now = DateTime.Now; var copyVersions = await _db.Queryable() .Where(item => versionIds.Contains(item.Id)) .ToListAsync(); if (copyVersions.Count != versionIds.Count || copyVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now))) throw Oops.Bah("原任务包含未生效、已失效或未启用的签署材料版本,不能继续复制"); var sourceMaterialRequirements = await _db.Queryable() .Where(item => item.TaskId == sourceTask.Id) .OrderBy(item => item.SortCode) .ToListAsync(); var sourceEmployeeFieldRequirements = (await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, sourceTask.Id)) .Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField) .ToList(); 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, sourceEmployeeFieldRequirements, 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 sourceEmployeeFieldRequirements, 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(); if (sourceEmployeeFieldRequirements.Count > 0) { var copiedEmployeeFieldRequirements = sourceEmployeeFieldRequirements.Select(source => new ObTaskFormRequirement { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TaskId = taskId, FieldId = source.FieldId, FieldCode = source.FieldCode, FieldName = source.FieldName, DataType = source.DataType, OptionsJson = source.OptionsJson, Required = source.Required, SortCode = source.SortCode }).ToList(); await _db.Insertable(copiedEmployeeFieldRequirements).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("用工类型不正确,请选择正式员工或兼职员工"); } } }