入职任务预览,入职任务cope,验证填入的信息
This commit is contained in:
+55
@@ -11,3 +11,58 @@ public class OnboardingGenerateContractInput
|
||||
[IdNotNull(ErrorMessage = "任务合同Id不能为空")]
|
||||
public long TaskContractId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 人事在创建入职任务前下载合同预览的参数。
|
||||
/// </summary>
|
||||
public class OnboardingContractPreviewInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 入职业务公司 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "公司Id不能为空")]
|
||||
public long CompanyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务部门 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "部门Id不能为空")]
|
||||
public long DepartmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入职岗位 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "岗位Id不能为空")]
|
||||
public long PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前选择的入职模板 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "入职模板Id不能为空")]
|
||||
public long OnboardingTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 人事当前在创建表单中填写的合同变量。
|
||||
/// </summary>
|
||||
public List<OnboardingInitialContractVariableInput> ContractVariables
|
||||
{
|
||||
get;
|
||||
set;
|
||||
} = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建任务前生成的临时 DOCX 预览文件。
|
||||
/// </summary>
|
||||
public class OnboardingContractPreviewOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// DOCX 文件内容,仅通过当前请求返回,不保存到文件表或 OSS。
|
||||
/// </summary>
|
||||
public byte[] FileBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 浏览器下载时使用的文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
}
|
||||
|
||||
+6
@@ -5,6 +5,12 @@ namespace SimpleAdmin.Application;
|
||||
/// </summary>
|
||||
public interface IOnboardingContractDocumentService : ITransient
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建入职任务前,根据人事当前填写的参数生成临时 DOCX 预览。
|
||||
/// </summary>
|
||||
Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
|
||||
OnboardingContractPreviewInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 根据已审核通过的任务合同生成并保存 DOCX 文件。
|
||||
/// </summary>
|
||||
|
||||
+197
@@ -14,6 +14,177 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
|
||||
_fileService = fileService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在任务创建前生成单份临时 DOCX,仅通过响应返回,不写文件表和 OSS。
|
||||
/// </summary>
|
||||
public async Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
|
||||
OnboardingContractPreviewInput input)
|
||||
{
|
||||
var company = await _db.Queryable<ObCompany>()
|
||||
.FirstAsync(item => item.Id == input.CompanyId
|
||||
&& item.Status == CommonStatusConst.ENABLE)
|
||||
?? throw Oops.Bah("公司不存在或未启用");
|
||||
|
||||
var departmentExists = await _db.Queryable<ObDepartment>()
|
||||
.AnyAsync(item => item.Id == input.DepartmentId
|
||||
&& item.CompanyId == input.CompanyId
|
||||
&& item.Status == CommonStatusConst.ENABLE);
|
||||
if (!departmentExists)
|
||||
throw Oops.Bah("部门不存在、未启用或不属于该公司");
|
||||
|
||||
var positionExists = await _db.Queryable<ObPosition>()
|
||||
.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<ObOnboardingTemplate>()
|
||||
.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<ObOnboardingTemplatePosition>()
|
||||
.AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id
|
||||
&& item.PositionId == input.PositionId);
|
||||
if (!supportsPosition)
|
||||
throw Oops.Bah("所选入职模板不适用于该岗位");
|
||||
|
||||
// 当前业务只预览入职模板中排序第一的合同,后续多合同可在此扩展。
|
||||
var contractConfig = await _db.Queryable<ObOnboardingTemplateContract>()
|
||||
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.FirstAsync()
|
||||
?? throw Oops.Bah("入职模板尚未配置合同文件");
|
||||
|
||||
var contractTemplate = await _db.Queryable<ObContractTemplate>()
|
||||
.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<ObContractTemplatePosition>()
|
||||
.AnyAsync(item => item.ContractTemplateId == contractTemplate.Id
|
||||
&& item.PositionId == input.PositionId);
|
||||
if (!contractSupportsPosition)
|
||||
throw Oops.Bah("合同模板不适用于当前岗位");
|
||||
|
||||
var version = await _db.Queryable<ObContractTemplateVersion>()
|
||||
.FirstAsync(item => item.Id == contractConfig.ContractTemplateVersionId
|
||||
&& item.TemplateId == contractTemplate.Id
|
||||
&& item.VersionStatus == ContractTemplateVersionStatus.Enabled)
|
||||
?? throw Oops.Bah("合同版本不存在、未启用或不属于当前合同模板");
|
||||
|
||||
var definitions = await _db.Queryable<ObTemplateVariable>()
|
||||
.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<OnboardingInitialContractVariableInput>();
|
||||
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<string, string>(
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
|
||||
/// </summary>
|
||||
@@ -86,4 +257,30 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
|
||||
await _db.Updateable(contract).ExecuteCommandAsync();
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断变量是否允许人事在发起任务前填写。
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成兼容 Windows 和 Linux 的合同预览下载文件名。
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
+17
-5
@@ -110,12 +110,13 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveValue(
|
||||
internal static string ResolveValue(
|
||||
ObTemplateVariable definition,
|
||||
ObTaskEmployeeInfo employeeInfo,
|
||||
IReadOnlyDictionary<string, string> 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;
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 校验任务合同变量的必填状态和员工动态变量数据类型。
|
||||
/// </summary>
|
||||
public interface IOnboardingContractVariableValidationService : ITransient
|
||||
{
|
||||
/// <summary>
|
||||
/// 校验指定任务的全部合同变量快照。
|
||||
/// </summary>
|
||||
Task Validate(long taskId);
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 统一校验任务合同变量,确保最终提交的数据可以安全用于合同生成。
|
||||
/// </summary>
|
||||
public class OnboardingContractVariableValidationService
|
||||
: IOnboardingContractVariableValidationService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
|
||||
public OnboardingContractVariableValidationService()
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Validate(long taskId)
|
||||
{
|
||||
var contracts = await _db.Queryable<ObTaskContract>()
|
||||
.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<ObTaskContractVariable>()
|
||||
.Where(item => contractIds.Contains(item.TaskContractId))
|
||||
.ToListAsync();
|
||||
var definitions = await _db.Queryable<ObTemplateVariable>()
|
||||
.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<string> 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
|
||||
};
|
||||
}
|
||||
}
|
||||
+11
-36
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -119,15 +122,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
{
|
||||
var task = await GetEditableTask(input.Token);
|
||||
var info = await _db.Queryable<ObTaskEmployeeInfo>()
|
||||
.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<ObTaskContract>()
|
||||
.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<ObTaskContract>()
|
||||
.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<ObTaskContractVariable>()
|
||||
.Where(item => contractIds.Contains(item.TaskContractId))
|
||||
.ToListAsync();
|
||||
var definitions = await _db.Queryable<ObTemplateVariable>()
|
||||
.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<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId)
|
||||
{
|
||||
return _db.Queryable<ObTaskMaterialRequirement>()
|
||||
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 统一校验并规范化员工端固定入职信息。
|
||||
/// </summary>
|
||||
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'
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 规范化文本,并校验固定登记表字段的格式。
|
||||
/// </summary>
|
||||
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<global::System.Text.Json.JsonElement, int> 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}格式不正确,请刷新页面后重新填写");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-41
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验候选人核心身份信息,避免非法格式进入合同和正式员工档案。
|
||||
/// </summary>
|
||||
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("员工补充变量格式不正确,请刷新页面后重新填写");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据员工当前已填写信息校验变量并提前生成合同 DOCX。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
@@ -47,6 +49,41 @@ public class OnboardingTaskCreateInput
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "人事备注不能为空")]
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量复制入职任务参数。
|
||||
/// </summary>
|
||||
public class OnboardingTaskCopyInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 被复制的原入职任务 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "原入职任务Id不能为空")]
|
||||
public long SourceTaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 在原任务之外需要新建的任务数量。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每条新任务分别填写的人事备注。
|
||||
/// </summary>
|
||||
public List<OnboardingTaskCopyItemInput> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单条复制任务的个性化参数。
|
||||
/// </summary>
|
||||
public class OnboardingTaskCopyItemInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 新任务的人事备注,用于区分候选人和专属链接。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "人事备注不能为空")]
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
|
||||
@@ -96,4 +133,14 @@ public class OnboardingTaskCreatedOutput
|
||||
/// 员工填写链接过期时间。
|
||||
/// </summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 批量复制入职任务结果。
|
||||
/// </summary>
|
||||
public class OnboardingTaskCopyOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 本次复制生成的全部新任务及独立链接信息。
|
||||
/// </summary>
|
||||
public List<OnboardingTaskCreatedOutput> Tasks { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -9,4 +9,9 @@ public interface IOnboardingTaskService : ITransient
|
||||
/// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。
|
||||
/// </summary>
|
||||
Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 复制原任务的业务配置和人事参数,批量创建独立的新入职任务。
|
||||
/// </summary>
|
||||
Task<OnboardingTaskCopyOutput> Copy(OnboardingTaskCopyInput input);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
/// </summary>
|
||||
public async Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Remark))
|
||||
throw Oops.Bah("请填写人事备注");
|
||||
|
||||
var companyEnabled = await _db.Queryable<ObCompany>()
|
||||
.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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制原任务锁定的业务配置与人事参数,并原子化创建多条独立任务。
|
||||
/// </summary>
|
||||
public async Task<OnboardingTaskCopyOutput> Copy(OnboardingTaskCopyInput input)
|
||||
{
|
||||
const int maxCopyCount = 50;
|
||||
if (input.Count < 1 || input.Count > maxCopyCount)
|
||||
throw Oops.Bah($"复制数量必须在1到{maxCopyCount}之间");
|
||||
|
||||
input.Items ??= new List<OnboardingTaskCopyItemInput>();
|
||||
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<ObOnboardingTask>()
|
||||
.InSingleAsync(input.SourceTaskId)
|
||||
?? throw Oops.Bah("原入职任务不存在");
|
||||
if (!sourceTask.DepartmentId.HasValue)
|
||||
throw Oops.Bah("原入职任务未设置部门,不能复制");
|
||||
|
||||
var companyEnabled = await _db.Queryable<ObCompany>()
|
||||
.AnyAsync(item => item.Id == sourceTask.CompanyId && item.Status == "enable");
|
||||
if (!companyEnabled)
|
||||
throw Oops.Bah("原任务所属公司不存在或未启用");
|
||||
|
||||
var departmentEnabled = await _db.Queryable<ObDepartment>()
|
||||
.AnyAsync(item => item.Id == sourceTask.DepartmentId.Value
|
||||
&& item.CompanyId == sourceTask.CompanyId
|
||||
&& item.Status == "enable");
|
||||
if (!departmentEnabled)
|
||||
throw Oops.Bah("原任务所属部门不存在或未启用");
|
||||
|
||||
var positionEnabled = await _db.Queryable<ObPosition>()
|
||||
.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<ObOnboardingTemplate>()
|
||||
.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<ObTaskContract>()
|
||||
.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<ObContractTemplateVersion>()
|
||||
.CountAsync(item => versionIds.Contains(item.Id)
|
||||
&& item.VersionStatus == ContractTemplateVersionStatus.Enabled);
|
||||
if (enabledVersionCount != versionIds.Count)
|
||||
throw Oops.Bah("原任务包含已停用的合同版本,不能继续复制");
|
||||
|
||||
var sourceMaterialRequirements = await _db.Queryable<ObTaskMaterialRequirement>()
|
||||
.Where(item => item.TaskId == sourceTask.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.ToListAsync();
|
||||
var sourceContractIds = sourceContracts.Select(item => item.Id).ToList();
|
||||
var sourceManualVariables = await _db.Queryable<ObTaskContractVariable>()
|
||||
.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<OnboardingTaskCreatedOutput>();
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据原任务快照创建一条全新的待填写任务,不复制任何候选人业务数据。
|
||||
/// </summary>
|
||||
private async Task<OnboardingTaskCreatedOutput> CreateCopiedTask(
|
||||
ObOnboardingTask sourceTask,
|
||||
List<ObTaskContract> sourceContracts,
|
||||
List<ObTaskMaterialRequirement> sourceMaterialRequirements,
|
||||
List<OnboardingInitialContractVariableInput> 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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验并保存任务创建时填写的人事合同变量。
|
||||
/// </summary>
|
||||
@@ -289,7 +516,7 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
/// <summary>
|
||||
/// 校验人事创建任务时填写的合同日期先后关系。
|
||||
/// </summary>
|
||||
private static void ValidateInitialContractDateRanges(
|
||||
internal static void ValidateInitialContractDateRanges(
|
||||
List<OnboardingInitialContractVariableInput> inputs)
|
||||
{
|
||||
inputs ??= new List<OnboardingInitialContractVariableInput>();
|
||||
|
||||
+59
-1
@@ -1,4 +1,4 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务分页查询参数。
|
||||
@@ -24,6 +24,64 @@ public class OnboardingTaskPageInput : BasePageInput
|
||||
/// 入职业务部门 Id。
|
||||
/// </summary>
|
||||
public long? DepartmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务岗位 Id。
|
||||
/// </summary>
|
||||
public long? PositionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务分页数据及当前查询条件下的状态统计。
|
||||
/// </summary>
|
||||
public class OnboardingTaskPageOutput : SqlSugarPagedList<ObOnboardingTask>
|
||||
{
|
||||
/// <summary>
|
||||
/// 不受状态筛选影响的任务状态统计。
|
||||
/// </summary>
|
||||
public OnboardingTaskStatisticsOutput Statistics { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务状态统计。
|
||||
/// </summary>
|
||||
public class OnboardingTaskStatisticsOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 待候选人填写数量。
|
||||
/// </summary>
|
||||
public int PendingFill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待人事审核数量。
|
||||
/// </summary>
|
||||
public int Reviewing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已退回待修改数量。
|
||||
/// </summary>
|
||||
public int Rejected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过或已归档数量。
|
||||
/// </summary>
|
||||
public int Approved { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据库按任务状态分组后的内部统计行。
|
||||
/// </summary>
|
||||
public class OnboardingTaskStatusCountOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务状态。
|
||||
/// </summary>
|
||||
public string TaskStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 该状态的任务数量。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ public interface IOnboardingTaskQueryService : ITransient
|
||||
/// <summary>
|
||||
/// 分页查询后台入职任务。
|
||||
/// </summary>
|
||||
Task<SqlSugarPagedList<ObOnboardingTask>> Page(OnboardingTaskPageInput input);
|
||||
Task<OnboardingTaskPageOutput> Page(OnboardingTaskPageInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 查询任务基础信息、人员资料、合同、材料、表单值、链接和审核记录。
|
||||
|
||||
+64
-8
@@ -17,22 +17,78 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
|
||||
/// <summary>
|
||||
/// 分页查询后台入职任务。
|
||||
/// </summary>
|
||||
public async Task<SqlSugarPagedList<ObOnboardingTask>> Page(OnboardingTaskPageInput input)
|
||||
public async Task<OnboardingTaskPageOutput> Page(OnboardingTaskPageInput input)
|
||||
{
|
||||
const string approvedGroupStatus = "approved_or_archived";
|
||||
var filterApprovedGroup = string.Equals(
|
||||
input.TaskStatus,
|
||||
approvedGroupStatus,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var list = await _db.Queryable<ObOnboardingTask>()
|
||||
.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<ObOnboardingTask>()
|
||||
.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)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+17
@@ -18,6 +18,23 @@ public class OnboardingContractDocumentController
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建入职任务前,按人事当前填写的参数下载 DOCX 合同预览。
|
||||
/// </summary>
|
||||
[HttpPost("preview-before-create")]
|
||||
[DisplayName("创建入职任务前预览合同")]
|
||||
public async Task<FileContentResult> PreviewBeforeCreate(
|
||||
[FromBody] OnboardingContractPreviewInput input)
|
||||
{
|
||||
var preview = await _service.PreviewBeforeCreate(input);
|
||||
return new FileContentResult(
|
||||
preview.FileBytes,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||||
{
|
||||
FileDownloadName = preview.FileName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成入职合同文件
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace SimpleAdmin.Web.Core;
|
||||
|
||||
/// <summary>
|
||||
@@ -25,4 +26,14 @@ public class OnboardingTaskController
|
||||
{
|
||||
return await _service.Create(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在原任务之外批量复制独立的新入职任务。
|
||||
/// </summary>
|
||||
[HttpPost("copy")]
|
||||
[DisplayName("复制入职任务")]
|
||||
public async Task<dynamic> Copy([FromBody] OnboardingTaskCopyInput input)
|
||||
{
|
||||
return await _service.Copy(input);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user