From 6151872b6ba6fc891cdc1df5d926d9fe9d2a9076 Mon Sep 17 00:00:00 2001 From: fuenmao <980740792@qq.com> Date: Sat, 29 Aug 2026 17:26:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A5=E8=81=8C=E4=BB=BB=E5=8A=A1=E9=A2=84?= =?UTF-8?q?=E8=A7=88=EF=BC=8C=E5=85=A5=E8=81=8C=E4=BB=BB=E5=8A=A1cope?= =?UTF-8?q?=EF=BC=8C=E9=AA=8C=E8=AF=81=E5=A1=AB=E5=85=A5=E7=9A=84=E4=BF=A1?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dto/OnboardingContractDocumentInput.cs | 55 ++++ .../IOnboardingContractDocumentService.cs | 6 + .../OnboardingContractDocumentService.cs | 197 ++++++++++++ .../OnboardingContractSnapshotService.cs | 22 +- ...ardingContractVariableValidationService.cs | 12 + ...ardingContractVariableValidationService.cs | 187 +++++++++++ .../OnboardingPublicSubmissionService.cs | 47 +-- .../OnboardingEmployeeInfoValidator.cs | 295 ++++++++++++++++++ .../PublicTask/OnboardingPublicTaskService.cs | 42 +-- .../Task/Dto/OnboardingTaskInput.cs | 49 ++- .../Onboarding/Task/IOnboardingTaskService.cs | 5 + .../Onboarding/Task/OnboardingTaskService.cs | 231 +++++++++++++- .../TaskQuery/Dto/OnboardingTaskQueryInput.cs | 60 +++- .../TaskQuery/IOnboardingTaskQueryService.cs | 2 +- .../TaskQuery/OnboardingTaskQueryService.cs | 72 ++++- .../OnboardingContractDocumentController.cs | 17 + .../Onboarding/OnboardingTaskController.cs | 11 + 17 files changed, 1215 insertions(+), 95 deletions(-) create mode 100644 SimpleAdmin.Application/Services/Onboarding/ContractVariableValidation/IOnboardingContractVariableValidationService.cs create mode 100644 SimpleAdmin.Application/Services/Onboarding/ContractVariableValidation/OnboardingContractVariableValidationService.cs create mode 100644 SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingEmployeeInfoValidator.cs diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/Dto/OnboardingContractDocumentInput.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/Dto/OnboardingContractDocumentInput.cs index 863bf77..7c04691 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/Dto/OnboardingContractDocumentInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/Dto/OnboardingContractDocumentInput.cs @@ -11,3 +11,58 @@ public class OnboardingGenerateContractInput [IdNotNull(ErrorMessage = "任务合同Id不能为空")] public long TaskContractId { get; set; } } + +/// +/// 人事在创建入职任务前下载合同预览的参数。 +/// +public class OnboardingContractPreviewInput +{ + /// + /// 入职业务公司 Id。 + /// + [IdNotNull(ErrorMessage = "公司Id不能为空")] + public long CompanyId { get; set; } + + /// + /// 入职业务部门 Id。 + /// + [IdNotNull(ErrorMessage = "部门Id不能为空")] + public long DepartmentId { get; set; } + + /// + /// 入职岗位 Id。 + /// + [IdNotNull(ErrorMessage = "岗位Id不能为空")] + public long PositionId { get; set; } + + /// + /// 当前选择的入职模板 Id。 + /// + [IdNotNull(ErrorMessage = "入职模板Id不能为空")] + public long OnboardingTemplateId { get; set; } + + /// + /// 人事当前在创建表单中填写的合同变量。 + /// + public List ContractVariables + { + get; + set; + } = new(); +} + +/// +/// 创建任务前生成的临时 DOCX 预览文件。 +/// +public class OnboardingContractPreviewOutput +{ + /// + /// DOCX 文件内容,仅通过当前请求返回,不保存到文件表或 OSS。 + /// + public byte[] FileBytes { get; set; } + + /// + /// 浏览器下载时使用的文件名。 + /// + public string FileName { get; set; } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/IOnboardingContractDocumentService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/IOnboardingContractDocumentService.cs index 5c9334a..8f450ff 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/IOnboardingContractDocumentService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/IOnboardingContractDocumentService.cs @@ -5,6 +5,12 @@ namespace SimpleAdmin.Application; /// public interface IOnboardingContractDocumentService : ITransient { + /// + /// 创建入职任务前,根据人事当前填写的参数生成临时 DOCX 预览。 + /// + Task PreviewBeforeCreate( + OnboardingContractPreviewInput input); + /// /// 根据已审核通过的任务合同生成并保存 DOCX 文件。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs index 91cc483..2bafdf5 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs @@ -14,6 +14,177 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ _fileService = fileService; } + /// + /// 在任务创建前生成单份临时 DOCX,仅通过响应返回,不写文件表和 OSS。 + /// + public async Task PreviewBeforeCreate( + OnboardingContractPreviewInput input) + { + var company = await _db.Queryable() + .FirstAsync(item => item.Id == input.CompanyId + && item.Status == CommonStatusConst.ENABLE) + ?? throw Oops.Bah("公司不存在或未启用"); + + var departmentExists = await _db.Queryable() + .AnyAsync(item => item.Id == input.DepartmentId + && item.CompanyId == input.CompanyId + && item.Status == CommonStatusConst.ENABLE); + if (!departmentExists) + throw Oops.Bah("部门不存在、未启用或不属于该公司"); + + var positionExists = await _db.Queryable() + .AnyAsync(item => item.Id == input.PositionId + && item.CompanyId == input.CompanyId + && item.DepartmentId == input.DepartmentId + && item.Status == CommonStatusConst.ENABLE); + if (!positionExists) + throw Oops.Bah("岗位不存在、未启用或不属于该部门/公司"); + + var onboardingTemplate = await _db.Queryable() + .FirstAsync(item => item.Id == input.OnboardingTemplateId + && item.CompanyId == input.CompanyId + && item.DepartmentId == input.DepartmentId + && item.Status == CommonStatusConst.ENABLE) + ?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司、部门"); + + var supportsPosition = await _db.Queryable() + .AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id + && item.PositionId == input.PositionId); + if (!supportsPosition) + throw Oops.Bah("所选入职模板不适用于该岗位"); + + // 当前业务只预览入职模板中排序第一的合同,后续多合同可在此扩展。 + var contractConfig = await _db.Queryable() + .Where(item => item.OnboardingTemplateId == onboardingTemplate.Id) + .OrderBy(item => item.SortCode) + .FirstAsync() + ?? throw Oops.Bah("入职模板尚未配置合同文件"); + + var contractTemplate = await _db.Queryable() + .FirstAsync(item => item.Id == contractConfig.ContractTemplateId + && item.CompanyId == input.CompanyId + && item.DepartmentId == input.DepartmentId + && item.Status == CommonStatusConst.ENABLE) + ?? throw Oops.Bah("合同模板不存在、未启用或不属于当前公司、部门"); + + var contractSupportsPosition = await _db.Queryable() + .AnyAsync(item => item.ContractTemplateId == contractTemplate.Id + && item.PositionId == input.PositionId); + if (!contractSupportsPosition) + throw Oops.Bah("合同模板不适用于当前岗位"); + + var version = await _db.Queryable() + .FirstAsync(item => item.Id == contractConfig.ContractTemplateVersionId + && item.TemplateId == contractTemplate.Id + && item.VersionStatus == ContractTemplateVersionStatus.Enabled) + ?? throw Oops.Bah("合同版本不存在、未启用或不属于当前合同模板"); + + var definitions = await _db.Queryable() + .Where(item => item.TemplateVersionId == version.Id) + .OrderBy(item => item.SortCode) + .ToListAsync(); + var duplicateDefinition = definitions + .GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateDefinition != null) + throw Oops.Bah($"合同变量“{duplicateDefinition.Key}”存在重复定义"); + + var inputs = input.ContractVariables + ?? new List(); + OnboardingTaskService.ValidateInitialContractDateRanges(inputs); + + var duplicateInput = inputs + .GroupBy(item => new + { + item.ContractTemplateVersionId, + VariableCode = item.VariableCode?.ToUpperInvariant() + }) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateInput != null) + throw Oops.Bah($"合同变量“{duplicateInput.Key.VariableCode}”重复提交"); + + var editableDefinitions = definitions + .Where(IsHrEditableDefinition) + .ToList(); + foreach (var submitted in inputs) + { + var definitionExists = submitted.ContractTemplateVersionId == version.Id + && editableDefinitions.Any(definition => + string.Equals( + definition.VariableCode, + submitted.VariableCode, + StringComparison.OrdinalIgnoreCase)); + if (!definitionExists) + throw Oops.Bah($"合同变量“{submitted.VariableCode}”不存在或不允许人事填写"); + } + + var missingRequired = editableDefinitions.FirstOrDefault(definition => + { + if (!definition.Required) + return false; + + var submitted = inputs.FirstOrDefault(item => + item.ContractTemplateVersionId == version.Id + && string.Equals( + item.VariableCode, + definition.VariableCode, + StringComparison.OrdinalIgnoreCase)); + return string.IsNullOrWhiteSpace( + submitted?.VariableValue ?? definition.DefaultValue); + }); + if (missingRequired != null) + throw Oops.Bah($"请填写合同变量“{missingRequired.VariableName}”"); + + var previewTask = new ObOnboardingTask + { + TaskNo = "【发起后自动生成】", + CompanyId = input.CompanyId, + DepartmentId = input.DepartmentId, + PositionId = input.PositionId, + OnboardingTemplateId = input.OnboardingTemplateId + }; + var emptyDynamicValues = new Dictionary( + StringComparer.OrdinalIgnoreCase); + var values = definitions.ToDictionary( + definition => definition.VariableCode, + definition => + { + var submitted = inputs.FirstOrDefault(item => + item.ContractTemplateVersionId == version.Id + && string.Equals( + item.VariableCode, + definition.VariableCode, + StringComparison.OrdinalIgnoreCase)); + if (submitted != null) + return submitted.VariableValue ?? string.Empty; + + return OnboardingContractSnapshotService.ResolveValue( + definition, + null, + emptyDynamicValues, + company, + previewTask, + true) + ?? string.Empty; + }, + StringComparer.OrdinalIgnoreCase); + + var source = await _fileService.Download( + new BaseIdInput { Id = version.SourceDocxFileId }) + ?? throw Oops.Bah("无法读取合同源文件"); + await using var sourceStream = source.FileStream; + using var sourceBytes = new MemoryStream(); + await sourceStream.CopyToAsync(sourceBytes); + + return new OnboardingContractPreviewOutput + { + FileBytes = DocxTemplateProcessor.Render( + sourceBytes.ToArray(), + values), + FileName = BuildPreviewFileName(contractTemplate.TemplateName) + }; + } + /// /// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。 /// @@ -86,4 +257,30 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ await _db.Updateable(contract).ExecuteCommandAsync(); return fileId; } + + /// + /// 判断变量是否允许人事在发起任务前填写。 + /// + private static bool IsHrEditableDefinition(ObTemplateVariable definition) + { + return definition.HrEditable + && (definition.VariableType == "hr" + || definition.VariableType == ContractVariableType.Contract + || definition.VariableType == ContractVariableType.Company + || definition.VariableCode.StartsWith( + "hr.", + StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 生成兼容 Windows 和 Linux 的合同预览下载文件名。 + /// + private static string BuildPreviewFileName(string contractName) + { + var invalidChars = global::System.IO.Path.GetInvalidFileNameChars(); + var safeName = new string((contractName ?? "合同") + .Select(character => invalidChars.Contains(character) ? '_' : character) + .ToArray()); + return $"{safeName}_人事预览_{DateTime.Now:yyyyMMddHHmmss}.docx"; + } } diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs index 34d3dfb..2e532d5 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs @@ -110,12 +110,13 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ }; } - private static string ResolveValue( + internal static string ResolveValue( ObTemplateVariable definition, ObTaskEmployeeInfo employeeInfo, IReadOnlyDictionary dynamicValues, ObCompany company, - ObOnboardingTask task) + ObOnboardingTask task, + bool useEmployeePlaceholder = false) { var sourceField = GetSourceField(definition); if (definition.VariableType == ContractVariableType.Employee) @@ -133,7 +134,15 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ if (doc.RootElement.TryGetProperty(sourceField, out var prop) || doc.RootElement.TryGetProperty(definition.VariableCode, out prop)) { - var str = prop.GetString(); + var str = prop.ValueKind switch + { + global::System.Text.Json.JsonValueKind.String => prop.GetString(), + global::System.Text.Json.JsonValueKind.Number => prop.ToString(), + global::System.Text.Json.JsonValueKind.True => bool.TrueString, + global::System.Text.Json.JsonValueKind.False => bool.FalseString, + global::System.Text.Json.JsonValueKind.Null => null, + _ => throw Oops.Bah($"员工补充变量“{definition.VariableName}”格式不正确") + }; if (!string.IsNullOrWhiteSpace(str)) return str; } } @@ -143,8 +152,11 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ } } - return dynamicValues.TryGetValue(sourceField, out var dynamicValue) - ? dynamicValue + if (dynamicValues.TryGetValue(sourceField, out var dynamicValue)) + return dynamicValue; + + return useEmployeePlaceholder + ? $"【待员工填写:{definition.VariableName ?? definition.VariableCode}】" : definition.DefaultValue; } diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractVariableValidation/IOnboardingContractVariableValidationService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractVariableValidation/IOnboardingContractVariableValidationService.cs new file mode 100644 index 0000000..b87fb72 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ContractVariableValidation/IOnboardingContractVariableValidationService.cs @@ -0,0 +1,12 @@ +namespace SimpleAdmin.Application; + +/// +/// 校验任务合同变量的必填状态和员工动态变量数据类型。 +/// +public interface IOnboardingContractVariableValidationService : ITransient +{ + /// + /// 校验指定任务的全部合同变量快照。 + /// + Task Validate(long taskId); +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractVariableValidation/OnboardingContractVariableValidationService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractVariableValidation/OnboardingContractVariableValidationService.cs new file mode 100644 index 0000000..4f0246e --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ContractVariableValidation/OnboardingContractVariableValidationService.cs @@ -0,0 +1,187 @@ +namespace SimpleAdmin.Application; + +/// +/// 统一校验任务合同变量,确保最终提交的数据可以安全用于合同生成。 +/// +public class OnboardingContractVariableValidationService + : IOnboardingContractVariableValidationService +{ + private readonly SqlSugarScope _db; + + public OnboardingContractVariableValidationService() + { + _db = DbContext.DB; + } + + /// + public async Task Validate(long taskId) + { + var contracts = await _db.Queryable() + .Where(item => item.TaskId == taskId) + .ToListAsync(); + if (contracts.Count == 0) + return; + + var contractIds = contracts + .Select(item => item.Id) + .ToList(); + var templateVersionIds = contracts + .Select(item => item.TemplateVersionId) + .Distinct() + .ToList(); + var variables = await _db.Queryable() + .Where(item => contractIds.Contains(item.TaskContractId)) + .ToListAsync(); + var definitions = await _db.Queryable() + .Where(item => templateVersionIds.Contains(item.TemplateVersionId)) + .ToListAsync(); + + foreach (var contract in contracts) + { + var contractDefinitions = definitions + .Where(item => item.TemplateVersionId == contract.TemplateVersionId) + .OrderBy(item => item.SortCode); + + foreach (var definition in contractDefinitions) + { + var variable = variables.FirstOrDefault(item => + item.TaskContractId == contract.Id + && item.VariableCode == definition.VariableCode); + var value = variable?.VariableValue?.Trim(); + + if (definition.Required && string.IsNullOrWhiteSpace(value)) + { + throw Oops.Bah( + $"合同“{contract.ContractName}”的必填变量“{definition.VariableName}”尚未填写"); + } + + if (string.IsNullOrWhiteSpace(value) + || definition.VariableType != ContractVariableType.Employee) + { + continue; + } + + ValidateDataType(contract.ContractName, definition, value); + ValidateOptions(contract.ContractName, definition, value); + } + } + } + + private static void ValidateDataType( + string contractName, + ObTemplateVariable definition, + string value) + { + var dataType = definition.DataType?.Trim().ToLowerInvariant() ?? "string"; + switch (dataType) + { + case "string": + return; + case "number": + var numberIsValid = decimal.TryParse( + value, + global::System.Globalization.NumberStyles.Number, + global::System.Globalization.CultureInfo.InvariantCulture, + out _); + if (!numberIsValid) + { + throw Oops.Bah( + $"合同“{contractName}”的变量“{definition.VariableName}”必须填写数字"); + } + + return; + case "date": + var dateIsValid = DateTime.TryParseExact( + value, + "yyyy-MM-dd", + global::System.Globalization.CultureInfo.InvariantCulture, + global::System.Globalization.DateTimeStyles.None, + out _); + if (!dateIsValid) + { + throw Oops.Bah( + $"合同“{contractName}”的变量“{definition.VariableName}”必须填写正确日期"); + } + + return; + default: + throw Oops.Bah( + $"合同“{contractName}”的变量“{definition.VariableName}”数据类型配置不正确"); + } + } + + private static void ValidateOptions( + string contractName, + ObTemplateVariable definition, + string value) + { + if (string.IsNullOrWhiteSpace(definition.OptionsJson)) + return; + + var options = ParseOptions(contractName, definition); + if (options.Contains(value)) + return; + + throw Oops.Bah( + $"合同“{contractName}”的变量“{definition.VariableName}”必须从配置选项中选择"); + } + + private static HashSet ParseOptions( + string contractName, + ObTemplateVariable definition) + { + try + { + using var document = global::System.Text.Json.JsonDocument.Parse( + definition.OptionsJson); + if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Array) + throw new global::System.Text.Json.JsonException(); + + var options = document.RootElement + .EnumerateArray() + .Select(GetOptionValue) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .ToHashSet(StringComparer.Ordinal); + + if (options.Count == 0) + throw new global::System.Text.Json.JsonException(); + + return options; + } + catch (global::System.Text.Json.JsonException) + { + throw Oops.Bah( + $"合同“{contractName}”的变量“{definition.VariableName}”可选项配置不正确"); + } + } + + private static string GetOptionValue( + global::System.Text.Json.JsonElement option) + { + if (option.ValueKind == global::System.Text.Json.JsonValueKind.String) + return option.GetString()?.Trim(); + + if (option.ValueKind == global::System.Text.Json.JsonValueKind.Object) + { + if (option.TryGetProperty("value", out var value)) + return GetJsonText(value); + if (option.TryGetProperty("label", out var label)) + return GetJsonText(label); + } + + return GetJsonText(option); + } + + private static string GetJsonText( + global::System.Text.Json.JsonElement value) + { + return value.ValueKind switch + { + global::System.Text.Json.JsonValueKind.String => value.GetString()?.Trim(), + global::System.Text.Json.JsonValueKind.Number => value.ToString(), + global::System.Text.Json.JsonValueKind.True => bool.TrueString, + global::System.Text.Json.JsonValueKind.False => bool.FalseString, + _ => null + }; + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs index 82967e3..36521df 100644 --- a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs @@ -8,14 +8,17 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ private readonly SqlSugarScope _db; private readonly IOnboardingContractSnapshotService _snapshotService; private readonly IOnboardingIdentityValidationService _identityValidationService; + private readonly IOnboardingContractVariableValidationService _contractVariableValidationService; public OnboardingPublicSubmissionService( IOnboardingContractSnapshotService snapshotService, - IOnboardingIdentityValidationService identityValidationService) + IOnboardingIdentityValidationService identityValidationService, + IOnboardingContractVariableValidationService contractVariableValidationService) { _db = DbContext.DB; _snapshotService = snapshotService; _identityValidationService = identityValidationService; + _contractVariableValidationService = contractVariableValidationService; } /// @@ -119,15 +122,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ { var task = await GetEditableTask(input.Token); var info = await _db.Queryable() - .FirstAsync(item => item.TaskId == task.Id); - var basicInfoIncomplete = info == null - || string.IsNullOrWhiteSpace(info.EmployeeName) - || string.IsNullOrWhiteSpace(info.Phone) - || string.IsNullOrWhiteSpace(info.IdCardNumber) - || string.IsNullOrWhiteSpace(info.BankCardNumber) - || string.IsNullOrWhiteSpace(info.BankName); - if (basicInfoIncomplete) - throw Oops.Bah("请先完整填写基本信息"); + .FirstAsync(item => item.TaskId == task.Id) + ?? throw Oops.Bah("请先完整填写基本信息"); + + // 最终提交必须重新执行全量校验,不能只依赖员工手机端的页面校验。 + OnboardingEmployeeInfoValidator.ValidateAndNormalize(info); + await _db.Updateable(info).ExecuteCommandAsync(); await _identityValidationService.EnsureNoDuplicate( task.Id, @@ -148,7 +148,7 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ if (hasMissingRequiredMaterial) throw Oops.Bah("仍有必传材料未上传"); - await EnsureRequiredContractVariables(task); + await _contractVariableValidationService.Validate(task.Id); var hasUnconfirmedRequiredContract = await _db.Queryable() .AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null); @@ -214,31 +214,6 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ throw Oops.Bah("该材料已达到最大上传数量"); } - private async Task EnsureRequiredContractVariables(ObOnboardingTask task) - { - var contracts = await _db.Queryable() - .Where(item => item.TaskId == task.Id) - .ToListAsync(); - var contractIds = contracts.Select(contract => contract.Id).ToList(); - var templateVersionIds = contracts.Select(contract => contract.TemplateVersionId).ToList(); - var variables = await _db.Queryable() - .Where(item => contractIds.Contains(item.TaskContractId)) - .ToListAsync(); - var definitions = await _db.Queryable() - .Where(item => templateVersionIds.Contains(item.TemplateVersionId) && item.Required) - .ToListAsync(); - var missing = contracts - .SelectMany(contract => definitions - .Where(definition => definition.TemplateVersionId == contract.TemplateVersionId) - .Select(definition => new { contract, definition })) - .FirstOrDefault(item => string.IsNullOrWhiteSpace( - variables.FirstOrDefault(variable => variable.TaskContractId == item.contract.Id - && variable.VariableCode == item.definition.VariableCode) - ?.VariableValue)); - if (missing != null) - throw Oops.Bah($"合同“{missing.contract.ContractName}”的必填变量“{missing.definition.VariableName}”尚未填写"); - } - private Task> GetMaterialRequirements(long taskId) { return _db.Queryable() diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingEmployeeInfoValidator.cs b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingEmployeeInfoValidator.cs new file mode 100644 index 0000000..fc836a2 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingEmployeeInfoValidator.cs @@ -0,0 +1,295 @@ +namespace SimpleAdmin.Application; + +/// +/// 统一校验并规范化员工端固定入职信息。 +/// +internal static class OnboardingEmployeeInfoValidator +{ + private static readonly int[] IdCardWeights = + { + 7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2 + }; + + private static readonly char[] IdCardCheckCodes = + { + '1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2' + }; + + /// + /// 规范化文本,并校验固定登记表字段的格式。 + /// + public static void ValidateAndNormalize(ObTaskEmployeeInfo input) + { + input.EmployeeName = input.EmployeeName?.Trim(); + input.Phone = input.Phone?.Trim(); + input.Email = input.Email?.Trim(); + input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant(); + input.BankCardNumber = global::System.Text.RegularExpressions.Regex.Replace( + input.BankCardNumber ?? string.Empty, + "[\\s-]", + string.Empty); + input.BankName = input.BankName?.Trim(); + input.BankAccountName = input.EmployeeName; + + if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100) + throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符"); + + if (input.Gender != "男" && input.Gender != "女") + throw Oops.Bah("请选择正确的性别"); + + if (!global::System.Text.RegularExpressions.Regex.IsMatch( + input.Phone ?? string.Empty, + "^1[3-9]\\d{9}$")) + { + throw Oops.Bah("请输入正确的 11 位手机号码"); + } + + if (!TryValidateIdCard(input.IdCardNumber, out var idCardBirthday, out var idCardError)) + throw Oops.Bah(idCardError); + + ValidateBirthday(input.Birthday, idCardBirthday); + ValidateOptionalEmail(input.Email); + ValidateOptionalNumber(input.Height, "身高", 100M, 250M, "厘米"); + ValidateOptionalNumber(input.Weight, "体重", 30M, 300M, "公斤"); + ValidateBankCard(input.BankCardNumber); + + if (string.IsNullOrWhiteSpace(input.BankName) || input.BankName.Length > 100) + throw Oops.Bah("开户银行名称不能为空且不能超过 100 个字符"); + + ValidateOptionalContactPhone(input.EmergencyPhone); + ValidateEducationHistory(input.EducationHistoryJson); + ValidateWorkHistory(input.WorkHistoryJson); + ValidateFamilyMembers(input.FamilyMembersJson); + ValidateJson(input.CustomVariablesJson, global::System.Text.Json.JsonValueKind.Object, "员工补充变量"); + } + + private static bool TryValidateIdCard( + string idCardNumber, + out DateTime birthday, + out string errorMessage) + { + birthday = default; + errorMessage = null; + + var structureIsValid = global::System.Text.RegularExpressions.Regex.IsMatch( + idCardNumber ?? string.Empty, + "^\\d{17}[0-9X]$"); + if (!structureIsValid) + { + errorMessage = "请输入正确的 18 位身份证号码"; + return false; + } + + var birthdayText = idCardNumber.Substring(6, 8); + var birthdayIsValid = DateTime.TryParseExact( + birthdayText, + "yyyyMMdd", + global::System.Globalization.CultureInfo.InvariantCulture, + global::System.Globalization.DateTimeStyles.None, + out birthday); + if (!birthdayIsValid || birthday.Date > DateTime.Today) + { + errorMessage = "身份证号码中的出生日期不正确"; + return false; + } + + var total = 0; + for (var index = 0; index < IdCardWeights.Length; index++) + { + total += (idCardNumber[index] - '0') * IdCardWeights[index]; + } + + if (IdCardCheckCodes[total % 11] == idCardNumber[17]) + return true; + + errorMessage = "身份证号码校验位不正确"; + return false; + } + + private static void ValidateBirthday(string birthdayText, DateTime idCardBirthday) + { + if (string.IsNullOrWhiteSpace(birthdayText)) + return; + + var birthdayIsValid = DateTime.TryParseExact( + birthdayText.Trim(), + "yyyy-MM-dd", + global::System.Globalization.CultureInfo.InvariantCulture, + global::System.Globalization.DateTimeStyles.None, + out var birthday); + if (!birthdayIsValid || birthday.Date > DateTime.Today) + throw Oops.Bah("请输入正确的出生日期"); + + if (birthday.Date != idCardBirthday.Date) + throw Oops.Bah("出生日期与身份证号码不一致"); + } + + private static void ValidateOptionalEmail(string email) + { + if (string.IsNullOrWhiteSpace(email)) + return; + + var emailIsValid = email.Length <= 100 + && global::System.Net.Mail.MailAddress.TryCreate(email, out _); + if (!emailIsValid) + throw Oops.Bah("请输入正确的电子邮箱"); + } + + private static void ValidateOptionalNumber( + string value, + string fieldName, + decimal min, + decimal max, + string unit) + { + if (string.IsNullOrWhiteSpace(value)) + return; + + var numberIsValid = decimal.TryParse( + value.Trim(), + global::System.Globalization.NumberStyles.Number, + global::System.Globalization.CultureInfo.InvariantCulture, + out var number); + if (!numberIsValid || number < min || number > max) + { + throw Oops.Bah($"{fieldName}应为 {min:0} 至 {max:0} {unit}之间的数字"); + } + } + + private static void ValidateBankCard(string bankCardNumber) + { + var bankCardIsValid = global::System.Text.RegularExpressions.Regex.IsMatch( + bankCardNumber ?? string.Empty, + "^\\d{12,19}$"); + if (!bankCardIsValid) + throw Oops.Bah("请输入 12 至 19 位银行卡号"); + } + + private static void ValidateOptionalContactPhone(string phone) + { + if (string.IsNullOrWhiteSpace(phone)) + return; + + var contactPhoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch( + phone.Trim(), + "^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$"); + if (!contactPhoneIsValid) + throw Oops.Bah("请输入正确的紧急联系人手机号码或座机号码"); + } + + private static void ValidateEducationHistory(string json) + { + ValidateHistoryRows(json, "教育经历", (row, rowNumber) => + { + GetRequiredRowValue(row, "timeRange", rowNumber, "教育经历", "时间范围"); + GetRequiredRowValue(row, "location", rowNumber, "教育经历", "学校或机构"); + GetRequiredRowValue(row, "content", rowNumber, "教育经历", "专业或学习内容"); + GetRequiredRowValue(row, "result", rowNumber, "教育经历", "结果或学位"); + }); + } + + private static void ValidateWorkHistory(string json) + { + ValidateHistoryRows(json, "工作经历", (row, rowNumber) => + { + GetRequiredRowValue(row, "timeRange", rowNumber, "工作经历", "起止时间"); + GetRequiredRowValue(row, "companyName", rowNumber, "工作经历", "工作单位"); + GetRequiredRowValue(row, "position", rowNumber, "工作经历", "担任职务"); + GetRequiredRowValue(row, "leaveReason", rowNumber, "工作经历", "离职原因"); + }); + } + + private static void ValidateFamilyMembers(string json) + { + ValidateHistoryRows(json, "家庭成员", (row, rowNumber) => + { + GetRequiredRowValue(row, "name", rowNumber, "家庭成员", "姓名"); + GetRequiredRowValue(row, "relationship", rowNumber, "家庭成员", "关系"); + var ageText = GetRequiredRowValue(row, "age", rowNumber, "家庭成员", "年龄"); + var phone = GetRequiredRowValue(row, "phone", rowNumber, "家庭成员", "联系电话"); + + var ageIsValid = int.TryParse(ageText, out var age) && age >= 0 && age <= 120; + if (!ageIsValid) + throw Oops.Bah($"第 {rowNumber} 条家庭成员的年龄应为 0 至 120 之间的整数"); + + var phoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch( + phone, + "^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$"); + if (!phoneIsValid) + throw Oops.Bah($"第 {rowNumber} 条家庭成员的联系电话格式不正确"); + }); + } + + private static void ValidateHistoryRows( + string json, + string fieldName, + Action validateRow) + { + if (string.IsNullOrWhiteSpace(json)) + return; + + try + { + using var document = global::System.Text.Json.JsonDocument.Parse(json); + if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Array) + throw new global::System.Text.Json.JsonException(); + + var rowNumber = 0; + foreach (var row in document.RootElement.EnumerateArray()) + { + rowNumber++; + if (row.ValueKind != global::System.Text.Json.JsonValueKind.Object) + throw Oops.Bah($"第 {rowNumber} 条{fieldName}格式不正确,请删除后重新添加"); + + validateRow(row, rowNumber); + } + } + catch (global::System.Text.Json.JsonException) + { + throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写"); + } + } + + private static string GetRequiredRowValue( + global::System.Text.Json.JsonElement row, + string propertyName, + int rowNumber, + string fieldName, + string displayName) + { + if (!row.TryGetProperty(propertyName, out var property) + || property.ValueKind is global::System.Text.Json.JsonValueKind.Null + or global::System.Text.Json.JsonValueKind.Undefined) + { + throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}"); + } + + var value = property.ValueKind == global::System.Text.Json.JsonValueKind.String + ? property.GetString() + : property.ToString(); + if (string.IsNullOrWhiteSpace(value)) + throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}"); + + return value.Trim(); + } + + private static void ValidateJson( + string json, + global::System.Text.Json.JsonValueKind expectedKind, + string fieldName) + { + if (string.IsNullOrWhiteSpace(json)) + return; + + try + { + using var document = global::System.Text.Json.JsonDocument.Parse(json); + if (document.RootElement.ValueKind != expectedKind) + throw new global::System.Text.Json.JsonException(); + } + catch (global::System.Text.Json.JsonException) + { + throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写"); + } + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs index 6339bca..f466262 100644 --- a/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs @@ -131,7 +131,7 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService throw Oops.Bah("当前任务不可编辑"); } - ValidateEmployeeInfo(input); + OnboardingEmployeeInfoValidator.ValidateAndNormalize(input); await _identityValidationService.EnsureNoDuplicate( task.Id, input.EmployeeName, @@ -170,46 +170,6 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService await _snapshotService.Build(task.Id, entity, invalidateGeneratedPreview: true); } - /// - /// 校验候选人核心身份信息,避免非法格式进入合同和正式员工档案。 - /// - private static void ValidateEmployeeInfo(OnboardingTaskPublicSaveInput input) - { - input.EmployeeName = input.EmployeeName?.Trim(); - input.Phone = input.Phone?.Trim(); - input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant(); - - if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100) - throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符"); - if (!global::System.Text.RegularExpressions.Regex.IsMatch( - input.Phone ?? string.Empty, - "^1[3-9]\\d{9}$")) - { - throw Oops.Bah("请输入正确的 11 位手机号码"); - } - - if (!global::System.Text.RegularExpressions.Regex.IsMatch( - input.IdCardNumber ?? string.Empty, - "^\\d{17}[0-9X]$")) - { - throw Oops.Bah("请输入正确的 18 位身份证号码"); - } - - if (string.IsNullOrWhiteSpace(input.CustomVariablesJson)) - return; - - try - { - using var document = global::System.Text.Json.JsonDocument.Parse(input.CustomVariablesJson); - if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Object) - throw new global::System.Text.Json.JsonException(); - } - catch (global::System.Text.Json.JsonException) - { - throw Oops.Bah("员工补充变量格式不正确,请刷新页面后重新填写"); - } - } - /// /// 根据员工当前已填写信息校验变量并提前生成合同 DOCX。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs b/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs index f04eaeb..ccb3938 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace SimpleAdmin.Application; /// @@ -47,6 +49,41 @@ public class OnboardingTaskCreateInput /// /// 业务备注。 /// + [Required(ErrorMessage = "人事备注不能为空")] + public string Remark { get; set; } +} + +/// +/// 批量复制入职任务参数。 +/// +public class OnboardingTaskCopyInput +{ + /// + /// 被复制的原入职任务 Id。 + /// + [IdNotNull(ErrorMessage = "原入职任务Id不能为空")] + public long SourceTaskId { get; set; } + + /// + /// 在原任务之外需要新建的任务数量。 + /// + public int Count { get; set; } + + /// + /// 每条新任务分别填写的人事备注。 + /// + public List Items { get; set; } = new(); +} + +/// +/// 单条复制任务的个性化参数。 +/// +public class OnboardingTaskCopyItemInput +{ + /// + /// 新任务的人事备注,用于区分候选人和专属链接。 + /// + [Required(ErrorMessage = "人事备注不能为空")] public string Remark { get; set; } } @@ -96,4 +133,14 @@ public class OnboardingTaskCreatedOutput /// 员工填写链接过期时间。 /// public DateTime ExpiresAt { get; set; } -} \ No newline at end of file +} +/// +/// 批量复制入职任务结果。 +/// +public class OnboardingTaskCopyOutput +{ + /// + /// 本次复制生成的全部新任务及独立链接信息。 + /// + public List Tasks { get; set; } = new(); +} diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/IOnboardingTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/Task/IOnboardingTaskService.cs index aae6734..303e497 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/IOnboardingTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/IOnboardingTaskService.cs @@ -9,4 +9,9 @@ public interface IOnboardingTaskService : ITransient /// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。 /// Task Create(OnboardingTaskCreateInput input); + + /// + /// 复制原任务的业务配置和人事参数,批量创建独立的新入职任务。 + /// + Task Copy(OnboardingTaskCopyInput input); } diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs index 834c44d..7112479 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs @@ -23,6 +23,9 @@ public class OnboardingTaskService : IOnboardingTaskService /// public async Task Create(OnboardingTaskCreateInput input) { + if (string.IsNullOrWhiteSpace(input.Remark)) + throw Oops.Bah("请填写人事备注"); + var companyEnabled = await _db.Queryable() .AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable"); if (!companyEnabled) @@ -121,7 +124,7 @@ public class OnboardingTaskService : IOnboardingTaskService AssignedReviewerUserId = input.AssignedReviewerUserId, LinkExpireAt = expiresAt, TaskStatus = OnboardingTaskStatus.PendingFill, - Remark = input.Remark + Remark = input.Remark.Trim() }; ValidateInitialContractDateRanges(input.ContractVariables); @@ -207,6 +210,230 @@ public class OnboardingTaskService : IOnboardingTaskService }; } + /// + /// 复制原任务锁定的业务配置与人事参数,并原子化创建多条独立任务。 + /// + public async Task Copy(OnboardingTaskCopyInput input) + { + const int maxCopyCount = 50; + if (input.Count < 1 || input.Count > maxCopyCount) + throw Oops.Bah($"复制数量必须在1到{maxCopyCount}之间"); + + input.Items ??= new List(); + if (input.Items.Count != input.Count) + throw Oops.Bah("复制数量与人事备注数量不一致"); + + var missingRemarkIndex = input.Items.FindIndex(item => + string.IsNullOrWhiteSpace(item.Remark)); + if (missingRemarkIndex >= 0) + throw Oops.Bah($"请填写第{missingRemarkIndex + 1}份任务的人事备注"); + + var sourceTask = await _db.Queryable() + .InSingleAsync(input.SourceTaskId) + ?? throw Oops.Bah("原入职任务不存在"); + if (!sourceTask.DepartmentId.HasValue) + throw Oops.Bah("原入职任务未设置部门,不能复制"); + + var companyEnabled = await _db.Queryable() + .AnyAsync(item => item.Id == sourceTask.CompanyId && item.Status == "enable"); + if (!companyEnabled) + throw Oops.Bah("原任务所属公司不存在或未启用"); + + var departmentEnabled = await _db.Queryable() + .AnyAsync(item => item.Id == sourceTask.DepartmentId.Value + && item.CompanyId == sourceTask.CompanyId + && item.Status == "enable"); + if (!departmentEnabled) + throw Oops.Bah("原任务所属部门不存在或未启用"); + + var positionEnabled = await _db.Queryable() + .AnyAsync(item => item.Id == sourceTask.PositionId + && item.CompanyId == sourceTask.CompanyId + && item.DepartmentId == sourceTask.DepartmentId.Value + && item.Status == "enable"); + if (!positionEnabled) + throw Oops.Bah("原任务所属岗位不存在或未启用"); + + var onboardingTemplateEnabled = await _db.Queryable() + .AnyAsync(item => item.Id == sourceTask.OnboardingTemplateId + && item.CompanyId == sourceTask.CompanyId + && item.DepartmentId == sourceTask.DepartmentId.Value + && item.Status == "enable"); + if (!onboardingTemplateEnabled) + throw Oops.Bah("原任务使用的入职模板不存在或未启用"); + + var sourceContracts = await _db.Queryable() + .Where(item => item.TaskId == sourceTask.Id) + .ToListAsync(); + if (sourceContracts.Count == 0) + throw Oops.Bah("原入职任务没有合同快照,不能复制"); + + var versionIds = sourceContracts + .Select(item => item.TemplateVersionId) + .Distinct() + .ToList(); + var enabledVersionCount = await _db.Queryable() + .CountAsync(item => versionIds.Contains(item.Id) + && item.VersionStatus == ContractTemplateVersionStatus.Enabled); + if (enabledVersionCount != versionIds.Count) + throw Oops.Bah("原任务包含已停用的合同版本,不能继续复制"); + + var sourceMaterialRequirements = await _db.Queryable() + .Where(item => item.TaskId == sourceTask.Id) + .OrderBy(item => item.SortCode) + .ToListAsync(); + var sourceContractIds = sourceContracts.Select(item => item.Id).ToList(); + var sourceManualVariables = await _db.Queryable() + .Where(item => sourceContractIds.Contains(item.TaskContractId) + && item.SourceType == ContractVariableSourceType.HrInput + && item.IsManualOverride) + .ToListAsync(); + + var initialContractVariables = ( + from contract in sourceContracts + join variable in sourceManualVariables + on contract.Id equals variable.TaskContractId + select new OnboardingInitialContractVariableInput + { + ContractTemplateVersionId = contract.TemplateVersionId, + VariableCode = variable.VariableCode, + VariableValue = variable.VariableValue + }).ToList(); + ValidateInitialContractDateRanges(initialContractVariables); + + var expiryConfig = await _configService.GetByConfigKey( + CateGoryConst.CONFIG_BIZ_DEFINE, + "onboard.link.expireHours"); + if (expiryConfig == null + || !int.TryParse(expiryConfig.ConfigValue, out var expiryHours) + || expiryHours <= 0) + { + throw Oops.Bah("请在系统配置中设置 onboard.link.expireHours(正整数小时)"); + } + + var outputs = new List(); + await _db.Ado.BeginTranAsync(); + try + { + foreach (var item in input.Items) + { + var output = await CreateCopiedTask( + sourceTask, + sourceContracts, + sourceMaterialRequirements, + initialContractVariables, + expiryHours, + item.Remark.Trim()); + outputs.Add(output); + } + + await _db.Ado.CommitTranAsync(); + } + catch + { + await _db.Ado.RollbackTranAsync(); + throw; + } + + return new OnboardingTaskCopyOutput + { + Tasks = outputs + }; + } + + /// + /// 根据原任务快照创建一条全新的待填写任务,不复制任何候选人业务数据。 + /// + private async Task CreateCopiedTask( + ObOnboardingTask sourceTask, + List sourceContracts, + List sourceMaterialRequirements, + List initialContractVariables, + int expiryHours, + string remark) + { + var now = DateTime.Now; + var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); + var expiresAt = now.AddHours(expiryHours); + var token = Guid.NewGuid().ToString("N"); + var task = new ObOnboardingTask + { + Id = taskId, + TaskNo = $"OB{now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}", + CompanyId = sourceTask.CompanyId, + DepartmentId = sourceTask.DepartmentId, + PositionId = sourceTask.PositionId, + OnboardingTemplateId = sourceTask.OnboardingTemplateId, + AssignedReviewerUserId = sourceTask.AssignedReviewerUserId, + LinkExpireAt = expiresAt, + TaskStatus = OnboardingTaskStatus.PendingFill, + Remark = remark + }; + + await _db.Insertable(task).ExecuteCommandAsync(); + await _db.Insertable(new ObTaskLink + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + TaskId = taskId, + PlainToken = token, + TokenHash = Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData( + global::System.Text.Encoding.UTF8.GetBytes(token))), + ExpiresAt = expiresAt + }).ExecuteCommandAsync(); + + await _db.Insertable(new ObTaskEmployeeInfo + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + TaskId = taskId, + Status = CommonStatusConst.ENABLE, + CreateTime = now + }).ExecuteCommandAsync(); + + var copiedContracts = sourceContracts.Select(source => new ObTaskContract + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + TaskId = taskId, + TemplateId = source.TemplateId, + TemplateVersionId = source.TemplateVersionId, + ContractName = source.ContractName, + ContractType = source.ContractType, + Required = source.Required, + PrintCopies = source.PrintCopies + }).ToList(); + await _db.Insertable(copiedContracts).ExecuteCommandAsync(); + + if (sourceMaterialRequirements.Count > 0) + { + var copiedRequirements = sourceMaterialRequirements.Select(source => + new ObTaskMaterialRequirement + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + TaskId = taskId, + MaterialTypeId = source.MaterialTypeId, + MaterialCode = source.MaterialCode, + MaterialName = source.MaterialName, + AllowedExtensions = source.AllowedExtensions, + MaxFileCount = source.MaxFileCount, + MaxFileSizeMb = source.MaxFileSizeMb, + Required = source.Required, + SortCode = source.SortCode + }).ToList(); + await _db.Insertable(copiedRequirements).ExecuteCommandAsync(); + } + + // 重新生成公司、系统和空员工变量,再覆盖原任务的人事参数。 + await _snapshotService.Build(taskId); + await ApplyInitialContractVariables(initialContractVariables, copiedContracts); + + return new OnboardingTaskCreatedOutput + { + TaskId = taskId, + TaskNo = task.TaskNo, + LinkToken = token, + ExpiresAt = expiresAt + }; + } + /// /// 校验并保存任务创建时填写的人事合同变量。 /// @@ -289,7 +516,7 @@ public class OnboardingTaskService : IOnboardingTaskService /// /// 校验人事创建任务时填写的合同日期先后关系。 /// - private static void ValidateInitialContractDateRanges( + internal static void ValidateInitialContractDateRanges( List inputs) { inputs ??= new List(); diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs index ae1fedd..fd00f16 100644 --- a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 入职任务分页查询参数。 @@ -24,6 +24,64 @@ public class OnboardingTaskPageInput : BasePageInput /// 入职业务部门 Id。 /// public long? DepartmentId { get; set; } + + /// + /// 入职业务岗位 Id。 + /// + public long? PositionId { get; set; } +} + +/// +/// 入职任务分页数据及当前查询条件下的状态统计。 +/// +public class OnboardingTaskPageOutput : SqlSugarPagedList +{ + /// + /// 不受状态筛选影响的任务状态统计。 + /// + public OnboardingTaskStatisticsOutput Statistics { get; set; } = new(); +} + +/// +/// 入职任务状态统计。 +/// +public class OnboardingTaskStatisticsOutput +{ + /// + /// 待候选人填写数量。 + /// + public int PendingFill { get; set; } + + /// + /// 待人事审核数量。 + /// + public int Reviewing { get; set; } + + /// + /// 已退回待修改数量。 + /// + public int Rejected { get; set; } + + /// + /// 审核通过或已归档数量。 + /// + public int Approved { get; set; } +} + +/// +/// 数据库按任务状态分组后的内部统计行。 +/// +public class OnboardingTaskStatusCountOutput +{ + /// + /// 任务状态。 + /// + public string TaskStatus { get; set; } + + /// + /// 该状态的任务数量。 + /// + public int Count { get; set; } } /// diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/IOnboardingTaskQueryService.cs b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/IOnboardingTaskQueryService.cs index 9d9371f..184106f 100644 --- a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/IOnboardingTaskQueryService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/IOnboardingTaskQueryService.cs @@ -8,7 +8,7 @@ public interface IOnboardingTaskQueryService : ITransient /// /// 分页查询后台入职任务。 /// - Task> Page(OnboardingTaskPageInput input); + Task Page(OnboardingTaskPageInput input); /// /// 查询任务基础信息、人员资料、合同、材料、表单值、链接和审核记录。 diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs index 6e8537a..217df88 100644 --- a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs @@ -17,22 +17,78 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService /// /// 分页查询后台入职任务。 /// - public async Task> Page(OnboardingTaskPageInput input) + public async Task Page(OnboardingTaskPageInput input) { + const string approvedGroupStatus = "approved_or_archived"; + var filterApprovedGroup = string.Equals( + input.TaskStatus, + approvedGroupStatus, + StringComparison.OrdinalIgnoreCase); + var list = await _db.Queryable() - .WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo), item => item.TaskNo.Contains(input.TaskNo)) - .WhereIF(!string.IsNullOrWhiteSpace(input.TaskStatus), item => item.TaskStatus == input.TaskStatus) - .WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value) - .WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value) + .WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo), + item => item.TaskNo.Contains(input.TaskNo)) + .WhereIF(!string.IsNullOrWhiteSpace(input.TaskStatus) && !filterApprovedGroup, + item => item.TaskStatus == input.TaskStatus) + .WhereIF(filterApprovedGroup, + item => item.TaskStatus == OnboardingTaskStatus.Approved + || item.TaskStatus == OnboardingTaskStatus.Archived) + .WhereIF(input.CompanyId.HasValue, + item => item.CompanyId == input.CompanyId.Value) + .WhereIF(input.DepartmentId.HasValue, + item => item.DepartmentId == input.DepartmentId.Value) + .WhereIF(input.PositionId.HasValue, + item => item.PositionId == input.PositionId.Value) .OrderByDescending(item => item.CreateTime) .ToPagedListAsync(input.PageNum, input.PageSize); if (list.List != null && list.List.Any()) - { await FillTaskMetadata(list.List); - } - return list; + // 统计继续应用组织、岗位和任务编号条件,但不受当前状态筛选影响。 + var statusRows = await _db.Queryable() + .WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo), + item => item.TaskNo.Contains(input.TaskNo)) + .WhereIF(input.CompanyId.HasValue, + item => item.CompanyId == input.CompanyId.Value) + .WhereIF(input.DepartmentId.HasValue, + item => item.DepartmentId == input.DepartmentId.Value) + .WhereIF(input.PositionId.HasValue, + item => item.PositionId == input.PositionId.Value) + .GroupBy(item => item.TaskStatus) + .Select(item => new OnboardingTaskStatusCountOutput + { + TaskStatus = item.TaskStatus, + Count = SqlFunc.AggregateCount(item.Id) + }) + .ToListAsync(); + var statusCountMap = statusRows.ToDictionary( + item => item.TaskStatus, + item => item.Count); + + return new OnboardingTaskPageOutput + { + PageNum = list.PageNum, + PageSize = list.PageSize, + Total = list.Total, + Pages = list.Pages, + List = list.List, + HasPrevPages = list.HasPrevPages, + HasNextPages = list.HasNextPages, + Statistics = new OnboardingTaskStatisticsOutput + { + PendingFill = statusCountMap.GetValueOrDefault( + OnboardingTaskStatus.PendingFill), + Reviewing = statusCountMap.GetValueOrDefault( + OnboardingTaskStatus.Reviewing), + Rejected = statusCountMap.GetValueOrDefault( + OnboardingTaskStatus.Rejected), + Approved = statusCountMap.GetValueOrDefault( + OnboardingTaskStatus.Approved) + + statusCountMap.GetValueOrDefault( + OnboardingTaskStatus.Archived) + } + }; } /// diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractDocumentController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractDocumentController.cs index 5ccc719..a88c4ce 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractDocumentController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractDocumentController.cs @@ -18,6 +18,23 @@ public class OnboardingContractDocumentController _service = service; } + /// + /// 创建入职任务前,按人事当前填写的参数下载 DOCX 合同预览。 + /// + [HttpPost("preview-before-create")] + [DisplayName("创建入职任务前预览合同")] + public async Task PreviewBeforeCreate( + [FromBody] OnboardingContractPreviewInput input) + { + var preview = await _service.PreviewBeforeCreate(input); + return new FileContentResult( + preview.FileBytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + { + FileDownloadName = preview.FileName + }; + } + /// /// 生成入职合同文件 /// diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTaskController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTaskController.cs index 6737266..fec03a2 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTaskController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTaskController.cs @@ -1,4 +1,5 @@ using System.ComponentModel; + namespace SimpleAdmin.Web.Core; /// @@ -25,4 +26,14 @@ public class OnboardingTaskController { return await _service.Create(input); } + + /// + /// 在原任务之外批量复制独立的新入职任务。 + /// + [HttpPost("copy")] + [DisplayName("复制入职任务")] + public async Task Copy([FromBody] OnboardingTaskCopyInput input) + { + return await _service.Copy(input); + } }