namespace SimpleAdmin.Application; /// /// 实现入职合同预览和归档 DOCX 文件生成能力。 /// public class OnboardingContractDocumentService : IOnboardingContractDocumentService { private readonly SqlSugarScope _db; private readonly IFileService _fileService; private readonly IOnboardingDataPermissionService _dataPermission; public OnboardingContractDocumentService( IFileService fileService, IOnboardingDataPermissionService dataPermission) { _db = DbContext.DB; _fileService = fileService; _dataPermission = dataPermission; } /// /// 在任务创建前生成指定合同的临时 DOCX,仅通过响应返回,不写文件表和 OSS。 /// public async Task PreviewBeforeCreate( OnboardingContractPreviewInput input) { var context = await LoadPreviewContext(input); var contractConfig = input.ContractTemplateVersionId.HasValue ? context.ContractConfigs.FirstOrDefault(item => item.ContractTemplateVersionId == input.ContractTemplateVersionId.Value) : context.ContractConfigs.FirstOrDefault(); if (contractConfig == null) throw Oops.Bah("所选合同不属于当前入职模板或已被移除"); return await RenderPreview(input, context, contractConfig, null); } /// /// 在任务创建前生成全部合同预览,并按模板顺序打包为 ZIP。 /// public async Task PreviewAllBeforeCreate( OnboardingContractPreviewInput input) { var context = await LoadPreviewContext(input); using var zipBytes = new MemoryStream(); using (var archive = new global::System.IO.Compression.ZipArchive( zipBytes, global::System.IO.Compression.ZipArchiveMode.Create, true)) { for (var index = 0; index < context.ContractConfigs.Count; index++) { var preview = await RenderPreview( input, context, context.ContractConfigs[index], index + 1); var entry = archive.CreateEntry( preview.FileName, global::System.IO.Compression.CompressionLevel.Fastest); await using var entryStream = entry.Open(); await entryStream.WriteAsync(preview.FileBytes); } } return new OnboardingContractPreviewOutput { FileBytes = zipBytes.ToArray(), FileName = $"全部合同_人事预览_{DateTime.Now:yyyyMMddHHmmss}.zip" }; } /// /// 校验预览上下文,并读取入职模板锁定的全部合同配置。 /// private async Task LoadPreviewContext( 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 contractConfigs = await _db.Queryable() .Where(item => item.OnboardingTemplateId == onboardingTemplate.Id) .OrderBy(item => item.SortCode) .OrderBy(item => item.Id) .ToListAsync(); if (contractConfigs.Count == 0) throw Oops.Bah("入职模板尚未配置合同文件"); var duplicateVersion = contractConfigs .GroupBy(item => item.ContractTemplateVersionId) .FirstOrDefault(group => group.Count() > 1); if (duplicateVersion != null) throw Oops.Bah("入职模板中存在重复的合同版本,请先调整模板配置"); var templateIds = contractConfigs .Select(item => item.ContractTemplateId) .Distinct() .ToList(); var contractTemplates = await _db.Queryable() .Where(item => templateIds.Contains(item.Id)) .ToListAsync(); if (contractTemplates.Count != templateIds.Count || contractTemplates.Any(item => item.CompanyId != input.CompanyId || item.DepartmentId != input.DepartmentId || !string.Equals( item.Status, CommonStatusConst.ENABLE, global::System.StringComparison.OrdinalIgnoreCase))) { throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板"); } var supportedTemplateCount = await _db.Queryable() .CountAsync(item => templateIds.Contains(item.ContractTemplateId) && item.PositionId == input.PositionId); if (supportedTemplateCount != templateIds.Count) throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板"); var versionIds = contractConfigs .Select(item => item.ContractTemplateVersionId) .ToList(); var versions = await _db.Queryable() .Where(item => versionIds.Contains(item.Id)) .ToListAsync(); var versionsValid = versions.Count == versionIds.Count && contractConfigs.All(config => versions.Any(version => version.Id == config.ContractTemplateVersionId && version.TemplateId == config.ContractTemplateId && version.VersionStatus == ContractTemplateVersionStatus.Enabled)); if (!versionsValid) throw Oops.Bah("入职模板包含不存在、未启用或归属错误的合同版本"); 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 unknownVersionInput = inputs.FirstOrDefault(item => !versionIds.Contains(item.ContractTemplateVersionId)); if (unknownVersionInput != null) throw Oops.Bah($"合同变量“{unknownVersionInput.VariableCode}”不属于当前入职模板"); return new ContractPreviewContext { Company = company, ContractConfigs = contractConfigs, ContractTemplates = contractTemplates, Versions = versions }; } /// /// 根据一条合同配置和当前人事参数渲染一份 DOCX。 /// private async Task RenderPreview( OnboardingContractPreviewInput input, ContractPreviewContext context, ObOnboardingTemplateContract contractConfig, int? fileOrder) { var contractTemplate = context.ContractTemplates.First(item => item.Id == contractConfig.ContractTemplateId); var version = context.Versions.First(item => item.Id == contractConfig.ContractTemplateVersionId); 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()) .Where(item => item.ContractTemplateVersionId == version.Id) .ToList(); var editableDefinitions = definitions .Where(IsHrEditableDefinition) .ToList(); foreach (var submitted in inputs) { var definitionExists = 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 => 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 => string.Equals( item.VariableCode, definition.VariableCode, StringComparison.OrdinalIgnoreCase)); if (submitted != null) return submitted.VariableValue ?? string.Empty; return OnboardingContractSnapshotService.ResolveValue( definition, null, emptyDynamicValues, context.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, fileOrder) }; } /// /// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。 /// public async Task Generate(OnboardingGenerateContractInput input) { return await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => GenerateCore(input)); } private async Task GenerateCore(OnboardingGenerateContractInput input) { var contract = await _db.Queryable().InSingleAsync(input.TaskContractId) ?? throw Oops.Bah("任务合同不存在"); var task = await _db.Queryable().InSingleAsync(contract.TaskId) ?? throw Oops.Bah("入职任务不存在"); await _dataPermission.EnsureCanManage( task.CreateUserId, OnboardingPermissionCodes.TaskManageAll, "入职任务"); return await GenerateCore(contract, task); } /// public async Task GenerateForPublicAccess(long taskContractId, long taskId) { return await OnboardingConcurrency.RunTask(_db, taskId, () => GenerateForPublicAccessCore(taskContractId, taskId)); } private async Task GenerateForPublicAccessCore(long taskContractId, long taskId) { var contract = await _db.Queryable() .FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId) ?? throw Oops.Bah("合同不存在"); var task = await _db.Queryable().InSingleAsync(taskId) ?? throw Oops.Bah("入职任务不存在"); return await GenerateCore(contract, task); } /// /// 执行已经完成调用方权限或公开链接归属校验的合同生成逻辑。 /// private async Task GenerateCore(ObTaskContract contract, ObOnboardingTask task) { var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill || task.TaskStatus == OnboardingTaskStatus.Rejected || task.TaskStatus == OnboardingTaskStatus.Reviewing || task.TaskStatus == OnboardingTaskStatus.Approved || task.TaskStatus == OnboardingTaskStatus.Archived; if (!statusAllowsGeneration) throw Oops.Bah("当前任务状态不能生成合同"); if (contract.GeneratedDocxFileId.HasValue) return contract.GeneratedDocxFileId.Value; var version = await _db.Queryable().InSingleAsync(contract.TemplateVersionId) ?? throw Oops.Bah("合同版本不存在"); var source = await _db.Queryable().InSingleAsync(version.SourceDocxFileId) ?? throw Oops.Bah("合同源文件不存在"); var definitions = await _db.Queryable() .Where(item => item.TemplateVersionId == version.Id) .ToListAsync(); var variables = await _db.Queryable() .Where(item => item.TaskContractId == contract.Id) .ToListAsync(); var duplicateVariable = variables .GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(group => group.Count() > 1); if (duplicateVariable != null) { throw Oops.Bah( $"合同变量快照“{duplicateVariable.Key}”存在重复数据,请联系管理员修复后重试"); } var missing = definitions .Where(item => item.Required) .FirstOrDefault(definition => string.IsNullOrWhiteSpace( variables.FirstOrDefault(variable => variable.VariableCode == definition.VariableCode)?.VariableValue)); if (missing != null) throw Oops.Bah($"合同“{contract.ContractName}”的变量“{missing.VariableName}”尚未填写,暂时不能预览合同"); var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id }) ?? throw Oops.Bah("无法读取合同源文件"); await using var sourceStream = sourceResult.FileStream; using var sourceBytes = new MemoryStream(); await sourceStream.CopyToAsync(sourceBytes); var rendered = DocxTemplateProcessor.Render( sourceBytes.ToArray(), variables.ToDictionary( item => item.VariableCode, item => item.VariableValue ?? string.Empty, StringComparer.OrdinalIgnoreCase)); var generatedAt = DateTime.Now; var employeeInfo = await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id); var employeeName = string.IsNullOrWhiteSpace(employeeInfo?.EmployeeName) ? "新员工" : employeeInfo.EmployeeName.Trim(); var docType = string.IsNullOrWhiteSpace(contract.ContractType) ? (string.IsNullOrWhiteSpace(contract.ContractName) ? "签署材料" : contract.ContractName.Trim()) : contract.ContractType.Trim(); var invalidChars = global::System.IO.Path.GetInvalidFileNameChars(); var safeEmployeeName = new string(employeeName.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray()); var safeDocType = new string(docType.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray()); // 按照规范:编号_员工姓名_文档类型.docx var name = $"{task.TaskNo}_{safeEmployeeName}_{safeDocType}.docx"; var fileId = await _fileService.StoreGeneratedFile( SysDictConst.FILE_ENGINE_ALIYUN_OSS, name, rendered); contract.GeneratedDocxFileId = fileId; contract.GeneratedAt = generatedAt; contract.ContractStatus = task.TaskStatus == OnboardingTaskStatus.Approved ? TaskContractStatus.OfflinePendingSign : TaskContractStatus.PendingConfirm; 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, int? fileOrder = null) { var invalidChars = global::System.IO.Path.GetInvalidFileNameChars(); var safeName = new string((contractName ?? "合同") .Select(character => invalidChars.Contains(character) ? '_' : character) .ToArray()); var orderPrefix = fileOrder.HasValue ? $"{fileOrder.Value:D2}_" : string.Empty; return $"{orderPrefix}{safeName}_人事预览_{DateTime.Now:yyyyMMddHHmmss}.docx"; } /// /// 创建任务前预览合同所需的已校验数据。 /// private sealed class ContractPreviewContext { public ObCompany Company { get; init; } public List ContractConfigs { get; init; } public List ContractTemplates { get; init; } public List Versions { get; init; } } }