namespace SimpleAdmin.Application; /// /// 实现员工端入职资料提交业务能力。 /// public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService { private readonly SqlSugarScope _db; private readonly IOnboardingContractSnapshotService _snapshotService; public OnboardingPublicSubmissionService( IOnboardingContractSnapshotService snapshotService) { _db = DbContext.DB; _snapshotService = snapshotService; } /// /// 保存候选人填写的动态字段值。 /// public async Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input) { var task = await GetEditableTask(input.Token); var requirements = await GetEffectiveFormRequirements(task); var fieldIds = input.Values.Select(item => item.FieldId).Distinct().ToList(); var hasInapplicableField = fieldIds.Any(id => requirements.All(requirement => requirement.FieldId != id)); if (hasInapplicableField) throw Oops.Bah("填写字段不适用于当前公司或岗位"); var fields = await _db.Queryable() .Where(field => fieldIds.Contains(field.Id) && field.Status == "enable") .ToListAsync(); foreach (var value in input.Values) { var field = fields.FirstOrDefault(item => item.Id == value.FieldId) ?? throw Oops.Bah("填写字段不存在或已停用"); var old = await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id && item.FieldId == field.Id); var row = new ObTaskFormValue { Id = old?.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TaskId = task.Id, FieldId = field.Id, FieldCode = field.FieldCode, FieldName = field.FieldName, FieldValue = value.FieldValue, SubmitRound = task.SubmitCount + 1 }; if (old == null) { await _db.Insertable(row).ExecuteCommandAsync(); } else { await _db.Updateable(row).ExecuteCommandAsync(); } } } /// /// 关联候选人已经上传的系统文件与材料类型。 /// public async Task SaveMaterial(OnboardingSaveMaterialInput input) { var task = await GetEditableTask(input.Token); var requirements = await GetEffectiveMaterialRequirements(task); var materialIsApplicable = requirements.Any(requirement => requirement.MaterialTypeId == input.MaterialTypeId); if (!materialIsApplicable) throw Oops.Bah("资料类型不适用于当前公司或岗位"); var material = await _db.Queryable() .FirstAsync(item => item.Id == input.MaterialTypeId && item.Status == "enable") ?? throw Oops.Bah("资料类型不存在或已停用"); var fileExists = await _db.Queryable().AnyAsync(item => item.Id == input.FileId); if (!fileExists) throw Oops.Bah("上传文件不存在"); var materialCount = await _db.Queryable() .CountAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId); if (materialCount >= material.MaxFileCount) throw Oops.Bah("该资料已达到最大上传数量"); await _db.Insertable(new ObTaskMaterial { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TaskId = task.Id, MaterialTypeId = material.Id, MaterialName = material.MaterialName, FileId = input.FileId, SubmitRound = task.SubmitCount + 1, UploadedAt = DateTime.Now }).ExecuteCommandAsync(); } /// /// 记录候选人已阅读并确认指定任务合同。 /// public async Task ConfirmContract(OnboardingConfirmContractInput input) { var task = await GetEditableTask(input.Token); var contract = await _db.Queryable() .FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == task.Id) ?? throw Oops.Bah("合同不存在"); contract.ReadAt ??= DateTime.Now; contract.ConfirmedAt = DateTime.Now; contract.ContractStatus = TaskContractStatus.Confirmed; await _db.Updateable(contract).ExecuteCommandAsync(); } /// /// 校验全部必填资料后提交入职任务进入审核。 /// public async Task Submit(OnboardingSubmitInput input) { 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); if (basicInfoIncomplete) throw Oops.Bah("请先完整填写基本信息"); await _snapshotService.Build(task.Id, info); // 提交时统一完成动态字段、材料、合同变量和合同确认校验,保存阶段只负责暂存。 var requiredFields = (await GetEffectiveFormRequirements(task)) .Where(item => item.Required) .Select(item => item.FieldId) .ToList(); var values = await _db.Queryable() .Where(item => item.TaskId == task.Id) .ToListAsync(); var hasMissingRequiredField = requiredFields.Any( id => values.All(item => item.FieldId != id || string.IsNullOrWhiteSpace(item.FieldValue))); if (hasMissingRequiredField) throw Oops.Bah("仍有必填动态信息未填写"); var requiredMaterials = (await GetEffectiveMaterialRequirements(task)) .Where(item => item.Required) .Select(item => item.MaterialTypeId) .ToList(); var materials = await _db.Queryable() .Where(item => item.TaskId == task.Id) .ToListAsync(); var hasMissingRequiredMaterial = requiredMaterials.Any( id => materials.All(item => item.MaterialTypeId != id)); if (hasMissingRequiredMaterial) throw Oops.Bah("仍有必传资料未上传"); await EnsureRequiredContractVariables(task); var hasUnconfirmedRequiredContract = await _db.Queryable() .AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null); if (hasUnconfirmedRequiredContract) throw Oops.Bah("请确认阅读全部必签合同"); task.TaskStatus = OnboardingTaskStatus.Reviewing; task.SubmitCount++; task.LastSubmittedAt = DateTime.Now; task.RejectReason = null; await _db.Updateable(task).ExecuteCommandAsync(); await _db.Insertable(new ObTaskAudit { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TaskId = task.Id, AuditRound = task.SubmitCount, AuditAction = OnboardingAuditAction.Submit, AuditOpinion = "员工提交入职资料", SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new { info, values, materials }), AuditedAt = DateTime.Now }).ExecuteCommandAsync(); } /// /// 在保存文件前校验材料适用范围、扩展名、大小和数量。 /// public async Task ValidateMaterialUpload(OnboardingMaterialUploadValidationInput input) { var task = await GetEditableTask(input.Token); var requirements = await GetEffectiveMaterialRequirements(task); var materialIsApplicable = requirements.Any(requirement => requirement.MaterialTypeId == input.MaterialTypeId); if (!materialIsApplicable) throw Oops.Bah("资料类型不适用于当前公司或岗位"); var material = await _db.Queryable() .FirstAsync(item => item.Id == input.MaterialTypeId && item.Status == "enable") ?? throw Oops.Bah("资料类型不存在或已停用"); var suffix = Path.GetExtension(input.FileName).TrimStart('.').ToLowerInvariant(); var allowedExtensions = (material.AllowedExtensions ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(item => item.TrimStart('.').ToLowerInvariant()); var suffixNotAllowed = allowedExtensions.Any() && !allowedExtensions.Contains(suffix); if (suffixNotAllowed) throw Oops.Bah("该资料不允许上传此文件类型"); var fileTooLarge = material.MaxFileSizeMb.HasValue && input.FileLength > material.MaxFileSizeMb.Value * 1024L * 1024L; if (fileTooLarge) throw Oops.Bah("文件超过允许的大小"); var materialCount = await _db.Queryable() .CountAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId); if (materialCount >= material.MaxFileCount) throw Oops.Bah("该资料已达到最大上传数量"); } 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 async Task> GetEffectiveFormRequirements(ObOnboardingTask task) { var requirements = await _db.Queryable() .Where(item => item.CompanyId == task.CompanyId && (item.PositionId == null || item.PositionId == task.PositionId)) .ToListAsync(); return requirements .GroupBy(item => item.FieldId) .Select(group => group.FirstOrDefault(item => item.PositionId == task.PositionId) ?? group.First()) .ToList(); } private async Task> GetEffectiveMaterialRequirements(ObOnboardingTask task) { var requirements = await _db.Queryable() .Where(item => item.CompanyId == task.CompanyId && (item.PositionId == null || item.PositionId == task.PositionId)) .ToListAsync(); return requirements .GroupBy(item => item.MaterialTypeId) .Select(group => group.FirstOrDefault(item => item.PositionId == task.PositionId) ?? group.First()) .ToList(); } private async Task GetEditableTask(string token) { if (string.IsNullOrWhiteSpace(token)) throw Oops.Bah("入职链接 Token 不能为空"); var hash = Convert.ToHexString( global::System.Security.Cryptography.SHA256.HashData( global::System.Text.Encoding.UTF8.GetBytes(token))); var link = await _db.Queryable().FirstAsync(item => item.TokenHash == hash) ?? throw Oops.Bah("链接不存在"); var linkUnavailable = link.LinkStatus != OnboardingLinkStatus.Active || link.ExpiresAt <= DateTime.Now; if (linkUnavailable) throw Oops.Bah("链接已失效"); var task = await _db.Queryable().InSingleAsync(link.TaskId) ?? throw Oops.Bah("入职任务不存在"); var taskNotEditable = task.TaskStatus != OnboardingTaskStatus.PendingFill && task.TaskStatus != OnboardingTaskStatus.Rejected; if (taskNotEditable) throw Oops.Bah("当前任务不可编辑"); return task; } }