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);
+ }
}