diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs index b372944..dd0cf51 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs @@ -37,6 +37,11 @@ public class ObOnboardingTask : BaseEntity /// public long PositionId { get; set; } + /// + /// 创建任务时锁定的入职模板 Id。 + /// + public long OnboardingTemplateId { get; set; } + /// /// 创建任务时指定的审核人用户 Id。 /// diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplate.cs b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplate.cs new file mode 100644 index 0000000..082c800 --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplate.cs @@ -0,0 +1,29 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职模板,统一组织公司、岗位、合同、补充字段和上传材料。 +/// +[SugarTable("ob_onboarding_template", TableDescription = "入职模板")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObOnboardingTemplate : BaseEntity +{ + /// + /// 入职模板业务编码。 + /// + public string TemplateCode { get; set; } + + /// + /// 入职模板显示名称。 + /// + public string TemplateName { get; set; } + + /// + /// 所属入职业务公司 Id。 + /// + public long CompanyId { get; set; } + + /// + /// 模板用途和适用范围说明。 + /// + public string Description { get; set; } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplateContract.cs b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplateContract.cs new file mode 100644 index 0000000..7d7d2b8 --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplateContract.cs @@ -0,0 +1,39 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职模板需要使用的合同文件及锁定版本。 +/// +[SugarTable("ob_onboarding_template_contract", TableDescription = "入职模板合同配置")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObOnboardingTemplateContract : BaseEntity +{ + /// + /// 入职模板 Id。 + /// + public long OnboardingTemplateId { get; set; } + + /// + /// 合同模板主档 Id。 + /// + public long ContractTemplateId { get; set; } + + /// + /// 锁定的合同模板版本 Id。 + /// + public long ContractTemplateVersionId { get; set; } + + /// + /// 员工是否必须查看并确认该合同。 + /// + public bool Required { get; set; } + + /// + /// 合同打印份数。 + /// + public int PrintCopies { get; set; } = 1; + + /// + /// 显示和生成顺序。 + /// + public int? SortCode { get; set; } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplateFormField.cs b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplateFormField.cs new file mode 100644 index 0000000..13683bc --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplateFormField.cs @@ -0,0 +1,29 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职模板需要员工填写的动态补充字段。 +/// +[SugarTable("ob_onboarding_template_form_field", TableDescription = "入职模板补充字段")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObOnboardingTemplateFormField : BaseEntity +{ + /// + /// 入职模板 Id。 + /// + public long OnboardingTemplateId { get; set; } + + /// + /// 动态补充字段 Id。 + /// + public long FieldId { get; set; } + + /// + /// 员工是否必须填写。 + /// + public bool Required { get; set; } + + /// + /// 手机端显示顺序。 + /// + public int? SortCode { get; set; } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplateMaterial.cs b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplateMaterial.cs new file mode 100644 index 0000000..b97725f --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplateMaterial.cs @@ -0,0 +1,29 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职模板需要员工上传的材料配置。 +/// +[SugarTable("ob_onboarding_template_material", TableDescription = "入职模板上传材料")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObOnboardingTemplateMaterial : BaseEntity +{ + /// + /// 入职模板 Id。 + /// + public long OnboardingTemplateId { get; set; } + + /// + /// 上传材料类型 Id。 + /// + public long MaterialTypeId { get; set; } + + /// + /// 员工是否必须上传。 + /// + public bool Required { get; set; } + + /// + /// 手机端显示顺序。 + /// + public int? SortCode { get; set; } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplatePosition.cs b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplatePosition.cs new file mode 100644 index 0000000..99805ef --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplatePosition.cs @@ -0,0 +1,19 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职模板适用岗位关系。 +/// +[SugarTable("ob_onboarding_template_position", TableDescription = "入职模板适用岗位")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObOnboardingTemplatePosition : BaseEntity +{ + /// + /// 入职模板 Id。 + /// + public long OnboardingTemplateId { get; set; } + + /// + /// 适用岗位 Id。 + /// + public long PositionId { get; set; } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObTaskContract.cs b/SimpleAdmin.Application/Entity/Onboarding/ObTaskContract.cs index 2abb9cc..0d1d126 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObTaskContract.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObTaskContract.cs @@ -47,6 +47,11 @@ public class ObTaskContract : BaseEntity /// public bool Required { get; set; } + /// + /// 任务创建时冻结的合同打印份数。 + /// + public int PrintCopies { get; set; } = 1; + /// /// 任务合同当前状态。 /// diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObTaskFormRequirement.cs b/SimpleAdmin.Application/Entity/Onboarding/ObTaskFormRequirement.cs new file mode 100644 index 0000000..1666e28 --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObTaskFormRequirement.cs @@ -0,0 +1,49 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职任务创建时冻结的动态补充字段要求。 +/// +[SugarTable("ob_task_form_requirement", TableDescription = "任务补充字段快照")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObTaskFormRequirement : BaseEntity +{ + /// + /// 入职任务 Id。 + /// + public long TaskId { get; set; } + + /// + /// 原动态字段 Id。 + /// + public long FieldId { get; set; } + + /// + /// 冻结的字段业务编码。 + /// + public string FieldCode { get; set; } + + /// + /// 冻结的字段显示名称。 + /// + public string FieldName { get; set; } + + /// + /// 冻结的数据类型。 + /// + public string DataType { get; set; } + + /// + /// 冻结的选项 JSON。 + /// + public string OptionsJson { get; set; } + + /// + /// 是否必须填写。 + /// + public bool Required { get; set; } + + /// + /// 手机端显示顺序。 + /// + public int? SortCode { get; set; } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObTaskMaterialRequirement.cs b/SimpleAdmin.Application/Entity/Onboarding/ObTaskMaterialRequirement.cs new file mode 100644 index 0000000..459f902 --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObTaskMaterialRequirement.cs @@ -0,0 +1,54 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职任务创建时冻结的上传材料要求。 +/// +[SugarTable("ob_task_material_requirement", TableDescription = "任务上传材料快照")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObTaskMaterialRequirement : BaseEntity +{ + /// + /// 入职任务 Id。 + /// + public long TaskId { get; set; } + + /// + /// 原材料类型 Id。 + /// + public long MaterialTypeId { get; set; } + + /// + /// 冻结的材料编码。 + /// + public string MaterialCode { get; set; } + + /// + /// 冻结的材料名称。 + /// + public string MaterialName { get; set; } + + /// + /// 冻结的允许扩展名。 + /// + public string AllowedExtensions { get; set; } + + /// + /// 冻结的最大文件数量。 + /// + public int MaxFileCount { get; set; } + + /// + /// 冻结的单文件大小上限,单位 MB。 + /// + public int? MaxFileSizeMb { get; set; } + + /// + /// 是否必须上传。 + /// + public bool Required { get; set; } + + /// + /// 手机端显示顺序。 + /// + public int? SortCode { get; set; } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs b/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs index a015bd8..dd9bc6e 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs @@ -139,6 +139,10 @@ public static class TaskContractStatus /// public static class ContractVariableType { + /// + /// 导入模板时尚未确认来源的变量。 + /// + public const string Unresolved = "unresolved"; /// /// 值来源于候选人或正式员工信息。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs b/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs index a5c3f4e..e807c7c 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs @@ -28,12 +28,12 @@ public class OnboardingAuditService : IOnboardingAuditService .FirstAsync(item => item.TaskId == task.Id) ?? throw Oops.Bah("候选人资料不存在"); - var hasMaterialNeedSupplement = await _db.Queryable() + var hasUnapprovedMaterial = await _db.Queryable() .AnyAsync(item => item.TaskId == task.Id - && item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement); - if (hasMaterialNeedSupplement) + && item.ReviewStatus != TaskMaterialReviewStatus.Passed); + if (hasUnapprovedMaterial) { - throw Oops.Bah("仍有资料需要补充,不能审核通过"); + throw Oops.Bah("仍有资料未审核通过,不能审核通过入职任务"); } // 审核通过是创建正式员工的唯一入口,因此必须在入库前拦截身份证号或手机号重复。 diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs index 4771350..bea8ef2 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs @@ -1,7 +1,7 @@ namespace SimpleAdmin.Application; /// -/// 实现入职合同文件生成业务能力。 +/// 实现入职合同预览和归档 DOCX 文件生成能力。 /// public class OnboardingContractDocumentService : IOnboardingContractDocumentService { @@ -15,7 +15,7 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ } /// - /// 根据已锁定的模板版本和变量快照生成任务合同 DOCX 文件。 + /// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。 /// public async Task Generate(OnboardingGenerateContractInput input) { @@ -23,10 +23,12 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ ?? throw Oops.Bah("任务合同不存在"); var task = await _db.Queryable().InSingleAsync(contract.TaskId) ?? throw Oops.Bah("入职任务不存在"); - - if (task.TaskStatus != OnboardingTaskStatus.Approved) - throw Oops.Bah("仅审核通过的任务可生成归档合同"); - + var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill + || task.TaskStatus == OnboardingTaskStatus.Rejected + || task.TaskStatus == OnboardingTaskStatus.Reviewing + || task.TaskStatus == OnboardingTaskStatus.Approved; + if (!statusAllowsGeneration) + throw Oops.Bah("当前任务状态不能生成合同"); if (contract.GeneratedDocxFileId.HasValue) return contract.GeneratedDocxFileId.Value; @@ -34,20 +36,31 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ ?? throw Oops.Bah("合同版本不存在"); var source = await _db.Queryable().InSingleAsync(version.SourceDocxFileId) ?? throw Oops.Bah("合同源文件不存在"); - var variables = await _db.Queryable() - .Where(x => x.TaskContractId == contract.Id) + var definitions = await _db.Queryable() + .Where(item => item.TemplateVersionId == version.Id) .ToListAsync(); + var variables = await _db.Queryable() + .Where(item => item.TaskContractId == contract.Id) + .ToListAsync(); + var missing = definitions + .Where(item => item.Required) + .FirstOrDefault(definition => string.IsNullOrWhiteSpace( + variables.FirstOrDefault(variable => variable.VariableCode == definition.VariableCode)?.VariableValue)); + if (missing != null) + throw Oops.Bah($"合同变量“{missing.VariableName}”尚未填写,暂时不能预览合同"); + var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id }) ?? throw Oops.Bah("无法读取合同源文件"); - await using var sourceStream = sourceResult.FileStream; using var sourceBytes = new MemoryStream(); await sourceStream.CopyToAsync(sourceBytes); - // 合同只读取任务变量快照,确保模板或员工档案后续修改不会影响已生成文件。 - var rendered = ReplaceDocxText( + var rendered = DocxTemplateProcessor.Render( sourceBytes.ToArray(), - variables.ToDictionary(x => x.VariableCode, x => x.VariableValue ?? string.Empty)); + variables.ToDictionary( + item => item.VariableCode, + item => item.VariableValue ?? string.Empty, + StringComparer.OrdinalIgnoreCase)); var name = $"{task.TaskNo}_{contract.ContractName}.docx"; var fileId = await _fileService.StoreGeneratedFile( SysDictConst.FILE_ENGINE_ALIYUN_OSS, @@ -56,53 +69,10 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ contract.GeneratedDocxFileId = fileId; contract.GeneratedAt = DateTime.Now; + contract.ContractStatus = task.TaskStatus == OnboardingTaskStatus.Approved + ? TaskContractStatus.OfflinePendingSign + : TaskContractStatus.PendingConfirm; await _db.Updateable(contract).ExecuteCommandAsync(); - return fileId; } - - private static byte[] ReplaceDocxText(byte[] docx, Dictionary values) - { - using var input = new MemoryStream(docx); - using var archive = new global::System.IO.Compression.ZipArchive( - input, - global::System.IO.Compression.ZipArchiveMode.Read); - using var output = new MemoryStream(); - using var target = new global::System.IO.Compression.ZipArchive( - output, - global::System.IO.Compression.ZipArchiveMode.Create, - true); - - // DOCX 本质是 ZIP 包,仅替换正文、页眉和页脚 XML 中的文本节点,其他资源原样复制。 - foreach (var entry in archive.Entries) - { - var next = target.CreateEntry(entry.FullName, global::System.IO.Compression.CompressionLevel.Optimal); - using var reader = entry.Open(); - using var writer = next.Open(); - - var isWordTextXml = entry.FullName == "word/document.xml" - || ((entry.FullName.StartsWith("word/header") || entry.FullName.StartsWith("word/footer")) - && entry.FullName.EndsWith(".xml")); - if (!isWordTextXml) - { - reader.CopyTo(writer); - continue; - } - - using var textReader = new StreamReader(reader); - var document = global::System.Xml.Linq.XDocument.Parse(textReader.ReadToEnd()); - var word = global::System.Xml.Linq.XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main"); - - foreach (var node in document.Descendants(word + "t")) - { - foreach (var item in values) - node.Value = node.Value.Replace("{{" + item.Key + "}}", item.Value ?? string.Empty); - } - - using var textWriter = new StreamWriter(writer); - textWriter.Write(document.ToString(global::System.Xml.Linq.SaveOptions.DisableFormatting)); - } - - return output.ToArray(); - } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/IOnboardingContractSnapshotService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/IOnboardingContractSnapshotService.cs index b05e6fc..0621a55 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/IOnboardingContractSnapshotService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/IOnboardingContractSnapshotService.cs @@ -9,5 +9,5 @@ public interface IOnboardingContractSnapshotService : ITransient /// /// 根据任务、公司和候选人资料构建或刷新合同变量快照。 /// - Task Build(long taskId, ObTaskEmployeeInfo employeeInfo = null); + Task Build(long taskId, ObTaskEmployeeInfo employeeInfo = null, bool invalidateGeneratedPreview = false); } diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs index ebfa2f3..66ac864 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs @@ -1,32 +1,53 @@ namespace SimpleAdmin.Application; -/// 冻结任务合同变量;DOCX 生成阶段只读取本快照,不再读取模板实时值。 /// -/// 实现任务合同变量快照业务能力。 +/// 构建任务合同变量快照,合同生成阶段只读取本快照。 /// public class OnboardingContractSnapshotService : IOnboardingContractSnapshotService { private readonly SqlSugarScope _db; - public OnboardingContractSnapshotService() => _db = DbContext.DB; + + public OnboardingContractSnapshotService() + { + _db = DbContext.DB; + } /// - /// 根据任务、公司和候选人资料构建或刷新合同变量快照。 + /// 根据任务、公司、员工固定信息和动态补充字段构建合同变量快照。 /// - public async Task Build(long taskId, ObTaskEmployeeInfo employeeInfo = null) + public async Task Build( + long taskId, + ObTaskEmployeeInfo employeeInfo = null, + bool invalidateGeneratedPreview = false) { - var task = await _db.Queryable().InSingleAsync(taskId) ?? throw Oops.Bah("入职任务不存在"); - var company = await _db.Queryable().InSingleAsync(task.CompanyId) ?? throw Oops.Bah("入职公司不存在"); - employeeInfo ??= await _db.Queryable().FirstAsync(x => x.TaskId == taskId); - var contracts = await _db.Queryable().Where(x => x.TaskId == taskId).ToListAsync(); + var task = await _db.Queryable().InSingleAsync(taskId) + ?? throw Oops.Bah("入职任务不存在"); + var company = await _db.Queryable().InSingleAsync(task.CompanyId) + ?? throw Oops.Bah("入职公司不存在"); + employeeInfo ??= await _db.Queryable() + .FirstAsync(item => item.TaskId == taskId); + var dynamicValues = await _db.Queryable() + .Where(item => item.TaskId == taskId) + .ToListAsync(); + var dynamicValueMap = dynamicValues + .GroupBy(item => item.FieldCode, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Last().FieldValue, StringComparer.OrdinalIgnoreCase); + var contracts = await _db.Queryable() + .Where(item => item.TaskId == taskId) + .ToListAsync(); + foreach (var contract in contracts) { - if (contract.GeneratedDocxFileId.HasValue || contract.GeneratedPdfFileId.HasValue) throw Oops.Bah("合同已生成,不能重建变量快照"); - var definitions = await _db.Queryable().Where(x => x.TemplateVersionId == contract.TemplateVersionId).OrderBy(x => x.SortCode).ToListAsync(); - var oldRows = await _db.Queryable().Where(x => x.TaskContractId == contract.Id).ToListAsync(); + var definitions = await _db.Queryable() + .Where(item => item.TemplateVersionId == contract.TemplateVersionId) + .OrderBy(item => item.SortCode) + .ToListAsync(); + var oldRows = await _db.Queryable() + .Where(item => item.TaskContractId == contract.Id) + .ToListAsync(); var rows = definitions.Select(definition => { - var old = oldRows.FirstOrDefault(x => x.VariableCode == definition.VariableCode); - // 人事手工填写的工资、补贴等合同参数优先级最高,刷新自动变量时不能覆盖。 + var old = oldRows.FirstOrDefault(item => item.VariableCode == definition.VariableCode); var preserveManual = old?.IsManualOverride == true; return new ObTaskContractVariable { @@ -36,54 +57,136 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ VariableName = definition.VariableName, VariableType = definition.VariableType, DataType = definition.DataType, - VariableValue = preserveManual ? old.VariableValue : ResolveValue(definition, employeeInfo, company, task), - SourceType = preserveManual ? ContractVariableSourceType.HrInput : GetSourceType(definition.VariableType), + VariableValue = preserveManual + ? old.VariableValue + : ResolveValue(definition, employeeInfo, dynamicValueMap, company, task), + SourceType = preserveManual + ? ContractVariableSourceType.HrInput + : GetSourceType(definition.VariableType), IsManualOverride = preserveManual }; }).ToList(); - await _db.Deleteable().Where(x => x.TaskContractId == contract.Id).ExecuteCommandAsync(); - if (rows.Count > 0) await _db.Insertable(rows).ExecuteCommandAsync(); + + await _db.Deleteable() + .Where(item => item.TaskContractId == contract.Id) + .ExecuteCommandAsync(); + if (rows.Count > 0) + await _db.Insertable(rows).ExecuteCommandAsync(); + + if (invalidateGeneratedPreview && task.TaskStatus is OnboardingTaskStatus.PendingFill or OnboardingTaskStatus.Rejected) + { + contract.GeneratedDocxFileId = null; + contract.GeneratedPdfFileId = null; + contract.GeneratedAt = null; + contract.ReadAt = null; + contract.ConfirmedAt = null; + contract.ContractStatus = TaskContractStatus.PendingGenerate; + await _db.Updateable(contract).ExecuteCommandAsync(); + } } } - private static string GetSourceType(string variableType) => variableType switch + private static string GetSourceType(string variableType) { - ContractVariableType.Employee => ContractVariableSourceType.Employee, - ContractVariableType.Company => ContractVariableSourceType.Company, - ContractVariableType.System => ContractVariableSourceType.System, - _ => ContractVariableSourceType.HrInput - }; + return variableType switch + { + ContractVariableType.Employee => ContractVariableSourceType.Employee, + ContractVariableType.Company => ContractVariableSourceType.Company, + ContractVariableType.System => ContractVariableSourceType.System, + _ => ContractVariableSourceType.HrInput + }; + } - private static string ResolveValue(ObTemplateVariable definition, ObTaskEmployeeInfo info, ObCompany company, ObOnboardingTask task) + private static string ResolveValue( + ObTemplateVariable definition, + ObTaskEmployeeInfo employeeInfo, + IReadOnlyDictionary dynamicValues, + ObCompany company, + ObOnboardingTask task) { + var sourceField = GetSourceField(definition); if (definition.VariableType == ContractVariableType.Employee) - return GetPropertyValue(info, definition.EmployeeField) ?? definition.DefaultValue; + { + var fixedValue = GetPropertyValue(employeeInfo, ResolveEmployeeField(sourceField), definition.FormatRule); + if (!string.IsNullOrWhiteSpace(fixedValue)) + return fixedValue; + return dynamicValues.TryGetValue(sourceField, out var dynamicValue) + ? dynamicValue + : definition.DefaultValue; + } + if (definition.VariableType == ContractVariableType.Company) - return GetPropertyValue(company, ResolveCompanyField(definition)) ?? definition.DefaultValue; - if (definition.VariableType == ContractVariableType.System && string.Equals(definition.VariableCode, "TASK_NO", StringComparison.OrdinalIgnoreCase)) - return task.TaskNo; + return GetPropertyValue(company, ResolveCompanyField(sourceField), definition.FormatRule) ?? definition.DefaultValue; + + if (definition.VariableType == ContractVariableType.System) + { + return sourceField.ToUpperInvariant() switch + { + "TASKNO" or "TASK_NO" or "CONTRACTNO" or "CONTRACT_NO" => task.TaskNo, + "CURRENTDATE" or "CURRENT_DATE" or "SIGNDATE" or "SIGN_DATE" => DateTime.Now.ToString("yyyy年MM月dd日"), + _ => definition.DefaultValue + }; + } + return definition.DefaultValue; } - private static string GetPropertyValue(object source, string propertyName) + private static string GetSourceField(ObTemplateVariable definition) { - if (source == null || string.IsNullOrWhiteSpace(propertyName)) return null; - return source.GetType().GetProperty(propertyName, global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.IgnoreCase)?.GetValue(source)?.ToString(); + if (!string.IsNullOrWhiteSpace(definition.EmployeeField)) + return definition.EmployeeField; + var separatorIndex = definition.VariableCode?.IndexOf('.') ?? -1; + return separatorIndex >= 0 + ? definition.VariableCode[(separatorIndex + 1)..] + : definition.VariableCode; } - private static string ResolveCompanyField(ObTemplateVariable definition) + private static string GetPropertyValue(object source, string propertyName, string formatRule = null) { - if (!string.IsNullOrWhiteSpace(definition.EmployeeField)) return definition.EmployeeField; - return definition.VariableCode.ToUpperInvariant() switch + if (source == null || string.IsNullOrWhiteSpace(propertyName)) + return null; + + var value = source.GetType() + .GetProperty( + propertyName, + global::System.Reflection.BindingFlags.Public + | global::System.Reflection.BindingFlags.Instance + | global::System.Reflection.BindingFlags.IgnoreCase) + ?.GetValue(source); + if (value == null) + return null; + + if (value is DateTime dateTime) + return dateTime.ToString(string.IsNullOrWhiteSpace(formatRule) ? "yyyy年MM月dd日" : formatRule); + if (value is DateTimeOffset dateTimeOffset) + return dateTimeOffset.ToString(string.IsNullOrWhiteSpace(formatRule) ? "yyyy年MM月dd日" : formatRule); + if (!string.IsNullOrWhiteSpace(formatRule) && value is IFormattable formattable) + return formattable.ToString(formatRule, null); + return value.ToString(); + } + + private static string ResolveEmployeeField(string sourceField) + { + return sourceField.ToUpperInvariant() switch { - "COMPANY_CODE" => nameof(ObCompany.CompanyCode), - "COMPANY_NAME" => nameof(ObCompany.CompanyName), - "LEGAL_NAME" or "COMPANY_LEGAL_NAME" => nameof(ObCompany.LegalName), - "UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode), - "LEGAL_REPRESENTATIVE" => nameof(ObCompany.LegalRepresentative), - "REGISTERED_ADDRESS" => nameof(ObCompany.RegisteredAddress), - "CONTACT_PHONE" => nameof(ObCompany.ContactPhone), - _ => definition.VariableCode + "FULLNAME" or "FULL_NAME" or "NAME" => nameof(ObTaskEmployeeInfo.EmployeeName), + "MOBILE" or "MOBILEPHONE" or "MOBILE_PHONE" => nameof(ObTaskEmployeeInfo.Phone), + "IDNUMBER" or "ID_NUMBER" or "IDENTITYNUMBER" => nameof(ObTaskEmployeeInfo.IdCardNumber), + _ => sourceField }; } -} + private static string ResolveCompanyField(string sourceField) + { + return sourceField.ToUpperInvariant() switch + { + "COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.CompanyCode), + "COMPANYNAME" or "COMPANY_NAME" => nameof(ObCompany.CompanyName), + "LEGALNAME" or "LEGAL_NAME" or "COMPANYLEGALNAME" => nameof(ObCompany.LegalName), + "UNIFIEDSOCIALCREDITCODE" or "UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode), + "LEGALREPRESENTATIVE" or "LEGAL_REPRESENTATIVE" => nameof(ObCompany.LegalRepresentative), + "REGISTEREDADDRESS" or "REGISTERED_ADDRESS" => nameof(ObCompany.RegisteredAddress), + "CONTACTPHONE" or "CONTACT_PHONE" => nameof(ObCompany.ContactPhone), + _ => sourceField + }; + } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/DocxTemplateProcessor.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/DocxTemplateProcessor.cs new file mode 100644 index 0000000..95bedae --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/DocxTemplateProcessor.cs @@ -0,0 +1,160 @@ +namespace SimpleAdmin.Application; + +/// +/// 负责从 DOCX 中识别占位符并在不依赖 Word 进程的情况下替换文本节点。 +/// +public static class DocxTemplateProcessor +{ + private static readonly global::System.Text.RegularExpressions.Regex PlaceholderRegex = new( + @"\{\{\s*(?[A-Za-z][A-Za-z0-9_.-]*)\s*\}\}", + global::System.Text.RegularExpressions.RegexOptions.Compiled); + + private static readonly global::System.Xml.Linq.XNamespace WordNamespace = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + + /// + /// 扫描正文、表格、页眉和页脚,返回去重后的变量编码。 + /// + public static List ExtractVariableCodes(byte[] docx) + { + using var input = new MemoryStream(docx); + using var archive = new global::System.IO.Compression.ZipArchive( + input, + global::System.IO.Compression.ZipArchiveMode.Read); + var codes = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName))) + { + using var stream = entry.Open(); + var document = global::System.Xml.Linq.XDocument.Load(stream); + foreach (var paragraph in document.Descendants(WordNamespace + "p")) + { + var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value)); + foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text)) + codes.Add(match.Groups["code"].Value.Trim()); + } + } + + return codes.OrderBy(code => code).ToList(); + } + + /// + /// 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。 + /// + public static byte[] Render(byte[] docx, IReadOnlyDictionary values) + { + using var input = new MemoryStream(docx); + using var archive = new global::System.IO.Compression.ZipArchive( + input, + global::System.IO.Compression.ZipArchiveMode.Read); + using var output = new MemoryStream(); + using var target = new global::System.IO.Compression.ZipArchive( + output, + global::System.IO.Compression.ZipArchiveMode.Create, + true); + + foreach (var entry in archive.Entries) + { + var next = target.CreateEntry( + entry.FullName, + global::System.IO.Compression.CompressionLevel.Optimal); + using var sourceStream = entry.Open(); + using var targetStream = next.Open(); + if (!IsWordTextXml(entry.FullName)) + { + sourceStream.CopyTo(targetStream); + continue; + } + + var document = global::System.Xml.Linq.XDocument.Load(sourceStream); + foreach (var paragraph in document.Descendants(WordNamespace + "p")) + ReplaceParagraphVariables(paragraph, values); + document.Save(targetStream, global::System.Xml.Linq.SaveOptions.DisableFormatting); + } + + target.Dispose(); + return output.ToArray(); + } + + private static void ReplaceParagraphVariables( + global::System.Xml.Linq.XElement paragraph, + IReadOnlyDictionary values) + { + var replacements = 0; + while (replacements < 1000) + { + var nodes = paragraph.Descendants(WordNamespace + "t").ToList(); + var text = string.Concat(nodes.Select(node => node.Value)); + var match = PlaceholderRegex.Matches(text) + .Cast() + .LastOrDefault(item => values.ContainsKey(item.Groups["code"].Value.Trim())); + if (match == null) + return; + + var code = match.Groups["code"].Value.Trim(); + ReplaceRange(nodes, match.Index, match.Length, values[code] ?? string.Empty); + replacements++; + } + + throw Oops.Bah("合同模板单个段落中的变量数量异常"); + } + + private static void ReplaceRange( + List nodes, + int start, + int length, + string replacement) + { + var end = start + length; + var offset = 0; + global::System.Xml.Linq.XElement startNode = null; + global::System.Xml.Linq.XElement endNode = null; + var startIndex = 0; + var endIndex = 0; + + foreach (var node in nodes) + { + var nextOffset = offset + node.Value.Length; + if (startNode == null && start >= offset && start < nextOffset) + { + startNode = node; + startIndex = start - offset; + } + + if (end > offset && end <= nextOffset) + { + endNode = node; + endIndex = end - offset; + break; + } + + offset = nextOffset; + } + + if (startNode == null || endNode == null) + return; + + if (ReferenceEquals(startNode, endNode)) + { + startNode.Value = startNode.Value[..startIndex] + + replacement + + startNode.Value[endIndex..]; + return; + } + + var startNodePosition = nodes.IndexOf(startNode); + var endNodePosition = nodes.IndexOf(endNode); + startNode.Value = startNode.Value[..startIndex] + replacement; + for (var index = startNodePosition + 1; index < endNodePosition; index++) + nodes[index].Value = string.Empty; + endNode.Value = endNode.Value[endIndex..]; + } + + private static bool IsWordTextXml(string entryName) + { + return entryName == "word/document.xml" + || ((entryName.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) + || entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase)) + && entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractScopeInput.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractScopeInput.cs deleted file mode 100644 index ca4a1d3..0000000 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractScopeInput.cs +++ /dev/null @@ -1,73 +0,0 @@ -namespace SimpleAdmin.Application; - -/// -/// 合同适用范围分页查询参数。 -/// -public class OnboardingContractScopePageInput : BasePageInput -{ - /// - /// 入职业务公司 Id。 - /// - public long? CompanyId { get; set; } - - /// - /// 岗位 Id;为空时表示公司级配置。 - /// - public long? PositionId { get; set; } - - /// - /// 合同模板 Id。 - /// - public long? TemplateId { get; set; } -} - -/// -/// 新增合同适用范围参数。 -/// -public class OnboardingContractScopeAddInput -{ - /// - /// 入职业务公司 Id。 - /// - [IdNotNull(ErrorMessage = "公司Id不能为空")] - public long CompanyId { get; set; } - - /// - /// 岗位 Id;为空时表示公司级配置。 - /// - public long? PositionId { get; set; } - - /// - /// 合同模板 Id。 - /// - [IdNotNull(ErrorMessage = "模板Id不能为空")] - public long TemplateId { get; set; } - - /// - /// 合同模板版本 Id。 - /// - [IdNotNull(ErrorMessage = "模板版本Id不能为空")] - public long TemplateVersionId { get; set; } - - /// - /// 是否必填、必传或必签。 - /// - public bool Required { get; set; } = true; - - /// - /// 显示排序码。 - /// - public int? SortCode { get; set; } -} - -/// -/// 编辑合同适用范围参数。 -/// -public class OnboardingContractScopeEditInput : OnboardingContractScopeAddInput -{ - /// - /// 业务数据 Id。 - /// - [IdNotNull(ErrorMessage = "适用范围Id不能为空")] - public long Id { get; set; } -} diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs index 30f7dea..e3d32dd 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs @@ -1,7 +1,7 @@ namespace SimpleAdmin.Application; /// -/// 定义合同模板、版本、变量和适用范围业务能力。 +/// 定义合同模板、DOCX 版本和自动识别变量的管理能力。 /// public interface IOnboardingContractTemplateService : ITransient { @@ -9,59 +9,49 @@ public interface IOnboardingContractTemplateService : ITransient /// 分页查询合同模板主档案。 /// Task> Page(OnboardingContractTemplatePageInput input); + /// /// 查询合同模板详情。 /// Task Detail(BaseIdInput input); + /// /// 新增合同模板主档案。 /// Task Add(OnboardingContractTemplateAddInput input); + /// /// 编辑合同模板主档案。 /// Task Edit(OnboardingContractTemplateEditInput input); + /// - /// 删除未被版本、适用范围或任务合同引用的模板。 + /// 删除未被版本、入职模板或任务合同引用的模板。 /// Task Delete(BaseIdListInput input); + /// /// 分页查询指定合同模板的版本。 /// Task> VersionPage(OnboardingContractVersionPageInput input); + /// - /// 新增合同模板 DOCX 版本。 + /// 新增合同模板 DOCX 版本并自动识别变量。 /// Task AddVersion(OnboardingContractVersionAddInput input); + /// /// 启用或停用合同模板版本。 /// Task SetVersionStatus(OnboardingContractVersionStatusInput input); + /// - /// 分页查询指定模板版本的变量定义。 + /// 分页查询指定模板版本自动识别出的变量。 /// Task> VariablePage(OnboardingTemplateVariablePageInput input); + /// - /// 为草稿模板版本新增变量。 - /// - Task AddVariable(OnboardingTemplateVariableAddInput input); - /// - /// 编辑草稿模板版本中的变量。 + /// 编辑并确认草稿模板版本中的变量定义。 /// Task EditVariable(OnboardingTemplateVariableEditInput input); - /// - /// 删除草稿模板版本中的变量。 - /// - Task DeleteVariable(BaseIdListInput input); Task> ScopePage(OnboardingContractScopePageInput input); - /// - /// 新增公司或岗位的合同适用范围。 - /// - Task AddScope(OnboardingContractScopeAddInput input); - /// - /// 编辑合同适用范围。 - /// - Task EditScope(OnboardingContractScopeEditInput input); - /// - /// 删除合同适用范围。 - /// - Task DeleteScope(BaseIdListInput input);} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs index 41b70d7..9ce9d7b 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs @@ -5,6 +5,12 @@ namespace SimpleAdmin.Application; /// public class OnboardingContractTemplateService : DbRepository, IOnboardingContractTemplateService { + private readonly IFileService _fileService; + + public OnboardingContractTemplateService(IFileService fileService) + { + _fileService = fileService; + } /// /// 分页查询合同模板主档案。 /// @@ -64,7 +70,12 @@ public class OnboardingContractTemplateService : DbRepository() .AnyAsync(it => input.Ids.Contains(it.TemplateId)); if (hasScopes) - throw Oops.Bah("模板已配置适用范围,不能删除"); + throw Oops.Bah("模板已配置旧版适用范围,不能删除"); + + var hasOnboardingTemplates = await Context.Queryable() + .AnyAsync(it => input.Ids.Contains(it.ContractTemplateId)); + if (hasOnboardingTemplates) + throw Oops.Bah("模板已被入职模板引用,不能删除"); var hasContracts = await Context.Queryable() .AnyAsync(it => input.Ids.Contains(it.TemplateId)); @@ -92,18 +103,45 @@ public class OnboardingContractTemplateService : DbRepository().InSingleAsync(input.SourceDocxFileId) - ?? throw Oops.Bah("DOCX源文件不存在"); + ?? throw Oops.Bah("DOCX 源文件不存在"); if (!string.Equals(file.Suffix, "docx", StringComparison.OrdinalIgnoreCase)) - throw Oops.Bah("合同模板仅支持DOCX文件"); + throw Oops.Bah("合同模板仅支持 DOCX 文件"); var versionExists = await Context.Queryable() - .AnyAsync(it => it.TemplateId == input.TemplateId && it.VersionNo == input.VersionNo); + .AnyAsync(item => item.TemplateId == input.TemplateId && item.VersionNo == input.VersionNo); if (versionExists) throw Oops.Bah("该模板版本号已存在"); - await Context.Insertable(input.Adapt()).ExecuteCommandAsync(); + var sourceResult = await _fileService.Download(new BaseIdInput { Id = file.Id }) + ?? throw Oops.Bah("无法读取 DOCX 源文件"); + await using var sourceStream = sourceResult.FileStream; + using var sourceBytes = new MemoryStream(); + await sourceStream.CopyToAsync(sourceBytes); + var bytes = sourceBytes.ToArray(); + var variableCodes = DocxTemplateProcessor.ExtractVariableCodes(bytes); + var versionId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); + var version = input.Adapt(); + version.Id = versionId; + version.VersionStatus = ContractTemplateVersionStatus.Draft; + version.FileHash = string.IsNullOrWhiteSpace(input.FileHash) + ? Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(bytes)) + : input.FileHash; + var variables = variableCodes.Select((code, index) => BuildDetectedVariable(versionId, code, index)).ToList(); + + await Context.Ado.BeginTranAsync(); + try + { + await Context.Insertable(version).ExecuteCommandAsync(); + if (variables.Count > 0) + await Context.Insertable(variables).ExecuteCommandAsync(); + await Context.Ado.CommitTranAsync(); + } + catch + { + await Context.Ado.RollbackTranAsync(); + throw; + } } /// @@ -122,10 +160,11 @@ public class OnboardingContractTemplateService : DbRepository() - .AnyAsync(it => it.TemplateVersionId == input.Id); - if (!hasVariables) - throw Oops.Bah("请先配置模板变量后再启用"); + var hasUnresolvedVariables = await Context.Queryable() + .AnyAsync(item => item.TemplateVersionId == input.Id + && item.VariableType == ContractVariableType.Unresolved); + if (hasUnresolvedVariables) + throw Oops.Bah("仍有未识别来源的合同变量,请逐项确认后再启用"); } version.VersionStatus = input.VersionStatus; @@ -143,19 +182,8 @@ public class OnboardingContractTemplateService : DbRepository it.VariableCode) .ToPagedListAsync(input.PageNum, input.PageSize); } - /// - /// 为草稿模板版本新增变量。 - /// - public async Task AddVariable(OnboardingTemplateVariableAddInput input) - { - await EnsureVersionDraft(input.TemplateVersionId); - await EnsureVariableUnique(input.TemplateVersionId, input.VariableCode, 0); - await Context.Insertable(input.Adapt()).ExecuteCommandAsync(); - } - - /// - /// 编辑草稿模板版本中的变量。 + /// 编辑草稿模板版本中自动识别出的变量,不允许修改 DOCX 占位符编码。 /// public async Task EditVariable(OnboardingTemplateVariableEditInput input) { @@ -163,77 +191,61 @@ public class OnboardingContractTemplateService : DbRepository()).ExecuteCommandAsync(); + var entity = input.Adapt(); + entity.VariableCode = variable.VariableCode; + await Context.Updateable(entity).ExecuteCommandAsync(); } - /// - /// 删除草稿模板版本中的变量。 - /// - public async Task DeleteVariable(BaseIdListInput input) + private static ObTemplateVariable BuildDetectedVariable(long versionId, string code, int index) { - if (input.Ids.Count == 0) - return; + var separatorIndex = code.IndexOf('.'); + var prefix = separatorIndex > 0 ? code[..separatorIndex].ToLowerInvariant() : string.Empty; + var sourceField = separatorIndex > 0 ? code[(separatorIndex + 1)..] : code; + var variableType = prefix switch + { + "employee" => ContractVariableType.Employee, + "company" => ContractVariableType.Company, + "hr" => ContractVariableType.Contract, + "system" => ContractVariableType.System, + _ => ContractVariableType.Unresolved + }; - var versionIds = await Context.Queryable() - .Where(it => input.Ids.Contains(it.Id)) - .Select(it => it.TemplateVersionId) - .ToListAsync(); - - foreach (var versionId in versionIds.Distinct()) - await EnsureVersionDraft(versionId); - - await Context.Deleteable().In(input.Ids).ExecuteCommandAsync(); + return new ObTemplateVariable + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + TemplateVersionId = versionId, + VariableCode = code, + VariableName = sourceField, + VariableType = variableType, + DataType = InferDataType(sourceField), + EmployeeField = variableType is ContractVariableType.Employee or ContractVariableType.Company + ? sourceField + : null, + Required = true, + HrEditable = variableType == ContractVariableType.Contract, + SortCode = (index + 1) * 10, + Remark = variableType == ContractVariableType.Unresolved ? "导入时未识别变量来源,请管理员确认" : null + }; } - /// - /// 分页查询合同适用范围。 - /// - public async Task> ScopePage(OnboardingContractScopePageInput input) + private static string InferDataType(string sourceField) { - return await Context.Queryable() - .WhereIF(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value) - .WhereIF(input.PositionId.HasValue, it => it.PositionId == input.PositionId.Value) - .WhereIF(input.TemplateId.HasValue, it => it.TemplateId == input.TemplateId.Value) - .OrderBy(it => it.SortCode) - .ToPagedListAsync(input.PageNum, input.PageSize); - } + var normalized = sourceField.ToLowerInvariant(); + if (normalized.Contains("date") || normalized.EndsWith("at")) + return "date"; + if (normalized.Contains("salary") + || normalized.Contains("amount") + || normalized.Contains("wage") + || normalized.Contains("copies")) + { + return "decimal"; + } - /// - /// 新增公司或岗位的合同适用范围。 - /// - public async Task AddScope(OnboardingContractScopeAddInput input) - { - await EnsureScopeValid(input.CompanyId, input.PositionId, input.TemplateId, input.TemplateVersionId); - await EnsureScopeUnique(input.CompanyId, input.PositionId, input.TemplateVersionId, 0); - await Context.Insertable(input.Adapt()).ExecuteCommandAsync(); - } - - /// - /// 编辑合同适用范围。 - /// - public async Task EditScope(OnboardingContractScopeEditInput input) - { - var scopeExists = await Context.Queryable().AnyAsync(it => it.Id == input.Id); - if (!scopeExists) - throw Oops.Bah("合同适用范围不存在"); - - await EnsureScopeValid(input.CompanyId, input.PositionId, input.TemplateId, input.TemplateVersionId); - await EnsureScopeUnique(input.CompanyId, input.PositionId, input.TemplateVersionId, input.Id); - await Context.Updateable(input.Adapt()).ExecuteCommandAsync(); - } - - /// - /// 删除合同适用范围。 - /// - public async Task DeleteScope(BaseIdListInput input) - { - if (input.Ids.Count == 0) - return; - - await Context.Deleteable().In(input.Ids).ExecuteCommandAsync(); + return "string"; } private async Task EnsureCodeUnique(string code, long id) @@ -251,44 +263,4 @@ public class OnboardingContractTemplateService : DbRepository() - .AnyAsync(it => it.TemplateVersionId == versionId && it.VariableCode == code && it.Id != id); - if (exists) - throw Oops.Bah("该版本变量编码已存在"); - } - - private async Task EnsureScopeValid(long companyId, long? positionId, long templateId, long versionId) - { - var companyExists = await Context.Queryable() - .AnyAsync(it => it.Id == companyId && it.Status == "enable"); - if (!companyExists) - throw Oops.Bah("所属公司不存在或未启用"); - - if (positionId.HasValue) - { - var positionExists = await Context.Queryable() - .AnyAsync(it => it.Id == positionId.Value && it.CompanyId == companyId && it.Status == "enable"); - if (!positionExists) - throw Oops.Bah("岗位不存在、未启用或不属于该公司"); - } - - var version = await Context.Queryable().InSingleAsync(versionId) - ?? throw Oops.Bah("合同版本不存在"); - if (version.TemplateId != templateId || version.VersionStatus != ContractTemplateVersionStatus.Enabled) - throw Oops.Bah("只能配置该模板的已启用合同版本"); - } - - private async Task EnsureScopeUnique(long companyId, long? positionId, long versionId, long id) - { - var exists = await Context.Queryable() - .AnyAsync(it => it.CompanyId == companyId - && it.PositionId == positionId - && it.TemplateVersionId == versionId - && it.Id != id); - if (exists) - throw Oops.Bah("该公司、岗位和合同版本的适用范围已存在"); - } } diff --git a/SimpleAdmin.Application/Services/Onboarding/EntryConfig/Dto/OnboardingEntryConfigInput.cs b/SimpleAdmin.Application/Services/Onboarding/EntryConfig/Dto/OnboardingEntryConfigInput.cs index cacfc21..62cb02c 100644 --- a/SimpleAdmin.Application/Services/Onboarding/EntryConfig/Dto/OnboardingEntryConfigInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/EntryConfig/Dto/OnboardingEntryConfigInput.cs @@ -91,79 +91,3 @@ public class OnboardingMaterialTypeSaveInput /// public string Status { get; set; } = "enable"; } - -/// -/// 保存公司岗位动态字段要求参数。 -/// -public class OnboardingFormRequirementSaveInput -{ - /// - /// 业务数据 Id。 - /// - public long? Id { get; set; } - - /// - /// 入职业务公司 Id。 - /// - [IdNotNull(ErrorMessage = "公司Id不能为空")] - public long CompanyId { get; set; } - - /// - /// 岗位 Id;为空时表示公司级配置。 - /// - public long? PositionId { get; set; } - - /// - /// 动态字段 Id。 - /// - [IdNotNull(ErrorMessage = "字段Id不能为空")] - public long FieldId { get; set; } - - /// - /// 是否必填、必传或必签。 - /// - public bool Required { get; set; } - - /// - /// 显示排序码。 - /// - public int? SortCode { get; set; } -} - -/// -/// 保存公司岗位材料要求参数。 -/// -public class OnboardingMaterialRequirementSaveInput -{ - /// - /// 业务数据 Id。 - /// - public long? Id { get; set; } - - /// - /// 入职业务公司 Id。 - /// - [IdNotNull(ErrorMessage = "公司Id不能为空")] - public long CompanyId { get; set; } - - /// - /// 岗位 Id;为空时表示公司级配置。 - /// - public long? PositionId { get; set; } - - /// - /// 材料类型 Id。 - /// - [IdNotNull(ErrorMessage = "资料类型Id不能为空")] - public long MaterialTypeId { get; set; } - - /// - /// 是否必填、必传或必签。 - /// - public bool Required { get; set; } - - /// - /// 显示排序码。 - /// - public int? SortCode { get; set; } -} diff --git a/SimpleAdmin.Application/Services/Onboarding/EntryConfig/IOnboardingEntryConfigService.cs b/SimpleAdmin.Application/Services/Onboarding/EntryConfig/IOnboardingEntryConfigService.cs index 5bb40b3..24b3d75 100644 --- a/SimpleAdmin.Application/Services/Onboarding/EntryConfig/IOnboardingEntryConfigService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/EntryConfig/IOnboardingEntryConfigService.cs @@ -1,7 +1,7 @@ namespace SimpleAdmin.Application; /// -/// 定义动态字段、材料类型及公司岗位要求配置业务能力。 +/// 定义员工动态补充字段与上传材料类型的基础配置能力。 /// public interface IOnboardingEntryConfigService : ITransient { @@ -9,48 +9,29 @@ public interface IOnboardingEntryConfigService : ITransient /// 查询全部动态入职字段。 /// Task> ListFormFields(); + /// /// 新增或编辑动态入职字段。 /// Task SaveFormField(OnboardingFormFieldSaveInput input); + /// - /// 删除未被填写要求引用的动态字段。 + /// 删除未被入职模板引用的动态字段。 /// Task DeleteFormFields(BaseIdListInput input); + /// /// 查询全部入职材料类型。 /// Task> ListMaterialTypes(); + /// /// 新增或编辑入职材料类型。 /// Task SaveMaterialType(OnboardingMaterialTypeSaveInput input); + /// - /// 删除未被要求或上传记录引用的材料类型。 + /// 删除未被入职模板或上传记录引用的材料类型。 /// Task DeleteMaterialTypes(BaseIdListInput input); - /// - /// 查询指定公司的动态字段填写要求。 - /// - Task> ListFormRequirements(long companyId); - /// - /// 新增或编辑公司岗位动态字段要求。 - /// - Task SaveFormRequirement(OnboardingFormRequirementSaveInput input); - /// - /// 删除动态字段填写要求。 - /// - Task DeleteFormRequirements(BaseIdListInput input); - /// - /// 查询指定公司的材料上传要求。 - /// - Task> ListMaterialRequirements(long companyId); - /// - /// 新增或编辑公司岗位材料要求。 - /// - Task SaveMaterialRequirement(OnboardingMaterialRequirementSaveInput input); - /// - /// 删除材料上传要求。 - /// - Task DeleteMaterialRequirements(BaseIdListInput input); -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/EntryConfig/OnboardingEntryConfigService.cs b/SimpleAdmin.Application/Services/Onboarding/EntryConfig/OnboardingEntryConfigService.cs index 6c3977c..dd67379 100644 --- a/SimpleAdmin.Application/Services/Onboarding/EntryConfig/OnboardingEntryConfigService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/EntryConfig/OnboardingEntryConfigService.cs @@ -1,7 +1,7 @@ namespace SimpleAdmin.Application; /// -/// 维护员工端动态字段、材料类型及公司岗位要求。 +/// 维护员工端动态补充字段和材料类型;与公司、岗位的组合关系由入职模板统一管理。 /// public class OnboardingEntryConfigService : IOnboardingEntryConfigService { @@ -92,8 +92,10 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService var isReferenced = await _db.Queryable() .AnyAsync(x => input.Ids.Contains(x.FieldId)); - if (isReferenced) - throw Oops.Bah("字段已被填写要求引用,不能删除"); + var isReferencedByTemplate = await _db.Queryable() + .AnyAsync(x => input.Ids.Contains(x.FieldId)); + if (isReferenced || isReferencedByTemplate) + throw Oops.Bah("字段已被入职模板引用,不能删除"); await _db.Deleteable().In(input.Ids).ExecuteCommandAsync(); } @@ -155,8 +157,10 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService var isReferenced = await _db.Queryable() .AnyAsync(x => input.Ids.Contains(x.MaterialTypeId)); - if (isReferenced) - throw Oops.Bah("资料类型已被资料要求引用,不能删除"); + var isReferencedByTemplate = await _db.Queryable() + .AnyAsync(x => input.Ids.Contains(x.MaterialTypeId)); + if (isReferenced || isReferencedByTemplate) + throw Oops.Bah("资料类型已被入职模板引用,不能删除"); var hasUploads = await _db.Queryable() .AnyAsync(x => input.Ids.Contains(x.MaterialTypeId)); @@ -165,142 +169,4 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService await _db.Deleteable().In(input.Ids).ExecuteCommandAsync(); } - - /// - /// 查询指定公司的动态字段填写要求。 - /// - public Task> ListFormRequirements(long companyId) - { - return _db.Queryable() - .Where(x => x.CompanyId == companyId) - .OrderBy(x => x.SortCode) - .ToListAsync(); - } - - /// - /// 新增或编辑公司岗位动态字段要求。 - /// - public async Task SaveFormRequirement(OnboardingFormRequirementSaveInput input) - { - await EnsureCompanyAndPosition(input.CompanyId, input.PositionId); - - var fieldExists = await _db.Queryable() - .AnyAsync(x => x.Id == input.FieldId && x.Status == "enable"); - if (!fieldExists) - throw Oops.Bah("动态字段不存在或未启用"); - - var requirementExists = await _db.Queryable() - .AnyAsync(x => x.CompanyId == input.CompanyId - && x.PositionId == input.PositionId - && x.FieldId == input.FieldId - && x.Id != input.Id); - if (requirementExists) - throw Oops.Bah("该公司、岗位和字段的要求已存在"); - - var entity = new ObFormRequirement - { - Id = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), - CompanyId = input.CompanyId, - PositionId = input.PositionId, - FieldId = input.FieldId, - Required = input.Required, - SortCode = input.SortCode - }; - - if (input.Id.HasValue) - { - await _db.Updateable(entity).ExecuteCommandAsync(); - return; - } - - await _db.Insertable(entity).ExecuteCommandAsync(); - } - - /// - /// 删除动态字段填写要求。 - /// - public Task DeleteFormRequirements(BaseIdListInput input) - { - if (input.Ids.Count == 0) - return Task.CompletedTask; - - return _db.Deleteable().In(input.Ids).ExecuteCommandAsync(); - } - - /// - /// 查询指定公司的材料上传要求。 - /// - public Task> ListMaterialRequirements(long companyId) - { - return _db.Queryable() - .Where(x => x.CompanyId == companyId) - .OrderBy(x => x.SortCode) - .ToListAsync(); - } - - /// - /// 新增或编辑公司岗位材料要求。 - /// - public async Task SaveMaterialRequirement(OnboardingMaterialRequirementSaveInput input) - { - await EnsureCompanyAndPosition(input.CompanyId, input.PositionId); - - var materialTypeExists = await _db.Queryable() - .AnyAsync(x => x.Id == input.MaterialTypeId && x.Status == "enable"); - if (!materialTypeExists) - throw Oops.Bah("资料类型不存在或未启用"); - - var requirementExists = await _db.Queryable() - .AnyAsync(x => x.CompanyId == input.CompanyId - && x.PositionId == input.PositionId - && x.MaterialTypeId == input.MaterialTypeId - && x.Id != input.Id); - if (requirementExists) - throw Oops.Bah("该公司、岗位和资料类型的要求已存在"); - - var entity = new ObMaterialRequirement - { - Id = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), - CompanyId = input.CompanyId, - PositionId = input.PositionId, - MaterialTypeId = input.MaterialTypeId, - Required = input.Required, - SortCode = input.SortCode - }; - - if (input.Id.HasValue) - { - await _db.Updateable(entity).ExecuteCommandAsync(); - return; - } - - await _db.Insertable(entity).ExecuteCommandAsync(); - } - - /// - /// 删除材料上传要求。 - /// - public Task DeleteMaterialRequirements(BaseIdListInput input) - { - if (input.Ids.Count == 0) - return Task.CompletedTask; - - return _db.Deleteable().In(input.Ids).ExecuteCommandAsync(); - } - - private async Task EnsureCompanyAndPosition(long companyId, long? positionId) - { - var companyExists = await _db.Queryable() - .AnyAsync(x => x.Id == companyId && x.Status == "enable"); - if (!companyExists) - throw Oops.Bah("公司不存在或未启用"); - - if (!positionId.HasValue) - return; - - var positionExists = await _db.Queryable() - .AnyAsync(x => x.Id == positionId.Value && x.CompanyId == companyId && x.Status == "enable"); - if (!positionExists) - throw Oops.Bah("岗位不存在、未启用或不属于该公司"); - } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/Dto/OnboardingTemplateInput.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/Dto/OnboardingTemplateInput.cs new file mode 100644 index 0000000..22037bc --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/Dto/OnboardingTemplateInput.cs @@ -0,0 +1,213 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职模板分页查询参数。 +/// +public class OnboardingTemplatePageInput : BasePageInput +{ + /// + /// 模板编码或名称关键词。 + /// + public string Keyword { get; set; } + + /// + /// 所属公司 Id。 + /// + public long? CompanyId { get; set; } + + /// + /// 适用岗位 Id。 + /// + public long? PositionId { get; set; } + + /// + /// 启用状态。 + /// + public string Status { get; set; } +} + +/// +/// 保存入职模板参数。 +/// +public class OnboardingTemplateSaveInput +{ + /// + /// 编辑时传入的入职模板 Id。 + /// + public long? Id { get; set; } + + /// + /// 入职模板业务编码。 + /// + public string TemplateCode { get; set; } + + /// + /// 入职模板名称。 + /// + public string TemplateName { get; set; } + + /// + /// 所属公司 Id。 + /// + public long CompanyId { get; set; } + + /// + /// 适用岗位 Id 列表。 + /// + public List PositionIds { get; set; } = new(); + + /// + /// 合同配置列表。 + /// + public List Contracts { get; set; } = new(); + + /// + /// 动态补充字段列表。 + /// + public List FormFields { get; set; } = new(); + + /// + /// 上传材料列表。 + /// + public List Materials { get; set; } = new(); + + /// + /// 模板状态。 + /// + public string Status { get; set; } = "enable"; + + /// + /// 模板说明。 + /// + public string Description { get; set; } +} + +/// +/// 入职模板合同配置参数。 +/// +public class OnboardingTemplateContractInput +{ + /// + /// 合同模板 Id。 + /// + public long ContractTemplateId { get; set; } + + /// + /// 合同模板版本 Id。 + /// + public long ContractTemplateVersionId { get; set; } + + /// + /// 是否必须查看确认。 + /// + public bool Required { get; set; } = true; + + /// + /// 打印份数。 + /// + public int PrintCopies { get; set; } = 1; + + /// + /// 排序码。 + /// + public int? SortCode { get; set; } +} + +/// +/// 入职模板补充字段参数。 +/// +public class OnboardingTemplateFormFieldInput +{ + /// + /// 动态字段 Id。 + /// + public long FieldId { get; set; } + + /// + /// 是否必填。 + /// + public bool Required { get; set; } = true; + + /// + /// 排序码。 + /// + public int? SortCode { get; set; } +} + +/// +/// 入职模板上传材料参数。 +/// +public class OnboardingTemplateMaterialInput +{ + /// + /// 材料类型 Id。 + /// + public long MaterialTypeId { get; set; } + + /// + /// 是否必传。 + /// + public bool Required { get; set; } = true; + + /// + /// 排序码。 + /// + public int? SortCode { get; set; } +} + +/// +/// 入职模板列表输出。 +/// +public class OnboardingTemplateOutput : ObOnboardingTemplate +{ + /// + /// 所属公司名称。 + /// + public string CompanyName { get; set; } + + /// + /// 适用岗位 Id 列表。 + /// + public List PositionIds { get; set; } = new(); + + /// + /// 适用岗位名称列表。 + /// + public List PositionNames { get; set; } = new(); + + /// + /// 合同配置数量。 + /// + public int ContractCount { get; set; } + + /// + /// 动态补充字段数量。 + /// + public int FormFieldCount { get; set; } + + /// + /// 上传材料数量。 + /// + public int MaterialCount { get; set; } +} + +/// +/// 入职模板详情输出。 +/// +public class OnboardingTemplateDetailOutput : OnboardingTemplateOutput +{ + /// + /// 合同配置明细。 + /// + public List Contracts { get; set; } = new(); + + /// + /// 动态补充字段配置明细。 + /// + public List FormFields { get; set; } = new(); + + /// + /// 上传材料配置明细。 + /// + public List Materials { get; set; } = new(); +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/IOnboardingTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/IOnboardingTemplateService.cs new file mode 100644 index 0000000..c148d40 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/IOnboardingTemplateService.cs @@ -0,0 +1,32 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职模板聚合配置服务。 +/// +public interface IOnboardingTemplateService +{ + /// + /// 分页查询入职模板。 + /// + Task> Page(OnboardingTemplatePageInput input); + + /// + /// 查询入职模板详情。 + /// + Task Detail(BaseIdInput input); + + /// + /// 新增或编辑入职模板及全部关联配置。 + /// + Task Save(OnboardingTemplateSaveInput input); + + /// + /// 删除未被任务使用的入职模板。 + /// + Task Delete(BaseIdListInput input); + + /// + /// 查询公司岗位可使用的入职模板。 + /// + Task> ListEnabled(long companyId, long positionId); +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs new file mode 100644 index 0000000..8cedffa --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs @@ -0,0 +1,431 @@ +namespace SimpleAdmin.Application; + +/// +/// 实现入职模板聚合配置及公司岗位匹配能力。 +/// +public class OnboardingTemplateService : IOnboardingTemplateService +{ + private readonly SqlSugarScope _db; + + public OnboardingTemplateService() + { + _db = DbContext.DB; + } + + /// + public async Task> Page(OnboardingTemplatePageInput input) + { + var query = _db.Queryable() + .WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), + item => item.TemplateCode.Contains(input.Keyword) || item.TemplateName.Contains(input.Keyword)) + .WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value) + .WhereIF(!string.IsNullOrWhiteSpace(input.Status), item => item.Status == input.Status); + + if (input.PositionId.HasValue) + { + var templateIds = await _db.Queryable() + .Where(item => item.PositionId == input.PositionId.Value) + .Select(item => item.OnboardingTemplateId) + .ToListAsync(); + query = query.Where(item => templateIds.Contains(item.Id)); + } + + var page = await query + .OrderByDescending(item => item.CreateTime) + .ToPagedListAsync(input.PageNum, input.PageSize); + var rows = await BuildOutputs(page.List.ToList()); + + return new SqlSugarPagedList + { + PageNum = page.PageNum, + PageSize = page.PageSize, + Total = page.Total, + Pages = page.Pages, + HasPrevPages = page.HasPrevPages, + HasNextPages = page.HasNextPages, + List = rows + }; + } + + /// + public async Task Detail(BaseIdInput input) + { + var template = await _db.Queryable().InSingleAsync(input.Id) + ?? throw Oops.Bah("入职模板不存在"); + var summary = (await BuildOutputs(new List { template })).Single(); + + return new OnboardingTemplateDetailOutput + { + Id = summary.Id, + TemplateCode = summary.TemplateCode, + TemplateName = summary.TemplateName, + CompanyId = summary.CompanyId, + CompanyName = summary.CompanyName, + Description = summary.Description, + Status = summary.Status, + CreateTime = summary.CreateTime, + UpdateTime = summary.UpdateTime, + PositionIds = summary.PositionIds, + PositionNames = summary.PositionNames, + ContractCount = summary.ContractCount, + FormFieldCount = summary.FormFieldCount, + MaterialCount = summary.MaterialCount, + Contracts = await _db.Queryable() + .Where(item => item.OnboardingTemplateId == template.Id) + .OrderBy(item => item.SortCode) + .ToListAsync(), + FormFields = await _db.Queryable() + .Where(item => item.OnboardingTemplateId == template.Id) + .OrderBy(item => item.SortCode) + .ToListAsync(), + Materials = await _db.Queryable() + .Where(item => item.OnboardingTemplateId == template.Id) + .OrderBy(item => item.SortCode) + .ToListAsync() + }; + } + + /// + public async Task Save(OnboardingTemplateSaveInput input) + { + NormalizeAndValidateInput(input); + await ValidateReferences(input); + + var templateId = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); + var codeExists = await _db.Queryable() + .AnyAsync(item => item.TemplateCode == input.TemplateCode && item.Id != templateId); + if (codeExists) + throw Oops.Bah("入职模板编码已存在"); + + var entity = new ObOnboardingTemplate + { + Id = templateId, + TemplateCode = input.TemplateCode.Trim(), + TemplateName = input.TemplateName.Trim(), + CompanyId = input.CompanyId, + Description = input.Description, + Status = input.Status + }; + + await _db.Ado.BeginTranAsync(); + try + { + if (input.Id.HasValue) + { + var exists = await _db.Queryable().AnyAsync(item => item.Id == templateId); + if (!exists) + throw Oops.Bah("入职模板不存在"); + await _db.Updateable(entity).IgnoreColumns(ignoreAllNullColumns: true).ExecuteCommandAsync(); + await DeleteRelations(templateId); + } + else + { + await _db.Insertable(entity).ExecuteCommandAsync(); + } + + await InsertRelations(templateId, input); + await _db.Ado.CommitTranAsync(); + return templateId; + } + catch + { + await _db.Ado.RollbackTranAsync(); + throw; + } + } + + /// + public async Task Delete(BaseIdListInput input) + { + if (input.Ids.Count == 0) + return; + + var isUsed = await _db.Queryable() + .AnyAsync(item => input.Ids.Contains(item.OnboardingTemplateId)); + if (isUsed) + throw Oops.Bah("入职模板已生成任务,不能删除,可以改为停用"); + + await _db.Ado.BeginTranAsync(); + try + { + foreach (var id in input.Ids) + await DeleteRelations(id); + await _db.Deleteable().In(input.Ids).ExecuteCommandAsync(); + await _db.Ado.CommitTranAsync(); + } + catch + { + await _db.Ado.RollbackTranAsync(); + throw; + } + } + + /// + public async Task> ListEnabled(long companyId, long positionId) + { + var templateIds = await _db.Queryable() + .Where(item => item.PositionId == positionId) + .Select(item => item.OnboardingTemplateId) + .ToListAsync(); + var templates = await _db.Queryable() + .Where(item => item.CompanyId == companyId + && item.Status == "enable" + && templateIds.Contains(item.Id)) + .OrderBy(item => item.TemplateName) + .ToListAsync(); + return await BuildOutputs(templates); + } + + private static void NormalizeAndValidateInput(OnboardingTemplateSaveInput input) + { + input.TemplateCode = input.TemplateCode?.Trim(); + input.TemplateName = input.TemplateName?.Trim(); + input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List(); + input.Contracts = input.Contracts? + .GroupBy(item => item.ContractTemplateVersionId) + .Select(group => group.First()) + .ToList() ?? new List(); + input.FormFields = input.FormFields? + .GroupBy(item => item.FieldId) + .Select(group => group.First()) + .ToList() ?? new List(); + input.Materials = input.Materials? + .GroupBy(item => item.MaterialTypeId) + .Select(group => group.First()) + .ToList() ?? new List(); + + if (string.IsNullOrWhiteSpace(input.TemplateCode) || string.IsNullOrWhiteSpace(input.TemplateName)) + throw Oops.Bah("请填写入职模板编码和名称"); + if (input.CompanyId <= 0) + throw Oops.Bah("请选择所属公司"); + if (input.PositionIds.Count == 0) + throw Oops.Bah("请至少选择一个适用岗位"); + if (input.Contracts.Count == 0) + throw Oops.Bah("请至少选择一份合同文件"); + if (input.Status != "enable" && input.Status != "disable") + throw Oops.Bah("入职模板状态不正确"); + if (input.Contracts.Any(item => item.PrintCopies <= 0)) + throw Oops.Bah("合同打印份数必须大于 0"); + } + + private async Task ValidateReferences(OnboardingTemplateSaveInput input) + { + var companyEnabled = await _db.Queryable() + .AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable"); + if (!companyEnabled) + throw Oops.Bah("所属公司不存在或未启用"); + + var positionCount = await _db.Queryable() + .CountAsync(item => input.PositionIds.Contains(item.Id) + && item.CompanyId == input.CompanyId + && item.Status == "enable"); + if (positionCount != input.PositionIds.Count) + throw Oops.Bah("存在不属于该公司或未启用的岗位"); + + var versionIds = input.Contracts.Select(item => item.ContractTemplateVersionId).ToList(); + var versions = await _db.Queryable() + .Where(item => versionIds.Contains(item.Id)) + .ToListAsync(); + if (versions.Count != versionIds.Count + || input.Contracts.Any(contract => versions.All(version => version.Id != contract.ContractTemplateVersionId + || version.TemplateId != contract.ContractTemplateId))) + { + throw Oops.Bah("合同模板与版本不匹配"); + } + + if (input.Status == "enable") + { + if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled)) + throw Oops.Bah("启用入职模板前,请先启用全部合同版本"); + + var variables = await _db.Queryable() + .Where(item => versionIds.Contains(item.TemplateVersionId)) + .ToListAsync(); + var validTypes = new[] + { + ContractVariableType.Employee, + ContractVariableType.Company, + ContractVariableType.Contract, + ContractVariableType.System + }; + if (variables.Any(item => !validTypes.Contains(item.VariableType))) + throw Oops.Bah("合同中仍有未确认来源的变量,请先完成变量映射"); + } + + var fieldIds = input.FormFields.Select(item => item.FieldId).ToList(); + var enabledFields = fieldIds.Count == 0 + ? new List() + : await _db.Queryable() + .Where(item => fieldIds.Contains(item.Id) && item.Status == "enable") + .ToListAsync(); + if (enabledFields.Count != fieldIds.Count) + throw Oops.Bah("存在未启用或不存在的动态补充字段"); + + if (input.Status == "enable") + { + var variables = await _db.Queryable() + .Where(item => versionIds.Contains(item.TemplateVersionId) + && item.VariableType == ContractVariableType.Employee) + .ToListAsync(); + var availableFields = new HashSet( + typeof(ObTaskEmployeeInfo).GetProperties().Select(property => property.Name), + StringComparer.OrdinalIgnoreCase); + foreach (var fieldCode in enabledFields.Select(item => item.FieldCode)) + availableFields.Add(fieldCode); + + var missingMapping = variables.FirstOrDefault(variable => + !availableFields.Contains(ResolveEmployeeVariableField(variable))); + if (missingMapping != null) + { + throw Oops.Bah( + $"合同员工变量“{missingMapping.VariableCode}”没有对应的固定字段或模板补充字段,请先完成字段映射"); + } + } + + var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList(); + if (materialIds.Count > 0) + { + var materialCount = await _db.Queryable() + .CountAsync(item => materialIds.Contains(item.Id) && item.Status == "enable"); + if (materialCount != materialIds.Count) + throw Oops.Bah("存在未启用或不存在的上传材料类型"); + } + } + + private static string ResolveEmployeeVariableField(ObTemplateVariable variable) + { + var sourceField = variable.EmployeeField; + if (string.IsNullOrWhiteSpace(sourceField)) + { + var separatorIndex = variable.VariableCode?.IndexOf('.') ?? -1; + sourceField = separatorIndex >= 0 + ? variable.VariableCode[(separatorIndex + 1)..] + : variable.VariableCode; + } + + return sourceField?.ToUpperInvariant() switch + { + "FULLNAME" or "FULL_NAME" or "NAME" => nameof(ObTaskEmployeeInfo.EmployeeName), + "MOBILE" or "MOBILEPHONE" or "MOBILE_PHONE" => nameof(ObTaskEmployeeInfo.Phone), + "IDNUMBER" or "ID_NUMBER" or "IDENTITYNUMBER" => nameof(ObTaskEmployeeInfo.IdCardNumber), + _ => sourceField + }; + } + private async Task InsertRelations(long templateId, OnboardingTemplateSaveInput input) + { + var positions = input.PositionIds.Select(positionId => new ObOnboardingTemplatePosition + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + OnboardingTemplateId = templateId, + PositionId = positionId + }).ToList(); + await _db.Insertable(positions).ExecuteCommandAsync(); + + var contracts = input.Contracts.Select(item => new ObOnboardingTemplateContract + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + OnboardingTemplateId = templateId, + ContractTemplateId = item.ContractTemplateId, + ContractTemplateVersionId = item.ContractTemplateVersionId, + Required = item.Required, + PrintCopies = item.PrintCopies, + SortCode = item.SortCode + }).ToList(); + await _db.Insertable(contracts).ExecuteCommandAsync(); + + if (input.FormFields.Count > 0) + { + var fields = input.FormFields.Select(item => new ObOnboardingTemplateFormField + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + OnboardingTemplateId = templateId, + FieldId = item.FieldId, + Required = item.Required, + SortCode = item.SortCode + }).ToList(); + await _db.Insertable(fields).ExecuteCommandAsync(); + } + + if (input.Materials.Count > 0) + { + var materials = input.Materials.Select(item => new ObOnboardingTemplateMaterial + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + OnboardingTemplateId = templateId, + MaterialTypeId = item.MaterialTypeId, + Required = item.Required, + SortCode = item.SortCode + }).ToList(); + await _db.Insertable(materials).ExecuteCommandAsync(); + } + } + + private async Task DeleteRelations(long templateId) + { + await _db.Deleteable() + .Where(item => item.OnboardingTemplateId == templateId) + .ExecuteCommandAsync(); + await _db.Deleteable() + .Where(item => item.OnboardingTemplateId == templateId) + .ExecuteCommandAsync(); + await _db.Deleteable() + .Where(item => item.OnboardingTemplateId == templateId) + .ExecuteCommandAsync(); + await _db.Deleteable() + .Where(item => item.OnboardingTemplateId == templateId) + .ExecuteCommandAsync(); + } + + private async Task> BuildOutputs(List templates) + { + if (templates.Count == 0) + return new List(); + + var templateIds = templates.Select(item => item.Id).ToList(); + var companyIds = templates.Select(item => item.CompanyId).Distinct().ToList(); + var companies = await _db.Queryable() + .Where(item => companyIds.Contains(item.Id)) + .ToListAsync(); + var relations = await _db.Queryable() + .Where(item => templateIds.Contains(item.OnboardingTemplateId)) + .ToListAsync(); + var positionIds = relations.Select(item => item.PositionId).Distinct().ToList(); + var positions = await _db.Queryable() + .Where(item => positionIds.Contains(item.Id)) + .ToListAsync(); + var contracts = await _db.Queryable() + .Where(item => templateIds.Contains(item.OnboardingTemplateId)) + .ToListAsync(); + var fields = await _db.Queryable() + .Where(item => templateIds.Contains(item.OnboardingTemplateId)) + .ToListAsync(); + var materials = await _db.Queryable() + .Where(item => templateIds.Contains(item.OnboardingTemplateId)) + .ToListAsync(); + + return templates.Select(template => + { + var templatePositions = relations.Where(item => item.OnboardingTemplateId == template.Id).ToList(); + return new OnboardingTemplateOutput + { + Id = template.Id, + TemplateCode = template.TemplateCode, + TemplateName = template.TemplateName, + CompanyId = template.CompanyId, + CompanyName = companies.FirstOrDefault(item => item.Id == template.CompanyId)?.CompanyName, + Description = template.Description, + Status = template.Status, + CreateTime = template.CreateTime, + UpdateTime = template.UpdateTime, + PositionIds = templatePositions.Select(item => item.PositionId).ToList(), + PositionNames = templatePositions + .Select(relation => positions.FirstOrDefault(position => position.Id == relation.PositionId)?.PositionName) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToList(), + ContractCount = contracts.Count(item => item.OnboardingTemplateId == template.Id), + FormFieldCount = fields.Count(item => item.OnboardingTemplateId == template.Id), + MaterialCount = materials.Count(item => item.OnboardingTemplateId == template.Id) + }; + }).ToList(); + } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs index 79f61aa..c3ba935 100644 --- a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs @@ -1,92 +1,95 @@ namespace SimpleAdmin.Application; /// -/// 实现员工端入职资料提交业务能力。 +/// 实现员工端动态资料、上传材料、合同确认和最终提交能力。 /// public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService { private readonly SqlSugarScope _db; private readonly IOnboardingContractSnapshotService _snapshotService; - public OnboardingPublicSubmissionService( - IOnboardingContractSnapshotService snapshotService) + public OnboardingPublicSubmissionService(IOnboardingContractSnapshotService snapshotService) { _db = DbContext.DB; _snapshotService = snapshotService; } /// - /// 保存候选人填写的动态字段值。 + /// 保存候选人填写的动态补充字段值。 /// public async Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input) { var task = await GetEditableTask(input.Token); - var requirements = await GetEffectiveFormRequirements(task); + var requirements = await GetFormRequirements(task.Id); var fieldIds = input.Values.Select(item => item.FieldId).Distinct().ToList(); - var hasInapplicableField = fieldIds.Any(id => requirements.All(requirement => requirement.FieldId != id)); + var hasInapplicableField = fieldIds.Any(id => requirements.All(item => item.FieldId != id)); if (hasInapplicableField) - throw Oops.Bah("填写字段不适用于当前公司或岗位"); + throw Oops.Bah("填写字段不属于当前入职模板"); - var fields = await _db.Queryable() - .Where(field => fieldIds.Contains(field.Id) && field.Status == "enable") - .ToListAsync(); foreach (var value in input.Values) { - var field = fields.FirstOrDefault(item => item.Id == value.FieldId) - ?? throw Oops.Bah("填写字段不存在或已停用"); + var requirement = requirements.FirstOrDefault(item => item.FieldId == value.FieldId) + ?? throw Oops.Bah("动态补充字段不存在"); var old = await _db.Queryable() - .FirstAsync(item => item.TaskId == task.Id && item.FieldId == field.Id); + .FirstAsync(item => item.TaskId == task.Id && item.FieldId == requirement.FieldId); var row = new ObTaskFormValue { Id = old?.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TaskId = task.Id, - FieldId = field.Id, - FieldCode = field.FieldCode, - FieldName = field.FieldName, + FieldId = requirement.FieldId, + FieldCode = requirement.FieldCode, + FieldName = requirement.FieldName, FieldValue = value.FieldValue, SubmitRound = task.SubmitCount + 1 }; if (old == null) - { await _db.Insertable(row).ExecuteCommandAsync(); - } else - { await _db.Updateable(row).ExecuteCommandAsync(); - } } + + await _snapshotService.Build(task.Id, invalidateGeneratedPreview: true); } /// - /// 关联候选人已经上传的系统文件与材料类型。 + /// 关联已上传 OSS 文件与任务材料要求。 /// public async Task SaveMaterial(OnboardingSaveMaterialInput input) { var task = await GetEditableTask(input.Token); - var requirements = await GetEffectiveMaterialRequirements(task); - var materialIsApplicable = requirements.Any(requirement => requirement.MaterialTypeId == input.MaterialTypeId); - if (!materialIsApplicable) - throw Oops.Bah("资料类型不适用于当前公司或岗位"); - - var material = await _db.Queryable() - .FirstAsync(item => item.Id == input.MaterialTypeId && item.Status == "enable") - ?? throw Oops.Bah("资料类型不存在或已停用"); + var requirement = await _db.Queryable() + .FirstAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId) + ?? throw Oops.Bah("材料类型不属于当前入职模板"); var fileExists = await _db.Queryable().AnyAsync(item => item.Id == input.FileId); if (!fileExists) throw Oops.Bah("上传文件不存在"); - var materialCount = await _db.Queryable() - .CountAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId); - if (materialCount >= material.MaxFileCount) - throw Oops.Bah("该资料已达到最大上传数量"); + var existingMaterials = await _db.Queryable() + .Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId) + .ToListAsync(); + var supplementMaterial = existingMaterials.FirstOrDefault( + item => item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement); + if (supplementMaterial != null) + { + supplementMaterial.FileId = input.FileId; + supplementMaterial.SubmitRound = task.SubmitCount + 1; + supplementMaterial.UploadedAt = DateTime.Now; + supplementMaterial.ReviewStatus = TaskMaterialReviewStatus.Pending; + supplementMaterial.ReviewRemark = null; + await _db.Updateable(supplementMaterial).ExecuteCommandAsync(); + return; + } + + if (existingMaterials.Count >= requirement.MaxFileCount) + throw Oops.Bah("该材料已达到最大上传数量"); await _db.Insertable(new ObTaskMaterial { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TaskId = task.Id, - MaterialTypeId = material.Id, - MaterialName = material.MaterialName, + MaterialTypeId = requirement.MaterialTypeId, + MaterialName = requirement.MaterialName, FileId = input.FileId, SubmitRound = task.SubmitCount + 1, UploadedAt = DateTime.Now @@ -94,7 +97,7 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ } /// - /// 记录候选人已阅读并确认指定任务合同。 + /// 记录候选人已经查看并确认动态生成的合同。 /// public async Task ConfirmContract(OnboardingConfirmContractInput input) { @@ -102,15 +105,16 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ var contract = await _db.Queryable() .FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == task.Id) ?? throw Oops.Bah("合同不存在"); + if (!contract.GeneratedDocxFileId.HasValue || !contract.ReadAt.HasValue) + throw Oops.Bah("请先查看已经生成的合同内容"); - contract.ReadAt ??= DateTime.Now; contract.ConfirmedAt = DateTime.Now; contract.ContractStatus = TaskContractStatus.Confirmed; await _db.Updateable(contract).ExecuteCommandAsync(); } /// - /// 校验全部必填资料后提交入职任务进入审核。 + /// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。 /// public async Task Submit(OnboardingSubmitInput input) { @@ -126,8 +130,7 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ await _snapshotService.Build(task.Id, info); - // 提交时统一完成动态字段、材料、合同变量和合同确认校验,保存阶段只负责暂存。 - var requiredFields = (await GetEffectiveFormRequirements(task)) + var requiredFields = (await GetFormRequirements(task.Id)) .Where(item => item.Required) .Select(item => item.FieldId) .ToList(); @@ -137,9 +140,9 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ var hasMissingRequiredField = requiredFields.Any( id => values.All(item => item.FieldId != id || string.IsNullOrWhiteSpace(item.FieldValue))); if (hasMissingRequiredField) - throw Oops.Bah("仍有必填动态信息未填写"); + throw Oops.Bah("仍有必填补充信息未填写"); - var requiredMaterials = (await GetEffectiveMaterialRequirements(task)) + var requiredMaterials = (await GetMaterialRequirements(task.Id)) .Where(item => item.Required) .Select(item => item.MaterialTypeId) .ToList(); @@ -149,14 +152,14 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ var hasMissingRequiredMaterial = requiredMaterials.Any( id => materials.All(item => item.MaterialTypeId != id)); if (hasMissingRequiredMaterial) - throw Oops.Bah("仍有必传资料未上传"); + throw Oops.Bah("仍有必传材料未上传"); await EnsureRequiredContractVariables(task); var hasUnconfirmedRequiredContract = await _db.Queryable() .AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null); if (hasUnconfirmedRequiredContract) - throw Oops.Bah("请确认阅读全部必签合同"); + throw Oops.Bah("请查看并确认全部必签合同"); task.TaskStatus = OnboardingTaskStatus.Reviewing; task.SubmitCount++; @@ -177,36 +180,34 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ } /// - /// 在保存文件前校验材料适用范围、扩展名、大小和数量。 + /// 上传 OSS 前按照任务材料快照校验扩展名、文件大小和数量。 /// public async Task ValidateMaterialUpload(OnboardingMaterialUploadValidationInput input) { var task = await GetEditableTask(input.Token); - var requirements = await GetEffectiveMaterialRequirements(task); - var materialIsApplicable = requirements.Any(requirement => requirement.MaterialTypeId == input.MaterialTypeId); - if (!materialIsApplicable) - throw Oops.Bah("资料类型不适用于当前公司或岗位"); - - var material = await _db.Queryable() - .FirstAsync(item => item.Id == input.MaterialTypeId && item.Status == "enable") - ?? throw Oops.Bah("资料类型不存在或已停用"); + var requirement = await _db.Queryable() + .FirstAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId) + ?? throw Oops.Bah("材料类型不属于当前入职模板"); var suffix = Path.GetExtension(input.FileName).TrimStart('.').ToLowerInvariant(); - var allowedExtensions = (material.AllowedExtensions ?? string.Empty) + var allowedExtensions = (requirement.AllowedExtensions ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(item => item.TrimStart('.').ToLowerInvariant()); var suffixNotAllowed = allowedExtensions.Any() && !allowedExtensions.Contains(suffix); if (suffixNotAllowed) - throw Oops.Bah("该资料不允许上传此文件类型"); + throw Oops.Bah("该材料不允许上传此文件类型"); - var fileTooLarge = material.MaxFileSizeMb.HasValue - && input.FileLength > material.MaxFileSizeMb.Value * 1024L * 1024L; + var fileTooLarge = requirement.MaxFileSizeMb.HasValue + && input.FileLength > requirement.MaxFileSizeMb.Value * 1024L * 1024L; if (fileTooLarge) throw Oops.Bah("文件超过允许的大小"); - var materialCount = await _db.Queryable() - .CountAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId); - if (materialCount >= material.MaxFileCount) - throw Oops.Bah("该资料已达到最大上传数量"); + var materials = await _db.Queryable() + .Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId) + .ToListAsync(); + var canReplaceSupplement = materials.Any( + item => item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement); + if (materials.Count >= requirement.MaxFileCount && !canReplaceSupplement) + throw Oops.Bah("该材料已达到最大上传数量"); } private async Task EnsureRequiredContractVariables(ObOnboardingTask task) @@ -234,30 +235,20 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ throw Oops.Bah($"合同“{missing.contract.ContractName}”的必填变量“{missing.definition.VariableName}”尚未填写"); } - private async Task> GetEffectiveFormRequirements(ObOnboardingTask task) + private Task> GetFormRequirements(long taskId) { - var requirements = await _db.Queryable() - .Where(item => item.CompanyId == task.CompanyId - && (item.PositionId == null || item.PositionId == task.PositionId)) + return _db.Queryable() + .Where(item => item.TaskId == taskId) + .OrderBy(item => item.SortCode) .ToListAsync(); - - return requirements - .GroupBy(item => item.FieldId) - .Select(group => group.FirstOrDefault(item => item.PositionId == task.PositionId) ?? group.First()) - .ToList(); } - private async Task> GetEffectiveMaterialRequirements(ObOnboardingTask task) + private Task> GetMaterialRequirements(long taskId) { - var requirements = await _db.Queryable() - .Where(item => item.CompanyId == task.CompanyId - && (item.PositionId == null || item.PositionId == task.PositionId)) + return _db.Queryable() + .Where(item => item.TaskId == taskId) + .OrderBy(item => item.SortCode) .ToListAsync(); - - return requirements - .GroupBy(item => item.MaterialTypeId) - .Select(group => group.FirstOrDefault(item => item.PositionId == task.PositionId) ?? group.First()) - .ToList(); } private async Task GetEditableTask(string token) @@ -283,4 +274,4 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ return task; } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs index 31d8c25..28866ea 100644 --- a/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs @@ -1,22 +1,25 @@ namespace SimpleAdmin.Application; /// -/// 员工手机端入口;仅凭一次性任务链接访问,绝不创建正式员工。 +/// 员工手机端公开入口,仅凭一次性任务链接访问入职资料。 /// public class OnboardingPublicTaskService : IOnboardingPublicTaskService { private readonly SqlSugarScope _db; private readonly IOnboardingContractSnapshotService _snapshotService; + private readonly IOnboardingContractDocumentService _contractDocumentService; public OnboardingPublicTaskService( - IOnboardingContractSnapshotService snapshotService) + IOnboardingContractSnapshotService snapshotService, + IOnboardingContractDocumentService contractDocumentService) { _db = DbContext.DB; _snapshotService = snapshotService; + _contractDocumentService = contractDocumentService; } /// - /// 通过有效 Token 获取员工端任务、字段、材料和合同信息。 + /// 通过有效 Token 获取任务创建时冻结的字段、材料和合同信息。 /// public async Task Detail(OnboardingTaskPublicAccessInput input) { @@ -28,70 +31,65 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService link.LastAccessedAt = DateTime.Now; await _db.Updateable(link).ExecuteCommandAsync(); - var formRequirements = await GetEffectiveFormRequirements(task); - var materialRequirements = await GetEffectiveMaterialRequirements(task); - var fields = await _db.Queryable() - .Where(x => formRequirements.Select(r => r.FieldId).Contains(x.Id) && x.Status == "enable") + var formRequirements = await _db.Queryable() + .Where(item => item.TaskId == task.Id) + .OrderBy(item => item.SortCode) .ToListAsync(); - var materialTypes = await _db.Queryable() - .Where(x => materialRequirements.Select(r => r.MaterialTypeId).Contains(x.Id) && x.Status == "enable") + var materialRequirements = await _db.Queryable() + .Where(item => item.TaskId == task.Id) + .OrderBy(item => item.SortCode) .ToListAsync(); return new OnboardingTaskPublicDetailOutput { Task = task, - EmployeeInfo = await _db.Queryable().FirstAsync(it => it.TaskId == task.Id), + EmployeeInfo = await _db.Queryable() + .FirstAsync(item => item.TaskId == task.Id), Contracts = await _db.Queryable() - .Where(it => it.TaskId == task.Id) + .Where(item => item.TaskId == task.Id) .ToListAsync(), DynamicValues = await _db.Queryable() - .Where(x => x.TaskId == task.Id) + .Where(item => item.TaskId == task.Id) .ToListAsync(), Materials = await _db.Queryable() - .Where(x => x.TaskId == task.Id) + .Where(item => item.TaskId == task.Id) .ToListAsync(), - FormFields = formRequirements - .OrderBy(x => x.SortCode) - .Select(requirement => fields.FirstOrDefault(field => field.Id == requirement.FieldId)) - .Where(field => field != null) - .Select(field => new OnboardingPublicFormFieldOutput - { - FieldId = field.Id, - FieldCode = field.FieldCode, - FieldName = field.FieldName, - DataType = field.DataType, - OptionsJson = field.OptionsJson, - Required = formRequirements.First(requirement => requirement.FieldId == field.Id).Required - }) - .ToList(), - MaterialRequirements = materialRequirements - .OrderBy(x => x.SortCode) - .Select(requirement => materialTypes.FirstOrDefault(material => material.Id == requirement.MaterialTypeId)) - .Where(material => material != null) - .Select(material => new OnboardingPublicMaterialOutput - { - MaterialTypeId = material.Id, - MaterialName = material.MaterialName, - AllowedExtensions = material.AllowedExtensions, - MaxFileCount = material.MaxFileCount, - Required = materialRequirements.First(requirement => requirement.MaterialTypeId == material.Id).Required - }) - .ToList() + FormFields = formRequirements.Select(requirement => new OnboardingPublicFormFieldOutput + { + FieldId = requirement.FieldId, + FieldCode = requirement.FieldCode, + FieldName = requirement.FieldName, + DataType = requirement.DataType, + OptionsJson = requirement.OptionsJson, + Required = requirement.Required + }).ToList(), + MaterialRequirements = materialRequirements.Select(requirement => new OnboardingPublicMaterialOutput + { + MaterialTypeId = requirement.MaterialTypeId, + MaterialName = requirement.MaterialName, + AllowedExtensions = requirement.AllowedExtensions, + MaxFileCount = requirement.MaxFileCount, + Required = requirement.Required + }).ToList() }; } /// - /// 保存候选人固定基础信息并刷新合同变量快照。 + /// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。 /// public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input) { var link = await GetActiveLink(input.Token); var task = await _db.Queryable().InSingleAsync(link.TaskId) ?? throw Oops.Bah("入职任务不存在"); - if (task.TaskStatus != OnboardingTaskStatus.PendingFill && task.TaskStatus != OnboardingTaskStatus.Rejected) + if (task.TaskStatus != OnboardingTaskStatus.PendingFill + && task.TaskStatus != OnboardingTaskStatus.Rejected) + { throw Oops.Bah("当前任务不可编辑"); + } - var old = await _db.Queryable().FirstAsync(it => it.TaskId == task.Id); + var old = await _db.Queryable() + .FirstAsync(item => item.TaskId == task.Id); var entity = input.Adapt(); entity.TaskId = task.Id; @@ -106,56 +104,41 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService await _db.Updateable(entity).ExecuteCommandAsync(); } - await _snapshotService.Build(task.Id, entity); + await _snapshotService.Build(task.Id, entity, invalidateGeneratedPreview: true); } /// - /// 校验任务归属后返回合同模板源文件 Id。 + /// 根据员工当前已填写信息生成合同预览,并返回已替换变量的 DOCX 文件 Id。 /// public async Task GetContractSourceFileId(OnboardingPublicContractSourceInput input) { var link = await GetActiveLink(input.Token); var contract = await _db.Queryable() - .FirstAsync(x => x.Id == input.TaskContractId && x.TaskId == link.TaskId) + .FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == link.TaskId) ?? throw Oops.Bah("合同不存在"); - var version = await _db.Queryable().InSingleAsync(contract.TemplateVersionId) - ?? throw Oops.Bah("合同模板版本不存在"); - return version.SourceDocxFileId; - } + await _snapshotService.Build(link.TaskId); + var fileId = await _contractDocumentService.Generate(new OnboardingGenerateContractInput + { + TaskContractId = contract.Id + }); - private async Task> GetEffectiveFormRequirements(ObOnboardingTask task) - { - var requirements = await _db.Queryable() - .Where(x => x.CompanyId == task.CompanyId && (x.PositionId == null || x.PositionId == task.PositionId)) - .ToListAsync(); - - // 同一字段同时存在公司级和岗位级配置时,岗位配置覆盖公司默认配置。 - return requirements - .GroupBy(x => x.FieldId) - .Select(group => group.FirstOrDefault(x => x.PositionId == task.PositionId) ?? group.First()) - .ToList(); - } - - private async Task> GetEffectiveMaterialRequirements(ObOnboardingTask task) - { - var requirements = await _db.Queryable() - .Where(x => x.CompanyId == task.CompanyId && (x.PositionId == null || x.PositionId == task.PositionId)) - .ToListAsync(); - - // 同一材料同时存在公司级和岗位级配置时,岗位配置覆盖公司默认配置。 - return requirements - .GroupBy(x => x.MaterialTypeId) - .Select(group => group.FirstOrDefault(x => x.PositionId == task.PositionId) ?? group.First()) - .ToList(); + contract.ReadAt ??= DateTime.Now; + if (contract.ContractStatus == TaskContractStatus.PendingGenerate) + contract.ContractStatus = TaskContractStatus.PendingConfirm; + await _db.Updateable(contract).ExecuteCommandAsync(); + return fileId; } private async Task GetActiveLink(string token) { + if (string.IsNullOrWhiteSpace(token)) + throw Oops.Bah("入职链接 Token 不能为空"); + var hash = Convert.ToHexString( global::System.Security.Cryptography.SHA256.HashData( global::System.Text.Encoding.UTF8.GetBytes(token))); - var link = await _db.Queryable().FirstAsync(it => it.TokenHash == hash) + var link = await _db.Queryable().FirstAsync(item => item.TokenHash == hash) ?? throw Oops.Bah("链接不存在"); if (link.LinkStatus != OnboardingLinkStatus.Active || link.ExpiresAt <= DateTime.Now) @@ -163,4 +146,4 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService return link; } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs b/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs index 36e9e65..2a86371 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs @@ -16,6 +16,16 @@ public class OnboardingTaskCreateInput /// [IdNotNull(ErrorMessage = "岗位Id不能为空")] public long PositionId { get; set; } + /// + /// 本次任务使用的入职模板 Id。 + /// + [IdNotNull(ErrorMessage = "入职模板Id不能为空")] + public long OnboardingTemplateId { get; set; } + + /// + /// 创建任务时由人事预先填写的合同变量。 + /// + public List ContractVariables { get; set; } = new(); /// /// 创建任务时指定的审核人用户 Id。 @@ -33,6 +43,28 @@ public class OnboardingTaskCreateInput public string Remark { get; set; } } +/// +/// 创建任务时填写的单个合同变量。 +/// +public class OnboardingInitialContractVariableInput +{ + /// + /// 变量所属合同模板版本 Id。 + /// + [IdNotNull(ErrorMessage = "合同模板版本Id不能为空")] + public long ContractTemplateVersionId { get; set; } + + /// + /// DOCX 中的完整变量编码,例如 hr.MonthlySalary。 + /// + public string VariableCode { get; set; } + + /// + /// 人事录入的变量值。 + /// + public string VariableValue { get; set; } +} + /// /// 入职任务创建结果。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs index b6d77a7..369fe14 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs @@ -1,7 +1,7 @@ namespace SimpleAdmin.Application; /// -/// 实现入职任务创建业务能力。 +/// 实现基于入职模板创建任务及冻结配置快照的业务能力。 /// public class OnboardingTaskService : IOnboardingTaskService { @@ -19,42 +19,69 @@ public class OnboardingTaskService : IOnboardingTaskService } /// - /// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。 + /// 校验公司、岗位和入职模板后创建任务,并冻结合同、补充字段及材料要求。 /// public async Task Create(OnboardingTaskCreateInput input) { var companyEnabled = await _db.Queryable() .AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable"); if (!companyEnabled) - { throw Oops.Bah("公司不存在或未启用"); - } var positionEnabled = await _db.Queryable() .AnyAsync(item => item.Id == input.PositionId && item.CompanyId == input.CompanyId && item.Status == "enable"); if (!positionEnabled) - { throw Oops.Bah("岗位不存在、未启用或不属于该公司"); - } - var scopes = await _db.Queryable() - .Where(item => item.CompanyId == input.CompanyId - && (item.PositionId == null || item.PositionId == input.PositionId)) + var onboardingTemplate = await _db.Queryable() + .FirstAsync(item => item.Id == input.OnboardingTemplateId + && item.CompanyId == input.CompanyId + && 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 fieldConfigs = await _db.Queryable() + .Where(item => item.OnboardingTemplateId == onboardingTemplate.Id) + .OrderBy(item => item.SortCode) + .ToListAsync(); + var materialConfigs = await _db.Queryable() + .Where(item => item.OnboardingTemplateId == onboardingTemplate.Id) + .OrderBy(item => item.SortCode) .ToListAsync(); - // 同一版本同时存在“全公司”和“指定岗位”范围时,岗位专属配置优先,避免同一合同生成两次。 - scopes = scopes - .GroupBy(item => item.TemplateVersionId) - .Select(group => group.FirstOrDefault(item => item.PositionId == input.PositionId) ?? group.First()) - .ToList(); - if (scopes.Count == 0) - { - throw Oops.Bah("该公司和岗位尚未配置合同适用范围"); - } + var templateIds = contractConfigs.Select(item => item.ContractTemplateId).Distinct().ToList(); + var contractTemplates = await _db.Queryable() + .Where(item => templateIds.Contains(item.Id)) + .ToListAsync(); + 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 fieldIds = fieldConfigs.Select(item => item.FieldId).Distinct().ToList(); + var fields = fieldIds.Count == 0 + ? new List() + : await _db.Queryable().Where(item => fieldIds.Contains(item.Id)).ToListAsync(); + 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(); - var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); var expiryConfig = await _configService.GetByConfigKey( CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.link.expireHours"); @@ -65,6 +92,7 @@ public class OnboardingTaskService : IOnboardingTaskService 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 @@ -73,13 +101,13 @@ public class OnboardingTaskService : IOnboardingTaskService TaskNo = $"OB{DateTime.Now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}", CompanyId = input.CompanyId, PositionId = input.PositionId, + OnboardingTemplateId = onboardingTemplate.Id, AssignedReviewerUserId = input.AssignedReviewerUserId, LinkExpireAt = expiresAt, TaskStatus = OnboardingTaskStatus.PendingFill, Remark = input.Remark }; - // 任务、链接、合同实例和变量快照属于同一创建动作,必须保证事务一致性。 await _db.Ado.BeginTranAsync(); try { @@ -93,27 +121,68 @@ public class OnboardingTaskService : IOnboardingTaskService ExpiresAt = expiresAt }).ExecuteCommandAsync(); - var templateIds = scopes.Select(item => item.TemplateId).Distinct().ToList(); - var templates = await _db.Queryable() - .Where(item => templateIds.Contains(item.Id)) - .ToListAsync(); - var contracts = scopes.Select(scope => + var contracts = contractConfigs.Select(config => { - var template = templates.First(item => item.Id == scope.TemplateId); + var template = contractTemplates.First(item => item.Id == config.ContractTemplateId); return new ObTaskContract { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TaskId = taskId, - TemplateId = scope.TemplateId, - TemplateVersionId = scope.TemplateVersionId, + TemplateId = config.ContractTemplateId, + TemplateVersionId = config.ContractTemplateVersionId, ContractName = template.TemplateName, ContractType = template.ContractType, - Required = scope.Required + Required = config.Required, + PrintCopies = config.PrintCopies }; }).ToList(); - await _db.Insertable(contracts).ExecuteCommandAsync(); + + if (fieldConfigs.Count > 0) + { + var fieldSnapshots = fieldConfigs.Select(config => + { + var field = fields.First(item => item.Id == config.FieldId); + return new ObTaskFormRequirement + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + TaskId = taskId, + FieldId = field.Id, + FieldCode = field.FieldCode, + FieldName = field.FieldName, + DataType = field.DataType, + OptionsJson = field.OptionsJson, + Required = config.Required, + SortCode = config.SortCode + }; + }).ToList(); + await _db.Insertable(fieldSnapshots).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 @@ -130,4 +199,73 @@ public class OnboardingTaskService : IOnboardingTaskService 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 == ContractVariableType.Contract + || item.VariableType == ContractVariableType.Company)) + .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 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(); + } } diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskContractVariable/OnboardingTaskContractVariableService.cs b/SimpleAdmin.Application/Services/Onboarding/TaskContractVariable/OnboardingTaskContractVariableService.cs index 3f7b58d..6bc5c09 100644 --- a/SimpleAdmin.Application/Services/Onboarding/TaskContractVariable/OnboardingTaskContractVariableService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/TaskContractVariable/OnboardingTaskContractVariableService.cs @@ -141,6 +141,15 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari { await _db.Updateable(rows) .ExecuteCommandAsync(); + + // 人事参数变化后,原合同文件和员工确认必须失效,避免员工确认旧内容。 + contract.GeneratedDocxFileId = null; + contract.GeneratedPdfFileId = null; + contract.GeneratedAt = null; + contract.ReadAt = null; + contract.ConfirmedAt = null; + contract.ContractStatus = TaskContractStatus.PendingGenerate; + await _db.Updateable(contract).ExecuteCommandAsync(); } } } diff --git a/SimpleAdmin.Application/SimpleAdmin.Application.xml b/SimpleAdmin.Application/SimpleAdmin.Application.xml index b3aa902..925de83 100644 --- a/SimpleAdmin.Application/SimpleAdmin.Application.xml +++ b/SimpleAdmin.Application/SimpleAdmin.Application.xml @@ -504,6 +504,11 @@ 入职任务所属岗位 Id。 + + + 创建任务时锁定的入职模板 Id。 + + 创建任务时指定的审核人用户 Id。 @@ -549,6 +554,131 @@ 业务备注。 + + + 入职模板,统一组织公司、岗位、合同、补充字段和上传材料。 + + + + + 入职模板业务编码。 + + + + + 入职模板显示名称。 + + + + + 所属入职业务公司 Id。 + + + + + 模板用途和适用范围说明。 + + + + + 入职模板需要使用的合同文件及锁定版本。 + + + + + 入职模板 Id。 + + + + + 合同模板主档 Id。 + + + + + 锁定的合同模板版本 Id。 + + + + + 员工是否必须查看并确认该合同。 + + + + + 合同打印份数。 + + + + + 显示和生成顺序。 + + + + + 入职模板需要员工填写的动态补充字段。 + + + + + 入职模板 Id。 + + + + + 动态补充字段 Id。 + + + + + 员工是否必须填写。 + + + + + 手机端显示顺序。 + + + + + 入职模板需要员工上传的材料配置。 + + + + + 入职模板 Id。 + + + + + 上传材料类型 Id。 + + + + + 员工是否必须上传。 + + + + + 手机端显示顺序。 + + + + + 入职模板适用岗位关系。 + + + + + 入职模板 Id。 + + + + + 适用岗位 Id。 + + 入职业务岗位档案,保存岗位默认合同期限、试用期及业务参数。 @@ -664,6 +794,11 @@ 是否为候选人必须阅读确认的合同。 + + + 任务创建时冻结的合同打印份数。 + + 任务合同当前状态。 @@ -889,6 +1024,51 @@ 业务备注。 + + + 入职任务创建时冻结的动态补充字段要求。 + + + + + 入职任务 Id。 + + + + + 原动态字段 Id。 + + + + + 冻结的字段业务编码。 + + + + + 冻结的字段显示名称。 + + + + + 冻结的数据类型。 + + + + + 冻结的选项 JSON。 + + + + + 是否必须填写。 + + + + + 手机端显示顺序。 + + 候选人动态字段填写值,保留字段快照及提交轮次。 @@ -1009,6 +1189,56 @@ 材料审核意见。 + + + 入职任务创建时冻结的上传材料要求。 + + + + + 入职任务 Id。 + + + + + 原材料类型 Id。 + + + + + 冻结的材料编码。 + + + + + 冻结的材料名称。 + + + + + 冻结的允许扩展名。 + + + + + 冻结的最大文件数量。 + + + + + 冻结的单文件大小上限,单位 MB。 + + + + + 是否必须上传。 + + + + + 手机端显示顺序。 + + 入职任务操作日志,用于追踪关键状态变化及操作人。 @@ -1249,6 +1479,11 @@ 合同变量类型。 + + + 导入模板时尚未确认来源的变量。 + + 值来源于候选人或正式员工信息。 @@ -1536,12 +1771,12 @@ - 实现入职合同文件生成业务能力。 + 实现入职合同预览和归档 DOCX 文件生成能力。 - 根据已锁定的模板版本和变量快照生成任务合同 DOCX 文件。 + 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。 @@ -1549,86 +1784,35 @@ 定义任务合同变量快照业务能力。 - + 根据任务、公司与候选人资料刷新自动变量;人事已手工填写的变量不会被覆盖。 根据任务、公司和候选人资料构建或刷新合同变量快照。 - 冻结任务合同变量;DOCX 生成阶段只读取本快照,不再读取模板实时值。 - 实现任务合同变量快照业务能力。 + 构建任务合同变量快照,合同生成阶段只读取本快照。 - + - 根据任务、公司和候选人资料构建或刷新合同变量快照。 + 根据任务、公司、员工固定信息和动态补充字段构建合同变量快照。 - + - 合同适用范围分页查询参数。 + 负责从 DOCX 中识别占位符并在不依赖 Word 进程的情况下替换文本节点。 - + - 入职业务公司 Id。 + 扫描正文、表格、页眉和页脚,返回去重后的变量编码。 - + - 岗位 Id;为空时表示公司级配置。 - - - - - 合同模板 Id。 - - - - - 新增合同适用范围参数。 - - - - - 入职业务公司 Id。 - - - - - 岗位 Id;为空时表示公司级配置。 - - - - - 合同模板 Id。 - - - - - 合同模板版本 Id。 - - - - - 是否必填、必传或必签。 - - - - - 显示排序码。 - - - - - 编辑合同适用范围参数。 - - - - - 业务数据 Id。 + 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。 @@ -1853,7 +2037,7 @@ - 定义合同模板、版本、变量和适用范围业务能力。 + 定义合同模板、DOCX 版本和自动识别变量的管理能力。 @@ -1878,7 +2062,7 @@ - 删除未被版本、适用范围或任务合同引用的模板。 + 删除未被版本、入职模板或任务合同引用的模板。 @@ -1888,7 +2072,7 @@ - 新增合同模板 DOCX 版本。 + 新增合同模板 DOCX 版本并自动识别变量。 @@ -1898,37 +2082,12 @@ - 分页查询指定模板版本的变量定义。 - - - - - 为草稿模板版本新增变量。 + 分页查询指定模板版本自动识别出的变量。 - 编辑草稿模板版本中的变量。 - - - - - 删除草稿模板版本中的变量。 - - - - - 新增公司或岗位的合同适用范围。 - - - - - 编辑合同适用范围。 - - - - - 删除合同适用范围。 + 编辑并确认草稿模板版本中的变量定义。 @@ -1981,39 +2140,9 @@ 分页查询指定模板版本的变量定义。 - - - 为草稿模板版本新增变量。 - - - 编辑草稿模板版本中的变量。 - - - - - 删除草稿模板版本中的变量。 - - - - - 分页查询合同适用范围。 - - - - - 新增公司或岗位的合同适用范围。 - - - - - 编辑合同适用范围。 - - - - - 删除合同适用范围。 + 编辑草稿模板版本中自动识别出的变量,不允许修改 DOCX 占位符编码。 @@ -2182,79 +2311,9 @@ 启用状态或状态筛选条件。 - - - 保存公司岗位动态字段要求参数。 - - - - - 业务数据 Id。 - - - - - 入职业务公司 Id。 - - - - - 岗位 Id;为空时表示公司级配置。 - - - - - 动态字段 Id。 - - - - - 是否必填、必传或必签。 - - - - - 显示排序码。 - - - - - 保存公司岗位材料要求参数。 - - - - - 业务数据 Id。 - - - - - 入职业务公司 Id。 - - - - - 岗位 Id;为空时表示公司级配置。 - - - - - 材料类型 Id。 - - - - - 是否必填、必传或必签。 - - - - - 显示排序码。 - - - 定义动态字段、材料类型及公司岗位要求配置业务能力。 + 定义员工动态补充字段与上传材料类型的基础配置能力。 @@ -2269,7 +2328,7 @@ - 删除未被填写要求引用的动态字段。 + 删除未被入职模板引用的动态字段。 @@ -2284,42 +2343,12 @@ - 删除未被要求或上传记录引用的材料类型。 - - - - - 查询指定公司的动态字段填写要求。 - - - - - 新增或编辑公司岗位动态字段要求。 - - - - - 删除动态字段填写要求。 - - - - - 查询指定公司的材料上传要求。 - - - - - 新增或编辑公司岗位材料要求。 - - - - - 删除材料上传要求。 + 删除未被入职模板或上传记录引用的材料类型。 - 维护员工端动态字段、材料类型及公司岗位要求。 + 维护员工端动态补充字段和材料类型;与公司、岗位的组合关系由入职模板统一管理。 @@ -2357,36 +2386,6 @@ 删除未被要求或上传记录引用的材料类型。 - - - 查询指定公司的动态字段填写要求。 - - - - - 新增或编辑公司岗位动态字段要求。 - - - - - 删除动态字段填写要求。 - - - - - 查询指定公司的材料上传要求。 - - - - - 新增或编辑公司岗位材料要求。 - - - - - 删除材料上传要求。 - - 定义入职任务撤销和归档能力。 @@ -2457,6 +2456,261 @@ 审核单份候选人上传材料并记录审核意见。 + + + 入职模板分页查询参数。 + + + + + 模板编码或名称关键词。 + + + + + 所属公司 Id。 + + + + + 适用岗位 Id。 + + + + + 启用状态。 + + + + + 保存入职模板参数。 + + + + + 编辑时传入的入职模板 Id。 + + + + + 入职模板业务编码。 + + + + + 入职模板名称。 + + + + + 所属公司 Id。 + + + + + 适用岗位 Id 列表。 + + + + + 合同配置列表。 + + + + + 动态补充字段列表。 + + + + + 上传材料列表。 + + + + + 模板状态。 + + + + + 模板说明。 + + + + + 入职模板合同配置参数。 + + + + + 合同模板 Id。 + + + + + 合同模板版本 Id。 + + + + + 是否必须查看确认。 + + + + + 打印份数。 + + + + + 排序码。 + + + + + 入职模板补充字段参数。 + + + + + 动态字段 Id。 + + + + + 是否必填。 + + + + + 排序码。 + + + + + 入职模板上传材料参数。 + + + + + 材料类型 Id。 + + + + + 是否必传。 + + + + + 排序码。 + + + + + 入职模板列表输出。 + + + + + 所属公司名称。 + + + + + 适用岗位 Id 列表。 + + + + + 适用岗位名称列表。 + + + + + 合同配置数量。 + + + + + 动态补充字段数量。 + + + + + 上传材料数量。 + + + + + 入职模板详情输出。 + + + + + 合同配置明细。 + + + + + 动态补充字段配置明细。 + + + + + 上传材料配置明细。 + + + + + 入职模板聚合配置服务。 + + + + + 分页查询入职模板。 + + + + + 查询入职模板详情。 + + + + + 新增或编辑入职模板及全部关联配置。 + + + + + 删除未被任务使用的入职模板。 + + + + + 查询公司岗位可使用的入职模板。 + + + + + 实现入职模板聚合配置及公司岗位匹配能力。 + + + + + + + + + + + + + + + + + 入职业务岗位分页查询参数。 @@ -2709,32 +2963,32 @@ - 实现员工端入职资料提交业务能力。 + 实现员工端动态资料、上传材料、合同确认和最终提交能力。 - 保存候选人填写的动态字段值。 + 保存候选人填写的动态补充字段值。 - 关联候选人已经上传的系统文件与材料类型。 + 关联已上传 OSS 文件与任务材料要求。 - 记录候选人已阅读并确认指定任务合同。 + 记录候选人已经查看并确认动态生成的合同。 - 校验全部必填资料后提交入职任务进入审核。 + 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。 - 在保存文件前校验材料适用范围、扩展名、大小和数量。 + 上传 OSS 前按照任务材料快照校验扩展名、文件大小和数量。 @@ -2759,22 +3013,22 @@ - 员工手机端入口;仅凭一次性任务链接访问,绝不创建正式员工。 + 员工手机端公开入口,仅凭一次性任务链接访问入职资料。 - 通过有效 Token 获取员工端任务、字段、材料和合同信息。 + 通过有效 Token 获取任务创建时冻结的字段、材料和合同信息。 - 保存候选人固定基础信息并刷新合同变量快照。 + 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。 - 校验任务归属后返回合同模板源文件 Id。 + 根据员工当前已填写信息生成合同预览,并返回已替换变量的 DOCX 文件 Id。 @@ -3027,6 +3281,16 @@ 入职任务所属岗位 Id。 + + + 本次任务使用的入职模板 Id。 + + + + + 创建任务时由人事预先填写的合同变量。 + + 创建任务时指定的审核人用户 Id。 @@ -3042,6 +3306,26 @@ 业务备注。 + + + 创建任务时填写的单个合同变量。 + + + + + 变量所属合同模板版本 Id。 + + + + + DOCX 中的完整变量编码,例如 hr.MonthlySalary。 + + + + + 人事录入的变量值。 + + 入职任务创建结果。 @@ -3234,12 +3518,17 @@ - 实现入职任务创建业务能力。 + 实现基于入职模板创建任务及冻结配置快照的业务能力。 - 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。 + 校验公司、岗位和入职模板后创建任务,并冻结合同、补充字段及材料要求。 + + + + + 校验并保存任务创建时填写的人事合同变量。 diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs index ed86c31..0080abc 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs @@ -1,7 +1,7 @@ namespace SimpleAdmin.Web.Core; /// -/// 提供合同模板、版本、变量和适用范围相关 HTTP 接口。 +/// 提供合同模板、DOCX 版本和自动识别变量配置接口。 /// [ApiDescriptionSettings("Application", Tag = "入职合同模板")] [Route("biz/onboarding/contract-template")] @@ -118,15 +118,6 @@ public class OnboardingContractTemplateController return await _service.VariablePage(input); } - /// - /// 新增模板变量。 - /// - [HttpPost("variable/add")] - public async Task AddVariable([FromBody] OnboardingTemplateVariableAddInput input) - { - await _service.AddVariable(input); - } - /// /// 编辑模板变量。 /// @@ -135,49 +126,4 @@ public class OnboardingContractTemplateController { await _service.EditVariable(input); } - - /// - /// 删除模板变量。 - /// - [HttpPost("variable/delete")] - public async Task DeleteVariable([FromBody] BaseIdListInput input) - { - await _service.DeleteVariable(input); - } - - /// - /// 分页查询合同适用范围。 - /// - [HttpGet("scope/page")] - public async Task ScopePage([FromQuery] OnboardingContractScopePageInput input) - { - return await _service.ScopePage(input); - } - - /// - /// 新增合同适用范围。 - /// - [HttpPost("scope/add")] - public async Task AddScope([FromBody] OnboardingContractScopeAddInput input) - { - await _service.AddScope(input); - } - - /// - /// 编辑合同适用范围。 - /// - [HttpPost("scope/edit")] - public async Task EditScope([FromBody] OnboardingContractScopeEditInput input) - { - await _service.EditScope(input); - } - - /// - /// 删除合同适用范围。 - /// - [HttpPost("scope/delete")] - public async Task DeleteScope([FromBody] BaseIdListInput input) - { - await _service.DeleteScope(input); - } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingEntryConfigController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingEntryConfigController.cs index 2a71aee..d1ae1c0 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingEntryConfigController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingEntryConfigController.cs @@ -1,7 +1,7 @@ namespace SimpleAdmin.Web.Core; /// -/// 提供入职字段与材料要求配置相关 HTTP 接口。 +/// 提供员工动态补充字段与上传材料类型配置接口。 /// [ApiDescriptionSettings("Application", Tag = "入职填写与材料配置")] [Route("biz/onboarding/entry-config")] @@ -68,58 +68,4 @@ public class OnboardingEntryConfigController { await _service.DeleteMaterialTypes(input); } - - /// - /// 查询公司岗位动态字段要求。 - /// - [HttpGet("form-requirement/list")] - public async Task ListFormRequirements([FromQuery] long companyId) - { - return await _service.ListFormRequirements(companyId); - } - - /// - /// 保存公司岗位动态字段要求。 - /// - [HttpPost("form-requirement/save")] - public async Task SaveFormRequirement([FromBody] OnboardingFormRequirementSaveInput input) - { - await _service.SaveFormRequirement(input); - } - - /// - /// 删除动态字段要求。 - /// - [HttpPost("form-requirement/delete")] - public async Task DeleteFormRequirements([FromBody] BaseIdListInput input) - { - await _service.DeleteFormRequirements(input); - } - - /// - /// 查询公司岗位材料要求。 - /// - [HttpGet("material-requirement/list")] - public async Task ListMaterialRequirements([FromQuery] long companyId) - { - return await _service.ListMaterialRequirements(companyId); - } - - /// - /// 保存公司岗位材料要求。 - /// - [HttpPost("material-requirement/save")] - public async Task SaveMaterialRequirement([FromBody] OnboardingMaterialRequirementSaveInput input) - { - await _service.SaveMaterialRequirement(input); - } - - /// - /// 删除材料要求。 - /// - [HttpPost("material-requirement/delete")] - public async Task DeleteMaterialRequirements([FromBody] BaseIdListInput input) - { - await _service.DeleteMaterialRequirements(input); - } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPublicTaskController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPublicTaskController.cs index 0977df3..7692220 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPublicTaskController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPublicTaskController.cs @@ -36,7 +36,7 @@ public class OnboardingPublicTaskController } /// - /// 下载当前任务锁定的合同模板源文件。 + /// 下载根据当前任务资料动态生成并已替换变量的合同文件。 /// [HttpGet("contract-source")] public async Task ContractSource([FromQuery] OnboardingPublicContractSourceInput input) diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTemplateController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTemplateController.cs new file mode 100644 index 0000000..0e8f0ef --- /dev/null +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTemplateController.cs @@ -0,0 +1,62 @@ +namespace SimpleAdmin.Web.Core; + +/// +/// 提供入职模板聚合配置接口。 +/// +[ApiDescriptionSettings("Application", Tag = "入职模板")] +[Route("biz/onboarding/template")] +[RolePermission] +public class OnboardingTemplateController +{ + private readonly IOnboardingTemplateService _service; + + public OnboardingTemplateController(IOnboardingTemplateService service) + { + _service = service; + } + + /// + /// 分页查询入职模板。 + /// + [HttpGet("page")] + public async Task Page([FromQuery] OnboardingTemplatePageInput input) + { + return await _service.Page(input); + } + + /// + /// 查询入职模板详情及全部关联配置。 + /// + [HttpGet("detail")] + public async Task Detail([FromQuery] BaseIdInput input) + { + return await _service.Detail(input); + } + + /// + /// 新增或编辑入职模板。 + /// + [HttpPost("save")] + public async Task Save([FromBody] OnboardingTemplateSaveInput input) + { + return await _service.Save(input); + } + + /// + /// 删除未被任务使用的入职模板。 + /// + [HttpPost("delete")] + public async Task Delete([FromBody] BaseIdListInput input) + { + await _service.Delete(input); + } + + /// + /// 查询指定公司岗位可使用的入职模板。 + /// + [HttpGet("list-enabled")] + public async Task ListEnabled([FromQuery] long companyId, [FromQuery] long positionId) + { + return await _service.ListEnabled(companyId, positionId); + } +} \ No newline at end of file diff --git a/SimpleAdmin.Web.Core/SimpleAdmin.Web.Core.xml b/SimpleAdmin.Web.Core/SimpleAdmin.Web.Core.xml index 5e260ac..7385258 100644 --- a/SimpleAdmin.Web.Core/SimpleAdmin.Web.Core.xml +++ b/SimpleAdmin.Web.Core/SimpleAdmin.Web.Core.xml @@ -88,7 +88,7 @@ - 提供合同模板、版本、变量和适用范围相关 HTTP 接口。 + 提供合同模板、DOCX 版本和自动识别变量配置接口。 @@ -141,41 +141,11 @@ 分页查询模板变量。 - - - 新增模板变量。 - - 编辑模板变量。 - - - 删除模板变量。 - - - - - 分页查询合同适用范围。 - - - - - 新增合同适用范围。 - - - - - 编辑合同适用范围。 - - - - - 删除合同适用范围。 - - 提供正式员工档案相关 HTTP 接口。 @@ -193,7 +163,7 @@ - 提供入职字段与材料要求配置相关 HTTP 接口。 + 提供员工动态补充字段与上传材料类型配置接口。 @@ -226,36 +196,6 @@ 删除入职材料类型。 - - - 查询公司岗位动态字段要求。 - - - - - 保存公司岗位动态字段要求。 - - - - - 删除动态字段要求。 - - - - - 查询公司岗位材料要求。 - - - - - 保存公司岗位材料要求。 - - - - - 删除材料要求。 - - 提供候选人材料审核相关 HTTP 接口。 @@ -348,7 +288,7 @@ - 下载当前任务锁定的合同模板源文件。 + 下载根据当前任务资料动态生成并已替换变量的合同文件。 @@ -406,6 +346,36 @@ 查询入职任务完整详情。 + + + 提供入职模板聚合配置接口。 + + + + + 分页查询入职模板。 + + + + + 查询入职模板详情及全部关联配置。 + + + + + 新增或编辑入职模板。 + + + + + 删除未被任务使用的入职模板。 + + + + + 查询指定公司岗位可使用的入职模板。 + + 机构管理控制器 diff --git a/database/migrations/20260824_onboarding_template_refactor.sql b/database/migrations/20260824_onboarding_template_refactor.sql new file mode 100644 index 0000000..cd21123 --- /dev/null +++ b/database/migrations/20260824_onboarding_template_refactor.sql @@ -0,0 +1,364 @@ +-- OnboardX 入职模板聚合重构 +-- 适用数据库:MySQL 8.x +-- 执行前请备份 OnboardX 数据库。 +-- 新逻辑不再读取 ob_form_requirement、ob_material_requirement、ob_contract_scope, +-- 三张旧表暂时保留,仅用于迁移和回滚;对应写接口已从业务代码中移除。 +-- 执行本脚本后请重启后端服务。 + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +CREATE TABLE IF NOT EXISTS `ob_onboarding_template` ( + `Id` BIGINT NOT NULL COMMENT '主键Id', + `TemplateCode` VARCHAR(100) NOT NULL COMMENT '入职模板编码', + `TemplateName` VARCHAR(200) NOT NULL COMMENT '入职模板名称', + `CompanyId` BIGINT NOT NULL COMMENT '所属公司Id', + `Description` VARCHAR(1000) NULL COMMENT '模板说明', + `Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '状态', + `CreateTime` DATETIME NULL COMMENT '创建时间', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + `CreateUserId` BIGINT NULL COMMENT '创建者Id', + `UpdateUserId` BIGINT NULL COMMENT '修改者Id', + `CreateUser` VARCHAR(100) NULL COMMENT '创建人', + `UpdateUser` VARCHAR(100) NULL COMMENT '更新人', + `IsDelete` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除', + `ExtJson` LONGTEXT NULL COMMENT '扩展信息', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_onboarding_template_code` (`TemplateCode`), + KEY `idx_ob_onboarding_template_company` (`CompanyId`, `Status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板'; + +CREATE TABLE IF NOT EXISTS `ob_onboarding_template_position` ( + `Id` BIGINT NOT NULL, + `OnboardingTemplateId` BIGINT NOT NULL COMMENT '入职模板Id', + `PositionId` BIGINT NOT NULL COMMENT '岗位Id', + `Status` VARCHAR(20) NULL DEFAULT 'enable', + `CreateTime` DATETIME NULL, + `UpdateTime` DATETIME NULL, + `CreateUserId` BIGINT NULL, + `UpdateUserId` BIGINT NULL, + `CreateUser` VARCHAR(100) NULL, + `UpdateUser` VARCHAR(100) NULL, + `IsDelete` TINYINT(1) NOT NULL DEFAULT 0, + `ExtJson` LONGTEXT NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_template_position` (`OnboardingTemplateId`, `PositionId`), + KEY `idx_ob_template_position_position` (`PositionId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板适用岗位'; + +CREATE TABLE IF NOT EXISTS `ob_onboarding_template_contract` ( + `Id` BIGINT NOT NULL, + `OnboardingTemplateId` BIGINT NOT NULL COMMENT '入职模板Id', + `ContractTemplateId` BIGINT NOT NULL COMMENT '合同模板Id', + `ContractTemplateVersionId` BIGINT NOT NULL COMMENT '合同模板版本Id', + `Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必须确认', + `PrintCopies` INT NOT NULL DEFAULT 1 COMMENT '打印份数', + `SortCode` INT NULL COMMENT '排序码', + `Status` VARCHAR(20) NULL DEFAULT 'enable', + `CreateTime` DATETIME NULL, + `UpdateTime` DATETIME NULL, + `CreateUserId` BIGINT NULL, + `UpdateUserId` BIGINT NULL, + `CreateUser` VARCHAR(100) NULL, + `UpdateUser` VARCHAR(100) NULL, + `IsDelete` TINYINT(1) NOT NULL DEFAULT 0, + `ExtJson` LONGTEXT NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_template_contract_version` (`OnboardingTemplateId`, `ContractTemplateVersionId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板合同配置'; + +CREATE TABLE IF NOT EXISTS `ob_onboarding_template_form_field` ( + `Id` BIGINT NOT NULL, + `OnboardingTemplateId` BIGINT NOT NULL COMMENT '入职模板Id', + `FieldId` BIGINT NOT NULL COMMENT '动态字段Id', + `Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必填', + `SortCode` INT NULL COMMENT '排序码', + `Status` VARCHAR(20) NULL DEFAULT 'enable', + `CreateTime` DATETIME NULL, + `UpdateTime` DATETIME NULL, + `CreateUserId` BIGINT NULL, + `UpdateUserId` BIGINT NULL, + `CreateUser` VARCHAR(100) NULL, + `UpdateUser` VARCHAR(100) NULL, + `IsDelete` TINYINT(1) NOT NULL DEFAULT 0, + `ExtJson` LONGTEXT NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_template_form_field` (`OnboardingTemplateId`, `FieldId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板补充字段'; + +CREATE TABLE IF NOT EXISTS `ob_onboarding_template_material` ( + `Id` BIGINT NOT NULL, + `OnboardingTemplateId` BIGINT NOT NULL COMMENT '入职模板Id', + `MaterialTypeId` BIGINT NOT NULL COMMENT '材料类型Id', + `Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必传', + `SortCode` INT NULL COMMENT '排序码', + `Status` VARCHAR(20) NULL DEFAULT 'enable', + `CreateTime` DATETIME NULL, + `UpdateTime` DATETIME NULL, + `CreateUserId` BIGINT NULL, + `UpdateUserId` BIGINT NULL, + `CreateUser` VARCHAR(100) NULL, + `UpdateUser` VARCHAR(100) NULL, + `IsDelete` TINYINT(1) NOT NULL DEFAULT 0, + `ExtJson` LONGTEXT NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_template_material` (`OnboardingTemplateId`, `MaterialTypeId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板上传材料'; + +CREATE TABLE IF NOT EXISTS `ob_task_form_requirement` ( + `Id` BIGINT NOT NULL, + `TaskId` BIGINT NOT NULL COMMENT '入职任务Id', + `FieldId` BIGINT NOT NULL COMMENT '原动态字段Id', + `FieldCode` VARCHAR(100) NOT NULL COMMENT '冻结字段编码', + `FieldName` VARCHAR(200) NOT NULL COMMENT '冻结字段名称', + `DataType` VARCHAR(50) NOT NULL COMMENT '冻结数据类型', + `OptionsJson` JSON NULL COMMENT '冻结选项JSON', + `Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必填', + `SortCode` INT NULL COMMENT '排序码', + `Status` VARCHAR(20) NULL DEFAULT 'enable', + `CreateTime` DATETIME NULL, + `UpdateTime` DATETIME NULL, + `CreateUserId` BIGINT NULL, + `UpdateUserId` BIGINT NULL, + `CreateUser` VARCHAR(100) NULL, + `UpdateUser` VARCHAR(100) NULL, + `IsDelete` TINYINT(1) NOT NULL DEFAULT 0, + `ExtJson` LONGTEXT NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_task_form_requirement` (`TaskId`, `FieldId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务补充字段快照'; + +CREATE TABLE IF NOT EXISTS `ob_task_material_requirement` ( + `Id` BIGINT NOT NULL, + `TaskId` BIGINT NOT NULL COMMENT '入职任务Id', + `MaterialTypeId` BIGINT NOT NULL COMMENT '原材料类型Id', + `MaterialCode` VARCHAR(100) NOT NULL COMMENT '冻结材料编码', + `MaterialName` VARCHAR(200) NOT NULL COMMENT '冻结材料名称', + `AllowedExtensions` VARCHAR(500) NULL COMMENT '冻结允许扩展名', + `MaxFileCount` INT NOT NULL DEFAULT 1 COMMENT '冻结最大文件数', + `MaxFileSizeMb` INT NULL COMMENT '冻结单文件大小MB', + `Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必传', + `SortCode` INT NULL COMMENT '排序码', + `Status` VARCHAR(20) NULL DEFAULT 'enable', + `CreateTime` DATETIME NULL, + `UpdateTime` DATETIME NULL, + `CreateUserId` BIGINT NULL, + `UpdateUserId` BIGINT NULL, + `CreateUser` VARCHAR(100) NULL, + `UpdateUser` VARCHAR(100) NULL, + `IsDelete` TINYINT(1) NOT NULL DEFAULT 0, + `ExtJson` LONGTEXT NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_task_material_requirement` (`TaskId`, `MaterialTypeId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务上传材料快照'; + +DROP PROCEDURE IF EXISTS `ob_add_column_if_missing`; +DELIMITER $$ +CREATE PROCEDURE `ob_add_column_if_missing`( + IN p_table_name VARCHAR(128), + IN p_column_name VARCHAR(128), + IN p_column_definition VARCHAR(1000) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = p_table_name + AND COLUMN_NAME = p_column_name + ) THEN + SET @ddl = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_column_definition); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL `ob_add_column_if_missing`( + 'ob_onboarding_task', + 'OnboardingTemplateId', + 'BIGINT NOT NULL DEFAULT 0 COMMENT ''创建任务时锁定的入职模板Id'' AFTER `PositionId`' +); +CALL `ob_add_column_if_missing`( + 'ob_task_contract', + 'PrintCopies', + 'INT NOT NULL DEFAULT 1 COMMENT ''合同打印份数'' AFTER `Required`' +); +DROP PROCEDURE IF EXISTS `ob_add_column_if_missing`; + +-- 将旧的公司/岗位合同范围迁移为“一公司一岗位一入职模板”。 +-- 公司级合同、字段和材料要求会自动继承到该公司的每个岗位模板。 +DROP TEMPORARY TABLE IF EXISTS `tmp_ob_template_map`; +CREATE TEMPORARY TABLE `tmp_ob_template_map` ( + `CompanyId` BIGINT NOT NULL, + `PositionId` BIGINT NOT NULL, + `TemplateCode` VARCHAR(100) NOT NULL, + `OnboardingTemplateId` BIGINT NULL, + PRIMARY KEY (`CompanyId`, `PositionId`) +); + +INSERT IGNORE INTO `tmp_ob_template_map` (`CompanyId`, `PositionId`, `TemplateCode`) +SELECT DISTINCT p.`CompanyId`, p.`Id`, CONCAT('MIGRATED_', p.`CompanyId`, '_', p.`Id`) +FROM `ob_position` p +INNER JOIN ( + SELECT `CompanyId` FROM `ob_contract_scope` WHERE `IsDelete` = 0 + UNION + SELECT `CompanyId` FROM `ob_form_requirement` WHERE `IsDelete` = 0 + UNION + SELECT `CompanyId` FROM `ob_material_requirement` WHERE `IsDelete` = 0 +) configured_company ON configured_company.`CompanyId` = p.`CompanyId` +WHERE p.`IsDelete` = 0; + +INSERT IGNORE INTO `ob_onboarding_template` ( + `Id`, `TemplateCode`, `TemplateName`, `CompanyId`, `Description`, `Status`, `CreateTime`, `IsDelete` +) +SELECT + UUID_SHORT(), + m.`TemplateCode`, + CONCAT(COALESCE(c.`CompanyName`, m.`CompanyId`), '-', COALESCE(p.`PositionName`, m.`PositionId`), '-入职模板'), + m.`CompanyId`, + '由旧公司岗位配置自动迁移,请管理员检查后使用', + 'enable', + NOW(), + 0 +FROM `tmp_ob_template_map` m +LEFT JOIN `ob_company` c ON c.`Id` = m.`CompanyId` +LEFT JOIN `ob_position` p ON p.`Id` = m.`PositionId`; + +UPDATE `tmp_ob_template_map` m +INNER JOIN `ob_onboarding_template` t ON t.`TemplateCode` = m.`TemplateCode` +SET m.`OnboardingTemplateId` = t.`Id`; + +INSERT IGNORE INTO `ob_onboarding_template_position` ( + `Id`, `OnboardingTemplateId`, `PositionId`, `Status`, `CreateTime`, `IsDelete` +) +SELECT UUID_SHORT(), m.`OnboardingTemplateId`, m.`PositionId`, 'enable', NOW(), 0 +FROM `tmp_ob_template_map` m; + +INSERT IGNORE INTO `ob_onboarding_template_contract` ( + `Id`, `OnboardingTemplateId`, `ContractTemplateId`, `ContractTemplateVersionId`, + `Required`, `PrintCopies`, `SortCode`, `Status`, `CreateTime`, `IsDelete` +) +SELECT + UUID_SHORT(), + m.`OnboardingTemplateId`, + MAX(s.`TemplateId`), + s.`TemplateVersionId`, + COALESCE( + MAX(CASE WHEN s.`PositionId` = m.`PositionId` THEN s.`Required` END), + MAX(s.`Required`) + ), + 1, + COALESCE( + MAX(CASE WHEN s.`PositionId` = m.`PositionId` THEN s.`SortCode` END), + MAX(s.`SortCode`) + ), + 'enable', + NOW(), + 0 +FROM `tmp_ob_template_map` m +INNER JOIN `ob_contract_scope` s + ON s.`CompanyId` = m.`CompanyId` + AND (s.`PositionId` IS NULL OR s.`PositionId` = m.`PositionId`) + AND s.`IsDelete` = 0 +GROUP BY m.`OnboardingTemplateId`, s.`TemplateVersionId`; + +INSERT IGNORE INTO `ob_onboarding_template_form_field` ( + `Id`, `OnboardingTemplateId`, `FieldId`, `Required`, `SortCode`, `Status`, `CreateTime`, `IsDelete` +) +SELECT + UUID_SHORT(), + m.`OnboardingTemplateId`, + r.`FieldId`, + COALESCE( + MAX(CASE WHEN r.`PositionId` = m.`PositionId` THEN r.`Required` END), + MAX(r.`Required`) + ), + COALESCE( + MAX(CASE WHEN r.`PositionId` = m.`PositionId` THEN r.`SortCode` END), + MAX(r.`SortCode`) + ), + 'enable', + NOW(), + 0 +FROM `tmp_ob_template_map` m +INNER JOIN `ob_form_requirement` r + ON r.`CompanyId` = m.`CompanyId` + AND (r.`PositionId` IS NULL OR r.`PositionId` = m.`PositionId`) + AND r.`IsDelete` = 0 +GROUP BY m.`OnboardingTemplateId`, r.`FieldId`; + +INSERT IGNORE INTO `ob_onboarding_template_material` ( + `Id`, `OnboardingTemplateId`, `MaterialTypeId`, `Required`, `SortCode`, `Status`, `CreateTime`, `IsDelete` +) +SELECT + UUID_SHORT(), + m.`OnboardingTemplateId`, + r.`MaterialTypeId`, + COALESCE( + MAX(CASE WHEN r.`PositionId` = m.`PositionId` THEN r.`Required` END), + MAX(r.`Required`) + ), + COALESCE( + MAX(CASE WHEN r.`PositionId` = m.`PositionId` THEN r.`SortCode` END), + MAX(r.`SortCode`) + ), + 'enable', + NOW(), + 0 +FROM `tmp_ob_template_map` m +INNER JOIN `ob_material_requirement` r + ON r.`CompanyId` = m.`CompanyId` + AND (r.`PositionId` IS NULL OR r.`PositionId` = m.`PositionId`) + AND r.`IsDelete` = 0 +GROUP BY m.`OnboardingTemplateId`, r.`MaterialTypeId`; + +-- 为迁移前已创建的入职任务回填入职模板,并冻结字段/材料要求快照。 +UPDATE `ob_onboarding_task` task +INNER JOIN `tmp_ob_template_map` m + ON m.`CompanyId` = task.`CompanyId` + AND m.`PositionId` = task.`PositionId` +SET task.`OnboardingTemplateId` = m.`OnboardingTemplateId` +WHERE task.`OnboardingTemplateId` = 0; + +INSERT IGNORE INTO `ob_task_form_requirement` ( + `Id`, `TaskId`, `FieldId`, `FieldCode`, `FieldName`, `DataType`, `OptionsJson`, + `Required`, `SortCode`, `Status`, `CreateTime`, `IsDelete` +) +SELECT + UUID_SHORT(), task.`Id`, config.`FieldId`, field.`FieldCode`, field.`FieldName`, + field.`DataType`, field.`OptionsJson`, config.`Required`, config.`SortCode`, + 'enable', NOW(), 0 +FROM `ob_onboarding_task` task +INNER JOIN `ob_onboarding_template_form_field` config + ON config.`OnboardingTemplateId` = task.`OnboardingTemplateId` +INNER JOIN `ob_form_field` field ON field.`Id` = config.`FieldId` AND field.`IsDelete` = 0 +WHERE task.`OnboardingTemplateId` <> 0; + +INSERT IGNORE INTO `ob_task_material_requirement` ( + `Id`, `TaskId`, `MaterialTypeId`, `MaterialCode`, `MaterialName`, + `AllowedExtensions`, `MaxFileCount`, `MaxFileSizeMb`, `Required`, `SortCode`, + `Status`, `CreateTime`, `IsDelete` +) +SELECT + UUID_SHORT(), task.`Id`, config.`MaterialTypeId`, material.`MaterialCode`, + material.`MaterialName`, material.`AllowedExtensions`, material.`MaxFileCount`, + material.`MaxFileSizeMb`, config.`Required`, config.`SortCode`, + 'enable', NOW(), 0 +FROM `ob_onboarding_task` task +INNER JOIN `ob_onboarding_template_material` config + ON config.`OnboardingTemplateId` = task.`OnboardingTemplateId` +INNER JOIN `ob_material_type` material ON material.`Id` = config.`MaterialTypeId` AND material.`IsDelete` = 0 +WHERE task.`OnboardingTemplateId` <> 0; + +DROP TEMPORARY TABLE IF EXISTS `tmp_ob_template_map`; +SET FOREIGN_KEY_CHECKS = 1; + +-- 执行后检查: +-- SELECT * FROM ob_onboarding_template; +-- SELECT * FROM ob_onboarding_template_position; +-- SELECT * FROM ob_onboarding_template_contract; +-- SELECT * FROM ob_onboarding_template_form_field; +-- SELECT * FROM ob_onboarding_template_material;