namespace SimpleAdmin.Application; /// /// 实现入职合同预览和归档 DOCX 文件生成能力。 /// public class OnboardingContractDocumentService : IOnboardingContractDocumentService { private readonly SqlSugarScope _db; private readonly IFileService _fileService; public OnboardingContractDocumentService(IFileService fileService) { _db = DbContext.DB; _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。 /// public async Task Generate(OnboardingGenerateContractInput input) { var contract = await _db.Queryable().InSingleAsync(input.TaskContractId) ?? throw Oops.Bah("任务合同不存在"); var task = await _db.Queryable().InSingleAsync(contract.TaskId) ?? throw Oops.Bah("入职任务不存在"); var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill || task.TaskStatus == OnboardingTaskStatus.Rejected || task.TaskStatus == OnboardingTaskStatus.Reviewing || task.TaskStatus == OnboardingTaskStatus.Approved; 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($"合同变量“{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 name = $"{task.TaskNo}_{contract.ContractName}_{generatedAt:yyyyMMddHHmmssfff}.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) { 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"; } }