Files
OnboardXAdmin/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs
T
2026-09-10 16:43:11 +08:00

415 lines
19 KiB
C#

namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职合同预览和归档 DOCX 文件生成能力。
/// </summary>
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;
}
/// <summary>
/// 在任务创建前生成指定合同的临时 DOCX,仅通过响应返回,不写文件表和 OSS。
/// </summary>
public async Task<OnboardingContractPreviewOutput> 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);
}
/// <summary>
/// 在任务创建前生成全部合同预览,并按模板顺序打包为 ZIP。
/// </summary>
public async Task<OnboardingContractPreviewOutput> 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"
};
}
/// <summary>
/// 校验预览上下文,并读取入职模板锁定的全部合同配置。
/// </summary>
private async Task<ContractPreviewContext> LoadPreviewContext(
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 contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
.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<ObContractTemplate>()
.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<ObContractTemplatePosition>()
.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<ObContractTemplateVersion>()
.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<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 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
};
}
/// <summary>
/// 根据一条合同配置和当前人事参数渲染一份 DOCX。
/// </summary>
private async Task<OnboardingContractPreviewOutput> 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<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>())
.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<string, string>(
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)
};
}
/// <summary>
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
/// </summary>
public async Task<long> Generate(OnboardingGenerateContractInput input)
{
var contract = await _db.Queryable<ObTaskContract>().InSingleAsync(input.TaskContractId)
?? throw Oops.Bah("任务合同不存在");
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(contract.TaskId)
?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
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<ObContractTemplateVersion>().InSingleAsync(contract.TemplateVersionId)
?? throw Oops.Bah("合同版本不存在");
var source = await _db.Queryable<SysFile>().InSingleAsync(version.SourceDocxFileId)
?? throw Oops.Bah("合同源文件不存在");
var definitions = await _db.Queryable<ObTemplateVariable>()
.Where(item => item.TemplateVersionId == version.Id)
.ToListAsync();
var variables = await _db.Queryable<ObTaskContractVariable>()
.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;
}
/// <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, 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";
}
/// <summary>
/// 创建任务前预览合同所需的已校验数据。
/// </summary>
private sealed class ContractPreviewContext
{
public ObCompany Company { get; init; }
public List<ObOnboardingTemplateContract> ContractConfigs { get; init; }
public List<ObContractTemplate> ContractTemplates { get; init; }
public List<ObContractTemplateVersion> Versions { get; init; }
}
}