458 lines
21 KiB
C#
458 lines
21 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 now = DateTime.Now;
|
|
var versionsValid = versions.Count == versionIds.Count
|
|
&& contractConfigs.All(config => versions.Any(version =>
|
|
version.Id == config.ContractTemplateVersionId
|
|
&& version.TemplateId == config.ContractTemplateId
|
|
&& ContractTemplateVersionValidity.IsAvailable(version, now)));
|
|
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)
|
|
{
|
|
return await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => GenerateCore(input));
|
|
}
|
|
|
|
private async Task<long> GenerateCore(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,
|
|
"入职任务");
|
|
|
|
return await GenerateCore(contract, task);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<long> GenerateForPublicAccess(long taskContractId, long taskId)
|
|
{
|
|
return await OnboardingConcurrency.RunTask(_db, taskId, () => GenerateForPublicAccessCore(taskContractId, taskId));
|
|
}
|
|
|
|
private async Task<long> GenerateForPublicAccessCore(long taskContractId, long taskId)
|
|
{
|
|
var contract = await _db.Queryable<ObTaskContract>()
|
|
.FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId)
|
|
?? throw Oops.Bah("合同不存在");
|
|
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
|
|
?? throw Oops.Bah("入职任务不存在");
|
|
|
|
return await GenerateCore(contract, task);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 执行已经完成调用方权限或公开链接归属校验的合同生成逻辑。
|
|
/// </summary>
|
|
private async Task<long> 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<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($"合同“{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<ObTaskEmployeeInfo>()
|
|
.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;
|
|
}
|
|
|
|
/// <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; }
|
|
}
|
|
}
|