namespace SimpleAdmin.Application; /// /// 合同模板主档案;具体 DOCX 文件、变量与适用范围均挂在此档案下。 /// public class OnboardingContractTemplateService : DbRepository, IOnboardingContractTemplateService { private readonly IFileService _fileService; private readonly IOnboardingDataPermissionService _dataPermission; public OnboardingContractTemplateService( IFileService fileService, IOnboardingDataPermissionService dataPermission) { _fileService = fileService; _dataPermission = dataPermission; } /// /// 分页查询合同模板主档案。 /// public async Task> Page(OnboardingContractTemplatePageInput input) { var query = Context.Queryable() .WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value) .WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value) .WhereIF( !string.IsNullOrWhiteSpace(input.TemplateName), it => it.TemplateName.Contains(input.TemplateName) || it.TemplateCode.Contains(input.TemplateName)) .WhereIF(!string.IsNullOrWhiteSpace(input.ContractType), it => it.ContractType == input.ContractType) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status); if (input.PositionId.HasValue) { var templateIds = await Context.Queryable() .Where(item => item.PositionId == input.PositionId.Value) .Select(item => item.ContractTemplateId) .ToListAsync(); query = query.Where(item => templateIds.Contains(item.Id)); } var page = await query .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "TemplateCode", "TemplateName", "ContractType")) .OrderByDescending(it => it.CreateTime) .OrderBy(it => it.TemplateName) .ToPagedListAsync(input.PageNum, input.PageSize); await PopulateScopeDisplay(page.List.ToList()); return page; } /// /// 查询合同模板详情。 /// public async Task Detail(BaseIdInput input) { var template = await GetByIdAsync(input.Id) ?? throw Oops.Bah("合同模板不存在"); await PopulateScopeDisplay(new List { template }); return template; } /// /// 查询同时覆盖全部已选岗位的启用合同模板。 /// public async Task> ListAvailable( OnboardingContractTemplateAvailableInput input) { input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List(); if (input.PositionIds.Count == 0) return new List(); await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds); var candidates = await Context.Queryable() .Where(item => item.CompanyId == input.CompanyId && item.DepartmentId == input.DepartmentId && item.Status == "enable") .OrderBy(item => item.TemplateName) .ToListAsync(); if (candidates.Count == 0) return candidates; var candidateIds = candidates.Select(item => item.Id).ToList(); var relations = await Context.Queryable() .Where(item => candidateIds.Contains(item.ContractTemplateId)) .ToListAsync(); var availableIds = relations .GroupBy(item => item.ContractTemplateId) .Where(group => input.PositionIds.All(positionId => group.Any(item => item.PositionId == positionId))) .Select(group => group.Key) .ToHashSet(); var availableTemplates = candidates .Where(item => availableIds.Contains(item.Id)) .ToList(); await PopulateScopeDisplay(availableTemplates); return availableTemplates; } /// /// 新增合同模板主档案。 /// public async Task Add(OnboardingContractTemplateAddInput input) { NormalizeInput(input); await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds); await EnsureCodeUnique(input.TemplateCode, 0); var templateId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); var entity = BuildEntity(templateId, input); await Context.Ado.BeginTranAsync(); try { await Context.Insertable(entity).ExecuteCommandAsync(); await InsertPositionRelations(templateId, input.PositionIds); await Context.Ado.CommitTranAsync(); } catch { await Context.Ado.RollbackTranAsync(); throw; } } /// /// 编辑合同模板主档案。 /// public async Task Edit(OnboardingContractTemplateEditInput input) { var template = await Detail(new BaseIdInput { Id = input.Id }); await _dataPermission.EnsureCanManage( template.CreateUserId, OnboardingPermissionCodes.ContractManageAll, "合同模板"); NormalizeInput(input); await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds); await ValidateReferencedOnboardingTemplates(input); await EnsureCodeUnique(input.TemplateCode, input.Id); var entity = BuildEntity(input.Id, input); await Context.Ado.BeginTranAsync(); try { await Context.Updateable(entity) .IgnoreColumns(ignoreAllNullColumns: true) .ExecuteCommandAsync(); await Context.Deleteable() .Where(item => item.ContractTemplateId == input.Id) .ExecuteCommandAsync(); await InsertPositionRelations(input.Id, input.PositionIds); await Context.Ado.CommitTranAsync(); } catch { await Context.Ado.RollbackTranAsync(); throw; } } /// /// 删除未被入职模板或任务合同引用的模板(级联删除未使用的版本和变量)。 /// public async Task Delete(BaseIdListInput input) { if (input.Ids.Count == 0) return; var templates = await Context.Queryable() .Where(item => input.Ids.Contains(item.Id)) .ToListAsync(); if (templates.Count != input.Ids.Distinct().Count()) throw Oops.Bah("合同模板不存在"); await _dataPermission.EnsureCanManage( templates.Select(item => item.CreateUserId), OnboardingPermissionCodes.ContractManageAll, "合同模板"); var hasOnboardingTemplates = await Context.Queryable() .AnyAsync(it => input.Ids.Contains(it.ContractTemplateId)); if (hasOnboardingTemplates) throw Oops.Bah("模板已被入职模板引用,不能删除,请先在入职模板中移除关联"); var hasContracts = await Context.Queryable() .AnyAsync(it => input.Ids.Contains(it.TemplateId)); if (hasContracts) throw Oops.Bah("模板已生成入职任务合同,不能删除,可改为停用"); var versionIds = await Context.Queryable() .Where(it => input.Ids.Contains(it.TemplateId)) .Select(it => it.Id) .ToListAsync(); await Context.Ado.BeginTranAsync(); try { if (versionIds.Count > 0) { await Context.Deleteable() .Where(it => versionIds.Contains(it.TemplateVersionId)) .ExecuteCommandAsync(); await Context.Deleteable() .In(versionIds) .ExecuteCommandAsync(); } await Context.Deleteable() .Where(item => input.Ids.Contains(item.ContractTemplateId)) .ExecuteCommandAsync(); await DeleteByIdsAsync(input.Ids.Cast().ToArray()); await Context.Ado.CommitTranAsync(); } catch { await Context.Ado.RollbackTranAsync(); throw; } } /// /// 删除未被入职模板或任务使用的合同版本。 /// public async Task DeleteVersion(BaseIdInput input) { var version = await Context.Queryable().InSingleAsync(input.Id) ?? throw Oops.Bah("合同版本不存在"); await EnsureTemplateCanManage(version.TemplateId); var isUsedInOnboardingTemplate = await Context.Queryable() .AnyAsync(it => it.ContractTemplateVersionId == version.Id); if (isUsedInOnboardingTemplate) throw Oops.Bah("该版本已被入职模板引用,不能删除,请先在入职模板中移除关联"); var isUsedInTask = await Context.Queryable() .AnyAsync(it => it.TemplateVersionId == version.Id); if (isUsedInTask) throw Oops.Bah("该版本已用于实际入职任务,不能删除,可改为停用"); await Context.Ado.BeginTranAsync(); try { await Context.Deleteable() .Where(it => it.TemplateVersionId == version.Id) .ExecuteCommandAsync(); await Context.Deleteable() .In(version.Id) .ExecuteCommandAsync(); await Context.Ado.CommitTranAsync(); } catch { await Context.Ado.RollbackTranAsync(); throw; } } /// /// 分页查询指定合同模板的版本。 /// public async Task> VersionPage(OnboardingContractVersionPageInput input) { return await Context.Queryable() .Where(it => it.TemplateId == input.TemplateId) .WhereIF(!string.IsNullOrWhiteSpace(input.VersionStatus), it => it.VersionStatus == input.VersionStatus) .OrderByDescending(it => it.CreateTime) .ToPagedListAsync(input.PageNum, input.PageSize); } /// /// 新增合同模板 DOCX 版本。 /// public async Task AddVersion(OnboardingContractVersionAddInput input) { var template = await Detail(new BaseIdInput { Id = input.TemplateId }); await _dataPermission.EnsureCanManage( template.CreateUserId, OnboardingPermissionCodes.ContractManageAll, "合同模板"); var file = await Context.Queryable().InSingleAsync(input.SourceDocxFileId) ?? throw Oops.Bah("DOCX 源文件不存在"); if (!string.Equals(file.Suffix, "docx", StringComparison.OrdinalIgnoreCase)) throw Oops.Bah("合同模板仅支持 DOCX 文件"); var versionExists = await Context.Queryable() .AnyAsync(item => item.TemplateId == input.TemplateId && item.VersionNo == input.VersionNo); if (versionExists) throw Oops.Bah("该模板版本号已存在"); var sourceResult = await _fileService.Download(new BaseIdInput { Id = file.Id }) ?? throw Oops.Bah("无法读取 DOCX 源文件"); await using var sourceStream = sourceResult.FileStream; using var sourceBytes = new MemoryStream(); await sourceStream.CopyToAsync(sourceBytes); var bytes = sourceBytes.ToArray(); var variableCodes = DocxTemplateProcessor.ExtractVariableCodes(bytes); // 严格比对变量字典库,防止手误和非法占位符 var dictList = await Context.Queryable().ToListAsync(); var duplicateDefinition = dictList .GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(group => group.Count() > 1); if (duplicateDefinition != null) { throw Oops.Bah( $"变量字典编码“{duplicateDefinition.Key}”存在重复数据,请先清理后再导入合同版本"); } var dictMap = dictList.ToDictionary(it => it.VariableCode, it => it, StringComparer.OrdinalIgnoreCase); var undefinedCodes = variableCodes.Where(code => !dictMap.ContainsKey(code)).ToList(); if (undefinedCodes.Count > 0) { throw Oops.Bah($"DOCX 模板包含未在字典库中注册的占位符:{string.Join(",", undefinedCodes)}。请检查拼写或先在【变量字典】中添加该变量。"); } var versionId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); var version = input.Adapt(); version.Id = versionId; version.VersionStatus = ContractTemplateVersionStatus.Draft; version.FileHash = string.IsNullOrWhiteSpace(input.FileHash) ? Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(bytes)) : input.FileHash; var variables = variableCodes.Select((code, index) => { var def = dictMap.TryGetValue(code, out var d) ? d : null; return BuildDetectedVariable(versionId, code, def, index); }).ToList(); await Context.Ado.BeginTranAsync(); try { await Context.Insertable(version).ExecuteCommandAsync(); if (variables.Count > 0) await Context.Insertable(variables).ExecuteCommandAsync(); await Context.Ado.CommitTranAsync(); } catch { await Context.Ado.RollbackTranAsync(); throw; } } /// /// 启用或停用合同模板版本。 /// public async Task SetVersionStatus(OnboardingContractVersionStatusInput input) { if (input.VersionStatus != ContractTemplateVersionStatus.Enabled && input.VersionStatus != ContractTemplateVersionStatus.Disabled) { throw Oops.Bah("版本状态仅支持启用或停用"); } var version = await Context.Queryable().InSingleAsync(input.Id) ?? throw Oops.Bah("模板版本不存在"); await EnsureTemplateCanManage(version.TemplateId); if (input.VersionStatus == ContractTemplateVersionStatus.Enabled) { var hasUnresolvedVariables = await Context.Queryable() .AnyAsync(item => item.TemplateVersionId == input.Id && item.VariableType == ContractVariableType.Unresolved); if (hasUnresolvedVariables) throw Oops.Bah("仍有未识别来源的合同变量,请逐项确认后再启用"); } version.VersionStatus = input.VersionStatus; await Context.Updateable(version).ExecuteCommandAsync(); } /// /// 分页查询指定模板版本的变量定义。 /// public async Task> VariablePage(OnboardingTemplateVariablePageInput input) { return await Context.Queryable() .Where(it => it.TemplateVersionId == input.TemplateVersionId) .OrderBy(it => it.SortCode) .OrderBy(it => it.VariableCode) .ToPagedListAsync(input.PageNum, input.PageSize); } /// /// 编辑模板版本中自动识别出的变量,不允许修改 DOCX 占位符编码。 /// public async Task EditVariable(OnboardingTemplateVariableEditInput input) { var variable = await Context.Queryable().InSingleAsync(input.Id) ?? throw Oops.Bah("模板变量不存在"); if (variable.TemplateVersionId != input.TemplateVersionId) throw Oops.Bah("不允许变更变量所属版本"); if (!string.Equals(variable.VariableCode, input.VariableCode, StringComparison.Ordinal)) throw Oops.Bah("DOCX 占位符编码由系统识别,不能修改"); var version = await Context.Queryable() .InSingleAsync(variable.TemplateVersionId) ?? throw Oops.Bah("模板版本不存在"); await EnsureTemplateCanManage(version.TemplateId); var isUsedInTask = await Context.Queryable() .AnyAsync(it => it.TemplateVersionId == input.TemplateVersionId); if (isUsedInTask) throw Oops.Bah("该版本已生成实际入职任务合同,变量配置已锁定"); var entity = input.Adapt(); entity.VariableCode = variable.VariableCode; await Context.Updateable(entity).ExecuteCommandAsync(); } private async Task EnsureTemplateCanManage(long templateId) { var template = await Context.Queryable().InSingleAsync(templateId) ?? throw Oops.Bah("合同模板不存在"); await _dataPermission.EnsureCanManage( template.CreateUserId, OnboardingPermissionCodes.ContractManageAll, "合同模板"); } private static ObTemplateVariable BuildDetectedVariable(long versionId, string code, ObVariableDefinition def, int index) { var separatorIndex = code.IndexOf('.'); var prefix = separatorIndex > 0 ? code[..separatorIndex].ToLowerInvariant() : string.Empty; var sourceField = separatorIndex > 0 ? code[(separatorIndex + 1)..] : code; var variableType = prefix switch { "employee" => ContractVariableType.Employee, "company" => ContractVariableType.Company, "hr" => ContractVariableType.Contract, "system" => ContractVariableType.System, _ => ContractVariableType.Unresolved }; var varName = def != null && !string.IsNullOrWhiteSpace(def.VariableName) ? def.VariableName : sourceField; var dataType = def != null && !string.IsNullOrWhiteSpace(def.DataType) ? def.DataType : InferDataType(sourceField); return new ObTemplateVariable { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), TemplateVersionId = versionId, VariableCode = code, VariableName = varName, VariableType = variableType, DataType = dataType, EmployeeField = variableType is ContractVariableType.Employee or ContractVariableType.Company ? sourceField : null, DefaultValue = def?.DefaultValue, Required = def?.Required ?? true, HrEditable = variableType == ContractVariableType.Contract, // 合同版本首次识别时继承全局变量字典排序;未登记排序时才使用识别顺序兜底。 SortCode = def?.SortCode ?? (index + 1) * 10, Remark = def?.Remark }; } private static string InferDataType(string sourceField) { var normalized = sourceField.ToLowerInvariant(); if (normalized.Contains("date") || normalized.EndsWith("at")) return "date"; if (normalized.Contains("salary") || normalized.Contains("amount") || normalized.Contains("wage") || normalized.Contains("copies")) { return "decimal"; } return "string"; } private async Task EnsureCodeUnique(string code, long id) { var exists = await Context.Queryable() .AnyAsync(it => it.TemplateCode == code && it.Id != id); if (exists) throw Oops.Bah("模板编码已存在"); } /// /// 标准化合同模板保存参数并执行基础业务校验。 /// private static void NormalizeInput(OnboardingContractTemplateAddInput input) { input.TemplateCode = OnboardingCodeValidator.Normalize(input.TemplateCode, "签署材料模板编码", 50); input.TemplateName = input.TemplateName?.Trim(); input.ContractType = input.ContractType?.Trim(); input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List(); if (string.IsNullOrWhiteSpace(input.TemplateName) || string.IsNullOrWhiteSpace(input.ContractType)) { throw Oops.Bah("请填写模板编码、模板名称和合同类型"); } if (input.PositionIds.Count == 0) throw Oops.Bah("请至少选择一个适用岗位"); if (input.Status != "enable" && input.Status != "disable") throw Oops.Bah("合同模板状态不正确"); } /// /// 校验公司、部门、岗位属于同一条启用的业务层级。 /// private async Task ValidateBusinessScope( long companyId, long departmentId, List positionIds) { var companyEnabled = await Context.Queryable() .AnyAsync(item => item.Id == companyId && item.Status == "enable"); if (!companyEnabled) throw Oops.Bah("所属公司不存在或未启用"); var departmentEnabled = await Context.Queryable() .AnyAsync(item => item.Id == departmentId && item.CompanyId == companyId && item.Status == "enable"); if (!departmentEnabled) throw Oops.Bah("所属部门不存在、未启用或不属于该公司"); var positionCount = await Context.Queryable() .CountAsync(item => positionIds.Contains(item.Id) && item.CompanyId == companyId && item.DepartmentId == departmentId && item.Status == "enable"); if (positionCount != positionIds.Count) throw Oops.Bah("存在未启用或不属于该公司、部门的岗位"); } /// /// 防止修改合同模板归属后破坏已经保存的入职模板配置。 /// private async Task ValidateReferencedOnboardingTemplates( OnboardingContractTemplateEditInput input) { var relations = await Context.Queryable() .Where(item => item.ContractTemplateId == input.Id) .ToListAsync(); if (relations.Count == 0) return; var onboardingTemplateIds = relations .Select(item => item.OnboardingTemplateId) .Distinct() .ToList(); var onboardingTemplates = await Context.Queryable() .Where(item => onboardingTemplateIds.Contains(item.Id)) .ToListAsync(); var positionRelations = await Context.Queryable() .Where(item => onboardingTemplateIds.Contains(item.OnboardingTemplateId)) .ToListAsync(); foreach (var template in onboardingTemplates) { var positionIds = positionRelations .Where(item => item.OnboardingTemplateId == template.Id) .Select(item => item.PositionId) .Distinct() .ToList(); var departmentMatches = !template.DepartmentId.HasValue || template.DepartmentId.Value == input.DepartmentId; var scopeMatches = template.CompanyId == input.CompanyId && departmentMatches && positionIds.All(input.PositionIds.Contains); if (!scopeMatches) { throw Oops.Bah( $"合同模板仍被入职模板“{template.TemplateName}”引用,当前归属调整会使其失效,请先调整入职模板"); } } } /// /// 构建合同模板主档案实体。 /// private static ObContractTemplate BuildEntity( long templateId, OnboardingContractTemplateAddInput input) { return new ObContractTemplate { Id = templateId, CompanyId = input.CompanyId, DepartmentId = input.DepartmentId, TemplateCode = input.TemplateCode, TemplateName = input.TemplateName, ContractType = input.ContractType, Description = input.Description, Status = input.Status }; } /// /// 保存合同模板适用岗位关系。 /// private async Task InsertPositionRelations(long templateId, List positionIds) { var relations = positionIds.Select(positionId => new ObContractTemplatePosition { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), ContractTemplateId = templateId, PositionId = positionId }).ToList(); await Context.Insertable(relations).ExecuteCommandAsync(); } /// /// 回填合同模板的公司、部门和适用岗位展示信息。 /// private async Task PopulateScopeDisplay(List templates) { if (templates.Count == 0) return; var templateIds = templates.Select(item => item.Id).ToList(); var companyIds = templates .Where(item => item.CompanyId.HasValue) .Select(item => item.CompanyId.Value) .Distinct() .ToList(); var departmentIds = templates .Where(item => item.DepartmentId.HasValue) .Select(item => item.DepartmentId.Value) .Distinct() .ToList(); var companies = companyIds.Count == 0 ? new List() : await Context.Queryable() .Where(item => companyIds.Contains(item.Id)) .ToListAsync(); var departments = departmentIds.Count == 0 ? new List() : await Context.Queryable() .Where(item => departmentIds.Contains(item.Id)) .ToListAsync(); var relations = await Context.Queryable() .Where(item => templateIds.Contains(item.ContractTemplateId)) .ToListAsync(); var positionIds = relations.Select(item => item.PositionId).Distinct().ToList(); var positions = positionIds.Count == 0 ? new List() : await Context.Queryable() .Where(item => positionIds.Contains(item.Id)) .ToListAsync(); foreach (var template in templates) { template.CompanyName = companies .FirstOrDefault(item => item.Id == template.CompanyId)?.CompanyName; template.DepartmentName = departments .FirstOrDefault(item => item.Id == template.DepartmentId)?.DepartmentName; template.PositionIds = relations .Where(item => item.ContractTemplateId == template.Id) .Select(item => item.PositionId) .ToList(); template.PositionNames = template.PositionIds .Select(positionId => positions.FirstOrDefault(item => item.Id == positionId)?.PositionName) .Where(name => !string.IsNullOrWhiteSpace(name)) .ToList(); } } }