diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplate.cs b/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplate.cs index 331af71..e43ba8a 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplate.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplate.cs @@ -17,6 +17,18 @@ namespace SimpleAdmin.Application; [Tenant(SqlSugarConst.DB_DEFAULT)] public class ObContractTemplate : BaseEntity { + /// + /// 合同模板所属入职业务公司 Id。 + /// + [SugarColumn(IsNullable = true)] + public long? CompanyId { get; set; } + + /// + /// 合同模板所属入职业务部门 Id。 + /// + [SugarColumn(IsNullable = true)] + public long? DepartmentId { get; set; } + /// /// 合同模板业务编码。 /// @@ -36,4 +48,28 @@ public class ObContractTemplate : BaseEntity /// 模板用途说明。 /// public string Description { get; set; } + + /// + /// 所属公司名称,仅用于接口展示。 + /// + [SugarColumn(IsIgnore = true)] + public string CompanyName { get; set; } + + /// + /// 所属部门名称,仅用于接口展示。 + /// + [SugarColumn(IsIgnore = true)] + public string DepartmentName { get; set; } + + /// + /// 适用岗位 Id 列表,仅用于接口展示和编辑回显。 + /// + [SugarColumn(IsIgnore = true)] + public List PositionIds { get; set; } = new(); + + /// + /// 适用岗位名称列表,仅用于接口展示。 + /// + [SugarColumn(IsIgnore = true)] + public List PositionNames { get; set; } = new(); } diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplatePosition.cs b/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplatePosition.cs new file mode 100644 index 0000000..719c933 --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplatePosition.cs @@ -0,0 +1,19 @@ +namespace SimpleAdmin.Application; + +/// +/// 合同模板适用岗位关系,用于限制合同模板可被哪些岗位的入职模板选择。 +/// +[SugarTable("ob_contract_template_position", TableDescription = "合同模板适用岗位")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObContractTemplatePosition : BaseEntity +{ + /// + /// 合同模板主档案 Id。 + /// + public long ContractTemplateId { get; set; } + + /// + /// 适用岗位 Id。 + /// + public long PositionId { get; set; } +} diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs b/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs new file mode 100644 index 0000000..fbd3e23 --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObDepartment.cs @@ -0,0 +1,40 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职业务部门档案,属于某家入职公司。 +/// +[SugarTable("ob_department", TableDescription = "入职业务部门")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObDepartment : BaseEntity +{ + /// + /// 所属入职业务公司 Id。 + /// + public long CompanyId { get; set; } + + /// + /// 部门业务编码。 + /// + public virtual string DepartmentCode { get; set; } + + /// + /// 部门名称。 + /// + public virtual string DepartmentName { get; set; } + + /// + /// 排序码。 + /// + public int SortCode { get; set; } = 99; + + /// + /// 业务备注。 + /// + public string Remark { get; set; } + + /// + /// 所属公司名称 (展示用) + /// + [SugarColumn(IsIgnore = true)] + public string CompanyName { get; set; } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObEmployee.cs b/SimpleAdmin.Application/Entity/Onboarding/ObEmployee.cs index a747cef..b7c347c 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObEmployee.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObEmployee.cs @@ -37,6 +37,12 @@ public class ObEmployee : BaseEntity /// public long CompanyId { get; set; } + /// + /// 所属入职业务部门 Id。 + /// + [SugarColumn(IsNullable = true)] + public long? DepartmentId { get; set; } + /// /// 所属部门名称。 /// diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs index a990480..c8f9c8e 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTask.cs @@ -22,6 +22,12 @@ public class ObOnboardingTask : BaseEntity /// public long CompanyId { get; set; } + /// + /// 所属入职业务部门 Id。 + /// + [SugarColumn(IsNullable = true)] + public long? DepartmentId { get; set; } + /// /// 入职任务所属岗位 Id。 /// @@ -83,6 +89,12 @@ public class ObOnboardingTask : BaseEntity [SugarColumn(IsIgnore = true)] public string CompanyName { get; set; } + /// + /// 部门名称 (展示用) + /// + [SugarColumn(IsIgnore = true)] + public string DepartmentName { get; set; } + /// /// 岗位名称 (展示用) /// @@ -94,5 +106,4 @@ public class ObOnboardingTask : BaseEntity /// [SugarColumn(IsIgnore = true)] public string OnboardingTemplateName { get; set; } - -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplate.cs b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplate.cs index 082c800..d8d28bf 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplate.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObOnboardingTemplate.cs @@ -22,8 +22,14 @@ public class ObOnboardingTemplate : BaseEntity /// public long CompanyId { get; set; } + /// + /// 所属入职业务部门 Id。 + /// + [SugarColumn(IsNullable = true)] + public long? DepartmentId { get; set; } + /// /// 模板用途和适用范围说明。 /// public string Description { get; set; } -} \ No newline at end of file +} diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs b/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs index dcedb81..6adaa46 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObPosition.cs @@ -11,7 +11,7 @@ namespace SimpleAdmin.Application; /// -/// 入职业务岗位档案,保存岗位默认合同期限、试用期及业务参数。 +/// 入职业务岗位档案,保存岗位所属部门、默认合同期限、试用期及业务参数。 /// [SugarTable("ob_position", TableDescription = "入职业务岗位")] [Tenant(SqlSugarConst.DB_DEFAULT)] @@ -22,6 +22,12 @@ public class ObPosition : BaseEntity /// public long CompanyId { get; set; } + /// + /// 所属入职业务部门 Id。 + /// + [SugarColumn(IsNullable = true)] + public long? DepartmentId { get; set; } + /// /// 岗位业务编码。 /// @@ -51,4 +57,16 @@ public class ObPosition : BaseEntity /// 业务备注。 /// public string Remark { get; set; } + + /// + /// 所属公司名称 (展示用) + /// + [SugarColumn(IsIgnore = true)] + public string CompanyName { get; set; } + + /// + /// 所属部门名称 (展示用) + /// + [SugarColumn(IsIgnore = true)] + public string DepartmentName { get; set; } } diff --git a/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs b/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs index a4dd855..3c12cbc 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Audit/OnboardingAuditService.cs @@ -1,7 +1,7 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// -/// 实现入职审核业务能力。 +/// 实现入职任务审核与退回业务能力。 /// public class OnboardingAuditService : IOnboardingAuditService { @@ -47,8 +47,17 @@ public class OnboardingAuditService : IOnboardingAuditService var employee = employeeInfo.Adapt(); employee.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); employee.CompanyId = task.CompanyId; + employee.DepartmentId = task.DepartmentId; employee.PositionId = task.PositionId; - employee.DepartmentName = employeeInfo.OnboardingDepartment; + if (task.DepartmentId.HasValue) + { + var dept = await _db.Queryable().InSingleAsync(task.DepartmentId.Value); + employee.DepartmentName = dept?.DepartmentName ?? employeeInfo.OnboardingDepartment; + } + else + { + employee.DepartmentName = employeeInfo.OnboardingDepartment; + } employee.College = employeeInfo.GraduatedSchool; employee.BirthDate = ParseDate(employeeInfo.Birthday); employee.ContactAddress = employeeInfo.CurrentAddress; @@ -68,13 +77,26 @@ public class OnboardingAuditService : IOnboardingAuditService await _db.Ado.BeginTranAsync(); try { + var updatedRows = await _db.Updateable() + .SetColumns(item => new ObOnboardingTask + { + EmployeeId = employee.Id, + TaskStatus = OnboardingTaskStatus.Approved, + AuditedAt = DateTime.Now + }) + .Where(item => item.Id == task.Id + && item.TaskStatus == OnboardingTaskStatus.Reviewing + && item.EmployeeId == null) + .ExecuteCommandAsync(); + if (updatedRows != 1) + throw Oops.Bah("该入职任务已被其他审核人处理,请刷新后查看"); + await _snapshotService.Build(task.Id, employeeInfo); await _db.Insertable(employee).ExecuteCommandAsync(); task.EmployeeId = employee.Id; task.TaskStatus = OnboardingTaskStatus.Approved; task.AuditedAt = DateTime.Now; - await _db.Updateable(task).ExecuteCommandAsync(); await WriteAudit(task, OnboardingAuditAction.Approve, input.AuditOpinion); await _db.Ado.CommitTranAsync(); } @@ -96,10 +118,34 @@ public class OnboardingAuditService : IOnboardingAuditService } var task = await GetAuditableTask(input.TaskId); - task.TaskStatus = OnboardingTaskStatus.Rejected; - task.RejectReason = input.AuditOpinion; - await _db.Updateable(task).ExecuteCommandAsync(); - await WriteAudit(task, OnboardingAuditAction.Reject, input.AuditOpinion); + await _db.Ado.BeginTranAsync(); + try + { + var auditedAt = DateTime.Now; + var updatedRows = await _db.Updateable() + .SetColumns(item => new ObOnboardingTask + { + TaskStatus = OnboardingTaskStatus.Rejected, + AuditedAt = auditedAt, + RejectReason = input.AuditOpinion + }) + .Where(item => item.Id == task.Id + && item.TaskStatus == OnboardingTaskStatus.Reviewing) + .ExecuteCommandAsync(); + if (updatedRows != 1) + throw Oops.Bah("该入职任务已被其他审核人处理,请刷新后查看"); + + task.TaskStatus = OnboardingTaskStatus.Rejected; + task.AuditedAt = auditedAt; + task.RejectReason = input.AuditOpinion; + await WriteAudit(task, OnboardingAuditAction.Reject, input.AuditOpinion); + await _db.Ado.CommitTranAsync(); + } + catch + { + await _db.Ado.RollbackTranAsync(); + throw; + } } private async Task GetAuditableTask(long taskId) @@ -113,19 +159,8 @@ public class OnboardingAuditService : IOnboardingAuditService var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds") ?? throw Oops.Bah("请配置入职审核人"); - using var configJson = global::System.Text.Json.JsonDocument.Parse(config.ConfigValue); // 审核人由全局配置与公司专属配置合并得出,不依赖独立业务表。 - var reviewerIds = new List(); - if (configJson.RootElement.TryGetProperty("all", out var allReviewers)) - { - reviewerIds.AddRange(allReviewers.EnumerateArray().Select(item => item.GetInt64())); - } - - if (configJson.RootElement.TryGetProperty("company", out var companyReviewers) - && companyReviewers.TryGetProperty(task.CompanyId.ToString(), out var reviewers)) - { - reviewerIds.AddRange(reviewers.EnumerateArray().Select(item => item.GetInt64())); - } + var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId); if (!reviewerIds.Contains(UserManager.UserId)) { diff --git a/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingReviewerConfigParser.cs b/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingReviewerConfigParser.cs new file mode 100644 index 0000000..2fe38af --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/Common/OnboardingReviewerConfigParser.cs @@ -0,0 +1,64 @@ +namespace SimpleAdmin.Application; + +/// +/// 解析入职审核人系统配置,统一支持全局审核人和公司专属审核人。 +/// +internal static class OnboardingReviewerConfigParser +{ + private const string ConfigKey = "onboard.reviewer.userIds"; + + /// + /// 获取指定公司的全部有效审核人用户 Id。 + /// + public static HashSet Parse(string configValue, long companyId) + { + if (string.IsNullOrWhiteSpace(configValue)) + throw Oops.Bah($"系统配置 {ConfigKey} 不能为空"); + + try + { + using var document = global::System.Text.Json.JsonDocument.Parse(configValue); + if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Object) + throw new global::System.Text.Json.JsonException(); + + var reviewerIds = new HashSet(); + if (document.RootElement.TryGetProperty("all", out var allReviewers)) + AddReviewers(allReviewers, reviewerIds); + + if (document.RootElement.TryGetProperty("company", out var companyReviewers)) + { + if (companyReviewers.ValueKind != global::System.Text.Json.JsonValueKind.Object) + throw new global::System.Text.Json.JsonException(); + + if (companyReviewers.TryGetProperty(companyId.ToString(), out var reviewers)) + AddReviewers(reviewers, reviewerIds); + } + + return reviewerIds; + } + catch (global::System.Text.Json.JsonException) + { + throw Oops.Bah( + $"系统配置 {ConfigKey} 格式不正确,应为包含 all 数组和 company 对象的 JSON"); + } + } + + /// + /// 将 JSON 数组中的审核人 Id 加入集合,并拒绝非整数配置。 + /// + private static void AddReviewers( + global::System.Text.Json.JsonElement element, + HashSet reviewerIds) + { + if (element.ValueKind != global::System.Text.Json.JsonValueKind.Array) + throw new global::System.Text.Json.JsonException(); + + foreach (var reviewer in element.EnumerateArray()) + { + if (!reviewer.TryGetInt64(out var reviewerId) || reviewerId <= 0) + throw new global::System.Text.Json.JsonException(); + + reviewerIds.Add(reviewerId); + } + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs index 16bca1b..9c41a7c 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs @@ -70,6 +70,21 @@ public class OnboardingCompanyService : DbRepository, IOnboardingComp if (hasPositions) throw Oops.Bah("公司下仍有岗位,不能删除"); + var hasDepartments = await Context.Queryable() + .AnyAsync(item => input.Ids.Contains(item.CompanyId)); + if (hasDepartments) + throw Oops.Bah("公司下仍有部门,不能删除"); + + var hasOnboardingTemplates = await Context.Queryable() + .AnyAsync(item => input.Ids.Contains(item.CompanyId)); + if (hasOnboardingTemplates) + throw Oops.Bah("公司已被入职模板引用,不能删除"); + + var hasContractTemplates = await Context.Queryable() + .AnyAsync(item => item.CompanyId.HasValue && input.Ids.Contains(item.CompanyId.Value)); + if (hasContractTemplates) + throw Oops.Bah("公司已被合同模板引用,不能删除"); + var hasTasks = await Context.Queryable() .AnyAsync(it => input.Ids.Contains(it.CompanyId)); if (hasTasks) diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs index 4adc6a1..91cc483 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs @@ -42,6 +42,15 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ 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( diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs index a4611f6..dc37328 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractSnapshot/OnboardingContractSnapshotService.cs @@ -136,7 +136,10 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ if (!string.IsNullOrWhiteSpace(str)) return str; } } - catch { } + catch (global::System.Text.Json.JsonException) + { + throw Oops.Bah("员工补充变量数据格式异常,请重新保存入职登记表后再生成合同"); + } } return dynamicValues.TryGetValue(sourceField, out var dynamicValue) diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractTemplateInput.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractTemplateInput.cs index dec4d89..29a5a1b 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractTemplateInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractTemplateInput.cs @@ -5,6 +5,21 @@ namespace SimpleAdmin.Application; /// public class OnboardingContractTemplatePageInput : BasePageInput { + /// + /// 所属公司 Id。 + /// + public long? CompanyId { get; set; } + + /// + /// 所属部门 Id。 + /// + public long? DepartmentId { get; set; } + + /// + /// 适用岗位 Id。 + /// + public long? PositionId { get; set; } + /// /// 合同模板名称。 /// @@ -26,6 +41,23 @@ public class OnboardingContractTemplatePageInput : BasePageInput /// public class OnboardingContractTemplateAddInput { + /// + /// 所属公司 Id。 + /// + [IdNotNull(ErrorMessage = "所属公司不能为空")] + public long CompanyId { get; set; } + + /// + /// 所属部门 Id。 + /// + [IdNotNull(ErrorMessage = "所属部门不能为空")] + public long DepartmentId { get; set; } + + /// + /// 合同模板适用的岗位 Id 列表。 + /// + public List PositionIds { get; set; } = new(); + /// /// 合同模板业务编码。 /// @@ -66,3 +98,27 @@ public class OnboardingContractTemplateEditInput : OnboardingContractTemplateAdd [IdNotNull(ErrorMessage = "模板Id不能为空")] public long Id { get; set; } } + +/// +/// 查询公司、部门及岗位可选合同模板参数。 +/// +public class OnboardingContractTemplateAvailableInput +{ + /// + /// 所属公司 Id。 + /// + [IdNotNull(ErrorMessage = "所属公司不能为空")] + public long CompanyId { get; set; } + + /// + /// 所属部门 Id。 + /// + [IdNotNull(ErrorMessage = "所属部门不能为空")] + public long DepartmentId { get; set; } + + /// + /// 入职模板已选择的岗位 Id 列表。 + /// 返回结果必须同时适用于这些岗位。 + /// + public List PositionIds { get; set; } = new(); +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs index 0b457b7..87a2f76 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs @@ -15,6 +15,11 @@ public interface IOnboardingContractTemplateService : ITransient /// Task Detail(BaseIdInput input); + /// + /// 查询同时适用于指定公司、部门和全部已选岗位的启用合同模板。 + /// + Task> ListAvailable(OnboardingContractTemplateAvailableInput input); + /// /// 新增合同模板主档案。 /// @@ -59,4 +64,4 @@ public interface IOnboardingContractTemplateService : ITransient /// 编辑并确认模板版本中的变量定义。 /// Task EditVariable(OnboardingTemplateVariableEditInput input); -} \ No newline at end of file +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs index 04380ab..4bf1b99 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs @@ -17,15 +17,30 @@ public class OnboardingContractTemplateService : DbRepository public async Task> Page(OnboardingContractTemplatePageInput input) { - return await Context.Queryable() + 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) + .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.SortField} {input.SortOrder}") .OrderBy(it => it.TemplateName) .ToPagedListAsync(input.PageNum, input.PageSize); + await PopulateScopeDisplay(page.List.ToList()); + return page; } /// @@ -33,7 +48,47 @@ public class OnboardingContractTemplateService : DbRepository public async Task Detail(BaseIdInput input) { - return await GetByIdAsync(input.Id) ?? throw Oops.Bah("合同模板不存在"); + 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; } /// @@ -41,8 +96,24 @@ public class OnboardingContractTemplateService : DbRepository public async Task Add(OnboardingContractTemplateAddInput input) { + NormalizeInput(input); + await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds); await EnsureCodeUnique(input.TemplateCode, 0); - await InsertAsync(input.Adapt()); + + 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; + } } /// @@ -51,8 +122,29 @@ public class OnboardingContractTemplateService : DbRepository()); + + 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; + } } /// @@ -95,6 +187,10 @@ public class OnboardingContractTemplateService : DbRepository input.Ids.Contains(it.TemplateId)) .ExecuteCommandAsync(); + await Context.Deleteable() + .Where(item => input.Ids.Contains(item.ContractTemplateId)) + .ExecuteCommandAsync(); + await DeleteByIdsAsync(input.Ids.Cast().ToArray()); await Context.Ado.CommitTranAsync(); } @@ -182,6 +278,15 @@ public class OnboardingContractTemplateService : DbRepository().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) @@ -336,4 +441,189 @@ public class OnboardingContractTemplateService : DbRepository + /// 标准化合同模板保存参数并执行基础业务校验。 + /// + private static void NormalizeInput(OnboardingContractTemplateAddInput input) + { + input.TemplateCode = input.TemplateCode?.Trim(); + input.TemplateName = input.TemplateName?.Trim(); + input.ContractType = input.ContractType?.Trim(); + input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List(); + + if (string.IsNullOrWhiteSpace(input.TemplateCode) + || 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(); + } + } } diff --git a/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs b/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs new file mode 100644 index 0000000..378d0bf --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/Department/Dto/OnboardingDepartmentInput.cs @@ -0,0 +1,63 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职业务部门分页查询参数。 +/// +public class OnboardingDepartmentPageInput : BasePageInput +{ + /// + /// 入职业务公司 Id。 + /// + public long? CompanyId { get; set; } + + /// + /// 部门编码。 + /// + public virtual string DepartmentCode { get; set; } + + /// + /// 部门名称。 + /// + public virtual string DepartmentName { get; set; } + + /// + /// 启用状态。 + /// + public string Status { get; set; } +} + +/// +/// 新增入职业务部门参数。 +/// +public class OnboardingDepartmentAddInput : ObDepartment +{ + /// + /// 所属公司 Id。 + /// + [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "所属公司不能为空")] + public new long CompanyId { get; set; } + + /// + /// 部门业务编码。 + /// + [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门编码不能为空")] + public override string DepartmentCode { get; set; } + + /// + /// 部门名称。 + /// + [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门名称不能为空")] + public override string DepartmentName { get; set; } +} + +/// +/// 编辑入职业务部门参数。 +/// +public class OnboardingDepartmentEditInput : OnboardingDepartmentAddInput +{ + /// + /// 业务数据 Id。 + /// + [IdNotNull(ErrorMessage = "部门Id不能为空")] + public override long Id { get; set; } +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/Department/IOnboardingDepartmentService.cs b/SimpleAdmin.Application/Services/Onboarding/Department/IOnboardingDepartmentService.cs new file mode 100644 index 0000000..fef85d1 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/Department/IOnboardingDepartmentService.cs @@ -0,0 +1,37 @@ +namespace SimpleAdmin.Application; + +/// +/// 定义入职业务部门业务能力。 +/// +public interface IOnboardingDepartmentService : ITransient +{ + /// + /// 分页查询入职业务部门。 + /// + Task> Page(OnboardingDepartmentPageInput input); + + /// + /// 查询指定公司下全部已启用部门列表。 + /// + Task> ListEnabled(long companyId); + + /// + /// 查询部门详情。 + /// + Task Detail(BaseIdInput input); + + /// + /// 新增入职业务部门。 + /// + Task Add(OnboardingDepartmentAddInput input); + + /// + /// 编辑入职业务部门。 + /// + Task Edit(OnboardingDepartmentEditInput input); + + /// + /// 批量删除部门。 + /// + Task Delete(BaseIdListInput input); +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs b/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs new file mode 100644 index 0000000..bbb935d --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/Department/OnboardingDepartmentService.cs @@ -0,0 +1,177 @@ +namespace SimpleAdmin.Application; + +/// +/// 实现入职业务部门业务能力。 +/// +public class OnboardingDepartmentService : DbRepository, IOnboardingDepartmentService +{ + /// + /// 分页查询入职业务部门。 + /// + public async Task> Page(OnboardingDepartmentPageInput input) + { + var list = await Context.Queryable() + .WhereIF(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value) + .WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentCode), it => it.DepartmentCode.Contains(input.DepartmentCode)) + .WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentName), it => it.DepartmentName.Contains(input.DepartmentName)) + .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) + .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}") + .OrderBy(it => it.SortCode) + .OrderBy(it => it.DepartmentName) + .ToPagedListAsync(input.PageNum, input.PageSize); + + if (list.List != null && list.List.Any()) + { + var companyIds = list.List.Select(d => d.CompanyId).Distinct().ToList(); + var companies = await Context.Queryable() + .Where(c => companyIds.Contains(c.Id)) + .ToListAsync(); + var companyMap = companies.ToDictionary(c => c.Id, c => c.CompanyName); + + foreach (var item in list.List) + { + if (companyMap.TryGetValue(item.CompanyId, out var cName)) + { + item.CompanyName = cName; + } + } + } + + return list; + } + + /// + /// 查询指定公司下全部已启用部门列表。 + /// + public Task> ListEnabled(long companyId) + { + return Context.Queryable() + .Where(it => it.CompanyId == companyId && it.Status == "enable") + .OrderBy(it => it.SortCode) + .OrderBy(it => it.DepartmentName) + .ToListAsync(); + } + + /// + /// 查询部门详情。 + /// + public async Task Detail(BaseIdInput input) + { + return await GetByIdAsync(input.Id) ?? throw Oops.Bah("部门不存在"); + } + + /// + /// 新增入职业务部门。 + /// + public async Task Add(OnboardingDepartmentAddInput input) + { + NormalizeInput(input); + await EnsureCompanyExists(input.CompanyId); + await EnsureCodeUnique(input.CompanyId, input.DepartmentCode, 0); + await InsertAsync(input.Adapt()); + } + + /// + /// 编辑入职业务部门。 + /// + public async Task Edit(OnboardingDepartmentEditInput input) + { + NormalizeInput(input); + var department = await Detail(new BaseIdInput { Id = input.Id }); + await EnsureCompanyCanChange(department, input.CompanyId); + await EnsureCompanyExists(input.CompanyId); + await EnsureCodeUnique(input.CompanyId, input.DepartmentCode, input.Id); + await UpdateAsync(input.Adapt()); + } + + /// + /// 删除未被岗位或任务引用的部门。 + /// + public async Task Delete(BaseIdListInput input) + { + if (input.Ids.Count == 0) + return; + + var hasPositions = await Context.Queryable() + .AnyAsync(it => it.DepartmentId.HasValue && input.Ids.Contains(it.DepartmentId.Value)); + if (hasPositions) + throw Oops.Bah("部门下已有岗位,不能删除"); + + var hasTasks = await Context.Queryable() + .AnyAsync(it => it.DepartmentId.HasValue && input.Ids.Contains(it.DepartmentId.Value)); + if (hasTasks) + throw Oops.Bah("部门已有入职任务,不能删除"); + + var hasEmployees = await Context.Queryable() + .AnyAsync(it => it.DepartmentId.HasValue && input.Ids.Contains(it.DepartmentId.Value)); + if (hasEmployees) + throw Oops.Bah("部门已有正式员工,不能删除"); + + var hasOnboardingTemplates = await Context.Queryable() + .AnyAsync(item => item.DepartmentId.HasValue && input.Ids.Contains(item.DepartmentId.Value)); + if (hasOnboardingTemplates) + throw Oops.Bah("部门已被入职模板引用,不能删除"); + + var hasContractTemplates = await Context.Queryable() + .AnyAsync(item => item.DepartmentId.HasValue && input.Ids.Contains(item.DepartmentId.Value)); + if (hasContractTemplates) + throw Oops.Bah("部门已被合同模板引用,不能删除"); + + await DeleteByIdsAsync(input.Ids.Cast().ToArray()); + } + + private async Task EnsureCompanyExists(long companyId) + { + var exists = await Context.Queryable() + .AnyAsync(it => it.Id == companyId && it.Status == "enable"); + if (!exists) + throw Oops.Bah("所属公司不存在、已删除或未启用"); + } + + /// + /// 标准化部门输入并拒绝非法状态。 + /// + private static void NormalizeInput(OnboardingDepartmentAddInput input) + { + input.DepartmentCode = input.DepartmentCode?.Trim(); + input.DepartmentName = input.DepartmentName?.Trim(); + if (string.IsNullOrWhiteSpace(input.DepartmentCode) + || string.IsNullOrWhiteSpace(input.DepartmentName)) + { + throw Oops.Bah("请填写部门编码和部门名称"); + } + + if (input.Status != "enable" && input.Status != "disable") + throw Oops.Bah("部门状态不正确"); + } + + /// + /// 已产生下级或业务引用的部门不允许直接变更所属公司。 + /// + private async Task EnsureCompanyCanChange(ObDepartment department, long companyId) + { + if (department.CompanyId == companyId) + return; + + var hasPosition = await Context.Queryable() + .AnyAsync(item => item.DepartmentId == department.Id); + var hasTask = await Context.Queryable() + .AnyAsync(item => item.DepartmentId == department.Id); + var hasEmployee = await Context.Queryable() + .AnyAsync(item => item.DepartmentId == department.Id); + var hasOnboardingTemplate = await Context.Queryable() + .AnyAsync(item => item.DepartmentId == department.Id); + var hasContractTemplate = await Context.Queryable() + .AnyAsync(item => item.DepartmentId == department.Id); + if (hasPosition || hasTask || hasEmployee || hasOnboardingTemplate || hasContractTemplate) + throw Oops.Bah("部门已有岗位、模板、任务或员工引用,不能直接变更所属公司"); + } + + private async Task EnsureCodeUnique(long companyId, string code, long id) + { + var exists = await Context.Queryable() + .AnyAsync(it => it.CompanyId == companyId && it.DepartmentCode == code && it.Id != id); + if (exists) + throw Oops.Bah("该公司下部门编码已存在"); + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs b/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs index 97a809b..00c39ff 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs @@ -21,7 +21,12 @@ public class OnboardingEmployeePageInput : BasePageInput public long? CompanyId { get; set; } /// - /// 岗位 Id;为空时表示公司级配置。 + /// 入职业务部门 Id。 + /// + public long? DepartmentId { get; set; } + + /// + /// 岗位 Id。 /// public long? PositionId { get; set; } } diff --git a/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs b/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs index 950a79d..08e89ce 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs @@ -1,33 +1,61 @@ namespace SimpleAdmin.Application; -/// 仅展示审核通过后创建的正式员工档案;入职资料的来源任务可追溯。 /// -/// 实现正式员工档案查询业务能力。 +/// 实现正式员工档案查询业务能力,仅展示审核通过后创建的正式员工。 /// public class OnboardingEmployeeService : IOnboardingEmployeeService { private readonly SqlSugarScope _db; - public OnboardingEmployeeService() => _db = DbContext.DB; + + public OnboardingEmployeeService() + { + _db = DbContext.DB; + } /// /// 分页查询审核通过后生成的正式员工档案。 /// - public Task> Page(OnboardingEmployeePageInput input) => _db.Queryable() - .WhereIF(!string.IsNullOrWhiteSpace(input.EmployeeName), x => x.EmployeeName.Contains(input.EmployeeName) || x.IdCardNumber.Contains(input.EmployeeName)) - .WhereIF(!string.IsNullOrWhiteSpace(input.Phone), x => x.Phone.Contains(input.Phone)) - .WhereIF(input.CompanyId.HasValue, x => x.CompanyId == input.CompanyId.Value) - .WhereIF(input.PositionId.HasValue, x => x.PositionId == input.PositionId.Value) - .OrderByDescending(x => x.CreateTime).ToPagedListAsync(input.PageNum, input.PageSize); + public Task> Page(OnboardingEmployeePageInput input) + { + return _db.Queryable() + .WhereIF( + !string.IsNullOrWhiteSpace(input.EmployeeName), + item => item.EmployeeName.Contains(input.EmployeeName) + || item.IdCardNumber.Contains(input.EmployeeName)) + .WhereIF(!string.IsNullOrWhiteSpace(input.Phone), item => item.Phone.Contains(input.Phone)) + .WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value) + .WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value) + .WhereIF(input.PositionId.HasValue, item => item.PositionId == input.PositionId.Value) + .OrderByDescending(item => item.CreateTime) + .ToPagedListAsync(input.PageNum, input.PageSize); + } /// /// 查询正式员工及其来源任务、合同和审核记录。 /// public async Task Detail(BaseIdInput input) { - var employee = await _db.Queryable().InSingleAsync(input.Id) ?? throw Oops.Bah("正式员工不存在"); + var employee = await _db.Queryable().InSingleAsync(input.Id) + ?? throw Oops.Bah("正式员工不存在"); var task = await _db.Queryable().FirstAsync(x => x.EmployeeId == employee.Id); - var contracts = task == null ? new List() : await _db.Queryable().Where(x => x.TaskId == task.Id).ToListAsync(); - var audits = task == null ? new List() : await _db.Queryable().Where(x => x.TaskId == task.Id).OrderByDescending(x => x.AuditedAt).ToListAsync(); - return new OnboardingEmployeeDetailOutput { Employee = employee, Task = task, Contracts = contracts, Audits = audits }; + var contracts = task == null + ? new List() + : await _db.Queryable() + .Where(item => item.TaskId == task.Id) + .ToListAsync(); + var audits = task == null + ? new List() + : await _db.Queryable() + .Where(item => item.TaskId == task.Id) + .OrderByDescending(item => item.AuditedAt) + .ToListAsync(); + + return new OnboardingEmployeeDetailOutput + { + Employee = employee, + Task = task, + Contracts = contracts, + Audits = audits + }; } } diff --git a/SimpleAdmin.Application/Services/Onboarding/Lifecycle/OnboardingTaskLifecycleService.cs b/SimpleAdmin.Application/Services/Onboarding/Lifecycle/OnboardingTaskLifecycleService.cs index 4d916e1..e6a245c 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Lifecycle/OnboardingTaskLifecycleService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Lifecycle/OnboardingTaskLifecycleService.cs @@ -25,10 +25,32 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService throw Oops.Bah("仅审核通过任务可归档"); } - task.TaskStatus = OnboardingTaskStatus.Archived; - task.ArchivedAt = DateTime.Now; - await _db.Updateable(task).ExecuteCommandAsync(); - await Log(task, OnboardingAuditAction.Archive, input.AuditOpinion); + await _db.Ado.BeginTranAsync(); + try + { + var archivedAt = DateTime.Now; + var updatedRows = await _db.Updateable() + .SetColumns(item => new ObOnboardingTask + { + TaskStatus = OnboardingTaskStatus.Archived, + ArchivedAt = archivedAt + }) + .Where(item => item.Id == task.Id + && item.TaskStatus == OnboardingTaskStatus.Approved) + .ExecuteCommandAsync(); + if (updatedRows != 1) + throw Oops.Bah("该入职任务状态已变化,请刷新后重试"); + + task.TaskStatus = OnboardingTaskStatus.Archived; + task.ArchivedAt = archivedAt; + await Log(task, OnboardingAuditAction.Archive, input.AuditOpinion); + await _db.Ado.CommitTranAsync(); + } + catch + { + await _db.Ado.RollbackTranAsync(); + throw; + } } /// @@ -37,22 +59,43 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService public async Task Revoke(OnboardingAuditActionInput input) { var task = await Authorize(input.TaskId); - if (task.TaskStatus == OnboardingTaskStatus.Approved || task.TaskStatus == OnboardingTaskStatus.Archived) + if (task.TaskStatus == OnboardingTaskStatus.Approved + || task.TaskStatus == OnboardingTaskStatus.Archived + || task.TaskStatus == OnboardingTaskStatus.Revoked) { - throw Oops.Bah("已通过或已归档任务不能撤销"); + throw Oops.Bah("已通过、已归档或已撤销任务不能再次撤销"); } - task.TaskStatus = OnboardingTaskStatus.Revoked; - await _db.Updateable(task).ExecuteCommandAsync(); - await _db.Updateable() - .SetColumns(item => new ObTaskLink - { - LinkStatus = OnboardingLinkStatus.Revoked, - RevokedAt = DateTime.Now - }) - .Where(item => item.TaskId == task.Id && item.LinkStatus == OnboardingLinkStatus.Active) - .ExecuteCommandAsync(); - await Log(task, OnboardingAuditAction.Revoke, input.AuditOpinion); + await _db.Ado.BeginTranAsync(); + try + { + var originalStatus = task.TaskStatus; + var revokedAt = DateTime.Now; + var updatedRows = await _db.Updateable() + .SetColumns(item => item.TaskStatus == OnboardingTaskStatus.Revoked) + .Where(item => item.Id == task.Id && item.TaskStatus == originalStatus) + .ExecuteCommandAsync(); + if (updatedRows != 1) + throw Oops.Bah("该入职任务状态已变化,请刷新后重试"); + + await _db.Updateable() + .SetColumns(item => new ObTaskLink + { + LinkStatus = OnboardingLinkStatus.Revoked, + RevokedAt = revokedAt + }) + .Where(item => item.TaskId == task.Id + && item.LinkStatus == OnboardingLinkStatus.Active) + .ExecuteCommandAsync(); + task.TaskStatus = OnboardingTaskStatus.Revoked; + await Log(task, OnboardingAuditAction.Revoke, input.AuditOpinion); + await _db.Ado.CommitTranAsync(); + } + catch + { + await _db.Ado.RollbackTranAsync(); + throw; + } } /// @@ -86,6 +129,9 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService await _db.Deleteable().Where(x => x.TaskId == task.Id).ExecuteCommandAsync(); await _db.Deleteable().Where(x => x.TaskId == task.Id).ExecuteCommandAsync(); await _db.Deleteable().Where(x => x.TaskId == task.Id).ExecuteCommandAsync(); + await _db.Deleteable().Where(x => x.TaskId == task.Id).ExecuteCommandAsync(); + await _db.Deleteable().Where(x => x.TaskId == task.Id).ExecuteCommandAsync(); + await _db.Deleteable().Where(x => x.TaskId == task.Id).ExecuteCommandAsync(); await _db.Deleteable().Where(x => x.TaskId == task.Id).ExecuteCommandAsync(); await _db.Deleteable().In(task.Id).ExecuteCommandAsync(); await _db.Ado.CommitTranAsync(); @@ -103,19 +149,7 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService ?? throw Oops.Bah("入职任务不存在"); var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds") ?? throw Oops.Bah("请配置入职审核人"); - - using var configJson = global::System.Text.Json.JsonDocument.Parse(config.ConfigValue); - var reviewerIds = new List(); - if (configJson.RootElement.TryGetProperty("all", out var allReviewers)) - { - reviewerIds.AddRange(allReviewers.EnumerateArray().Select(item => item.GetInt64())); - } - - if (configJson.RootElement.TryGetProperty("company", out var companyReviewers) - && companyReviewers.TryGetProperty(task.CompanyId.ToString(), out var reviewers)) - { - reviewerIds.AddRange(reviewers.EnumerateArray().Select(item => item.GetInt64())); - } + var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId); if (!reviewerIds.Contains(UserManager.UserId)) { diff --git a/SimpleAdmin.Application/Services/Onboarding/MaterialReview/OnboardingMaterialReviewService.cs b/SimpleAdmin.Application/Services/Onboarding/MaterialReview/OnboardingMaterialReviewService.cs index 84ba66f..bbeb80e 100644 --- a/SimpleAdmin.Application/Services/Onboarding/MaterialReview/OnboardingMaterialReviewService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/MaterialReview/OnboardingMaterialReviewService.cs @@ -50,18 +50,7 @@ public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService { var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds") ?? throw Oops.Bah("请配置入职审核人"); - using var configJson = global::System.Text.Json.JsonDocument.Parse(config.ConfigValue); - var reviewerIds = new List(); - if (configJson.RootElement.TryGetProperty("all", out var allReviewers)) - { - reviewerIds.AddRange(allReviewers.EnumerateArray().Select(item => item.GetInt64())); - } - - if (configJson.RootElement.TryGetProperty("company", out var companyReviewers) - && companyReviewers.TryGetProperty(task.CompanyId.ToString(), out var reviewers)) - { - reviewerIds.AddRange(reviewers.EnumerateArray().Select(item => item.GetInt64())); - } + var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId); if (!reviewerIds.Contains(UserManager.UserId)) { diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/Dto/OnboardingTemplateInput.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/Dto/OnboardingTemplateInput.cs index 22037bc..73155db 100644 --- a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/Dto/OnboardingTemplateInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/Dto/OnboardingTemplateInput.cs @@ -15,6 +15,11 @@ public class OnboardingTemplatePageInput : BasePageInput /// public long? CompanyId { get; set; } + /// + /// 所属部门 Id。 + /// + public long? DepartmentId { get; set; } + /// /// 适用岗位 Id。 /// @@ -51,6 +56,12 @@ public class OnboardingTemplateSaveInput /// public long CompanyId { get; set; } + /// + /// 所属部门 Id。 + /// + [IdNotNull(ErrorMessage = "所属部门不能为空")] + public long DepartmentId { get; set; } + /// /// 适用岗位 Id 列表。 /// @@ -165,6 +176,11 @@ public class OnboardingTemplateOutput : ObOnboardingTemplate /// public string CompanyName { get; set; } + /// + /// 所属部门名称。 + /// + public string DepartmentName { get; set; } + /// /// 适用岗位 Id 列表。 /// @@ -210,4 +226,4 @@ public class OnboardingTemplateDetailOutput : OnboardingTemplateOutput /// 上传材料配置明细。 /// public List Materials { get; set; } = new(); -} \ No newline at end of file +} diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/IOnboardingTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/IOnboardingTemplateService.cs index 24f9c4a..655dd73 100644 --- a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/IOnboardingTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/IOnboardingTemplateService.cs @@ -26,7 +26,10 @@ public interface IOnboardingTemplateService : ITransient Task Delete(BaseIdListInput input); /// - /// 查询公司岗位可使用的入职模板。 + /// 查询公司、部门和岗位可使用的入职模板。 /// - Task> ListEnabled(long companyId, long positionId); -} \ No newline at end of file + Task> ListEnabled( + long companyId, + long departmentId, + long positionId); +} diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs index d332d2a..3f2ca1b 100644 --- a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs @@ -20,6 +20,7 @@ public class OnboardingTemplateService : IOnboardingTemplateService .WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), item => item.TemplateCode.Contains(input.Keyword) || item.TemplateName.Contains(input.Keyword)) .WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value) + .WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), item => item.Status == input.Status); if (input.PositionId.HasValue) @@ -62,6 +63,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService TemplateName = summary.TemplateName, CompanyId = summary.CompanyId, CompanyName = summary.CompanyName, + DepartmentId = summary.DepartmentId, + DepartmentName = summary.DepartmentName, Description = summary.Description, Status = summary.Status, CreateTime = summary.CreateTime, @@ -101,6 +104,7 @@ public class OnboardingTemplateService : IOnboardingTemplateService TemplateCode = input.TemplateCode.Trim(), TemplateName = input.TemplateName.Trim(), CompanyId = input.CompanyId, + DepartmentId = input.DepartmentId, Description = input.Description, Status = input.Status }; @@ -149,9 +153,6 @@ public class OnboardingTemplateService : IOnboardingTemplateService foreach (var id in input.Ids) await DeleteRelations(id); - await _db.Deleteable() - .Where(it => input.Ids.Contains(it.Id)) - .ExecuteCommandAsync(); await _db.Updateable() .SetColumns(it => it.IsDelete == true) .Where(it => input.Ids.Contains(it.Id)) @@ -167,7 +168,10 @@ public class OnboardingTemplateService : IOnboardingTemplateService } /// - public async Task> ListEnabled(long companyId, long positionId) + public async Task> ListEnabled( + long companyId, + long departmentId, + long positionId) { var templateIds = await _db.Queryable() .Where(item => item.PositionId == positionId) @@ -175,6 +179,7 @@ public class OnboardingTemplateService : IOnboardingTemplateService .ToListAsync(); var templates = await _db.Queryable() .Where(item => item.CompanyId == companyId + && item.DepartmentId == departmentId && item.Status == "enable" && templateIds.Contains(item.Id)) .OrderBy(item => item.TemplateName) @@ -187,10 +192,13 @@ public class OnboardingTemplateService : IOnboardingTemplateService input.TemplateCode = input.TemplateCode?.Trim(); input.TemplateName = input.TemplateName?.Trim(); input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List(); - input.Contracts = input.Contracts? - .GroupBy(item => item.ContractTemplateVersionId) - .Select(group => group.First()) - .ToList() ?? new List(); + input.Contracts ??= new List(); + var duplicateContract = input.Contracts + .GroupBy(item => item.ContractTemplateId) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateContract != null) + throw Oops.Bah("同一份合同模板在入职模板中只能选择一个版本"); + input.FormFields = input.FormFields? .GroupBy(item => item.FieldId) .Select(group => group.First()) @@ -204,10 +212,19 @@ public class OnboardingTemplateService : IOnboardingTemplateService throw Oops.Bah("请填写入职模板编码和名称"); if (input.CompanyId <= 0) throw Oops.Bah("请选择所属公司"); + if (input.DepartmentId <= 0) + throw Oops.Bah("请选择所属部门"); if (input.PositionIds.Count == 0) throw Oops.Bah("请至少选择一个适用岗位"); + if (input.PositionIds.Any(positionId => positionId <= 0)) + throw Oops.Bah("适用岗位数据不正确"); if (input.Contracts.Count == 0) throw Oops.Bah("请至少选择一份合同文件"); + if (input.Contracts.Any(item => item.ContractTemplateId <= 0 + || item.ContractTemplateVersionId <= 0)) + { + throw Oops.Bah("合同模板或版本数据不正确"); + } if (input.Status != "enable" && input.Status != "disable") throw Oops.Bah("入职模板状态不正确"); if (input.Contracts.Any(item => item.PrintCopies <= 0)) @@ -221,12 +238,20 @@ public class OnboardingTemplateService : IOnboardingTemplateService if (!companyEnabled) throw Oops.Bah("所属公司不存在或未启用"); + var departmentEnabled = await _db.Queryable() + .AnyAsync(item => item.Id == input.DepartmentId + && item.CompanyId == input.CompanyId + && item.Status == "enable"); + if (!departmentEnabled) + throw Oops.Bah("所属部门不存在、未启用或不属于该公司"); + var positionCount = await _db.Queryable() .CountAsync(item => input.PositionIds.Contains(item.Id) && item.CompanyId == input.CompanyId + && item.DepartmentId == input.DepartmentId && item.Status == "enable"); if (positionCount != input.PositionIds.Count) - throw Oops.Bah("存在不属于该公司或未启用的岗位"); + throw Oops.Bah("存在未启用或不属于该公司、部门的岗位"); var versionIds = input.Contracts.Select(item => item.ContractTemplateVersionId).ToList(); var versions = await _db.Queryable() @@ -239,8 +264,43 @@ public class OnboardingTemplateService : IOnboardingTemplateService throw Oops.Bah("合同模板与版本不匹配"); } + var contractTemplateIds = input.Contracts + .Select(item => item.ContractTemplateId) + .Distinct() + .ToList(); + var contractTemplates = await _db.Queryable() + .Where(item => contractTemplateIds.Contains(item.Id)) + .ToListAsync(); + if (contractTemplates.Count != contractTemplateIds.Count + || contractTemplates.Any(item => item.CompanyId != input.CompanyId + || item.DepartmentId != input.DepartmentId)) + { + throw Oops.Bah("存在不属于当前公司、部门的合同模板"); + } + + var contractPositionRelations = await _db.Queryable() + .Where(item => contractTemplateIds.Contains(item.ContractTemplateId)) + .ToListAsync(); + var unsupportedContract = contractTemplateIds.FirstOrDefault(contractTemplateId => + { + var supportedPositionIds = contractPositionRelations + .Where(item => item.ContractTemplateId == contractTemplateId) + .Select(item => item.PositionId) + .ToHashSet(); + return input.PositionIds.Any(positionId => !supportedPositionIds.Contains(positionId)); + }); + if (unsupportedContract > 0) + { + var contractName = contractTemplates + .First(item => item.Id == unsupportedContract) + .TemplateName; + throw Oops.Bah($"合同模板“{contractName}”不能覆盖当前选择的全部岗位"); + } + if (input.Status == "enable") { + if (contractTemplates.Any(item => item.Status != "enable")) + throw Oops.Bah("启用入职模板前,请先启用全部合同模板"); if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled)) throw Oops.Bah("启用入职模板前,请先启用全部合同版本"); } @@ -302,6 +362,9 @@ public class OnboardingTemplateService : IOnboardingTemplateService await _db.Deleteable() .Where(item => item.OnboardingTemplateId == templateId) .ExecuteCommandAsync(); + await _db.Deleteable() + .Where(item => item.OnboardingTemplateId == templateId) + .ExecuteCommandAsync(); } private async Task> BuildOutputs(List templates) @@ -311,9 +374,19 @@ public class OnboardingTemplateService : IOnboardingTemplateService var templateIds = templates.Select(item => item.Id).ToList(); var companyIds = templates.Select(item => item.CompanyId).Distinct().ToList(); + var departmentIds = templates + .Where(item => item.DepartmentId.HasValue) + .Select(item => item.DepartmentId.Value) + .Distinct() + .ToList(); var companies = await _db.Queryable() .Where(item => companyIds.Contains(item.Id)) .ToListAsync(); + var departments = departmentIds.Count == 0 + ? new List() + : await _db.Queryable() + .Where(item => departmentIds.Contains(item.Id)) + .ToListAsync(); var relations = await _db.Queryable() .Where(item => templateIds.Contains(item.OnboardingTemplateId)) .ToListAsync(); @@ -338,6 +411,9 @@ public class OnboardingTemplateService : IOnboardingTemplateService TemplateName = template.TemplateName, CompanyId = template.CompanyId, CompanyName = companies.FirstOrDefault(item => item.Id == template.CompanyId)?.CompanyName, + DepartmentId = template.DepartmentId, + DepartmentName = departments + .FirstOrDefault(item => item.Id == template.DepartmentId)?.DepartmentName, Description = template.Description, Status = template.Status, CreateTime = template.CreateTime, @@ -353,4 +429,4 @@ public class OnboardingTemplateService : IOnboardingTemplateService }; }).ToList(); } -} \ No newline at end of file +} diff --git a/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs b/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs index 8e4b5e9..5de8f08 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Position/Dto/OnboardingPositionInput.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 入职业务岗位分页查询参数。 @@ -10,6 +10,11 @@ public class OnboardingPositionPageInput : BasePageInput /// public long? CompanyId { get; set; } + /// + /// 入职业务部门 Id。 + /// + public long? DepartmentId { get; set; } + /// /// 岗位编码。 /// @@ -21,7 +26,7 @@ public class OnboardingPositionPageInput : BasePageInput public virtual string PositionName { get; set; } /// - /// 启用状态或状态筛选条件。 + /// 启用状态。 /// public string Status { get; set; } } @@ -31,6 +36,18 @@ public class OnboardingPositionPageInput : BasePageInput /// public class OnboardingPositionAddInput : ObPosition { + /// + /// 所属公司 Id。 + /// + [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "所属公司不能为空")] + public new long CompanyId { get; set; } + + /// + /// 所属部门 Id。 + /// + [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "所属部门不能为空")] + public new long? DepartmentId { get; set; } + /// /// 岗位业务编码。 /// @@ -54,4 +71,4 @@ public class OnboardingPositionEditInput : OnboardingPositionAddInput /// [IdNotNull(ErrorMessage = "岗位Id不能为空")] public override long Id { get; set; } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/Position/IOnboardingPositionService.cs b/SimpleAdmin.Application/Services/Onboarding/Position/IOnboardingPositionService.cs index 5c8994d..9c2c09a 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Position/IOnboardingPositionService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Position/IOnboardingPositionService.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 定义入职业务岗位业务能力。 @@ -9,24 +9,34 @@ public interface IOnboardingPositionService : ITransient /// 分页查询入职业务岗位。 /// Task> Page(OnboardingPositionPageInput input); + /// /// 查询指定公司下全部已启用岗位。 /// Task> ListEnabled(long companyId); + + /// + /// 查询指定部门下全部已启用岗位。 + /// + Task> ListEnabledByDepartment(long departmentId); + /// /// 查询岗位详情。 /// Task Detail(BaseIdInput input); + /// /// 新增入职业务岗位。 /// Task Add(OnboardingPositionAddInput input); + /// /// 编辑入职业务岗位。 /// Task Edit(OnboardingPositionEditInput input); + /// /// 删除未被任务或正式员工引用的岗位。 /// Task Delete(BaseIdListInput input); -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs b/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs index f42d228..f18b7c4 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 实现入职业务岗位业务能力。 @@ -10,14 +10,45 @@ public class OnboardingPositionService : DbRepository, IOnboardingPo /// public async Task> Page(OnboardingPositionPageInput input) { - return await Context.Queryable() + var list = await Context.Queryable() .WhereIF(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value) + .WhereIF(input.DepartmentId.HasValue, it => it.DepartmentId == input.DepartmentId.Value) .WhereIF(!string.IsNullOrWhiteSpace(input.PositionCode), it => it.PositionCode.Contains(input.PositionCode)) .WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName)) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}") .OrderBy(it => it.PositionName) .ToPagedListAsync(input.PageNum, input.PageSize); + + if (list.List != null && list.List.Any()) + { + var companyIds = list.List.Select(p => p.CompanyId).Distinct().ToList(); + var departmentIds = list.List.Where(p => p.DepartmentId.HasValue).Select(p => p.DepartmentId.Value).Distinct().ToList(); + + var companies = await Context.Queryable() + .Where(c => companyIds.Contains(c.Id)) + .ToListAsync(); + var departments = departmentIds.Count == 0 + ? new List() + : await Context.Queryable().Where(d => departmentIds.Contains(d.Id)).ToListAsync(); + + var companyMap = companies.ToDictionary(c => c.Id, c => c.CompanyName); + var departmentMap = departments.ToDictionary(d => d.Id, d => d.DepartmentName); + + foreach (var item in list.List) + { + if (companyMap.TryGetValue(item.CompanyId, out var cName)) + { + item.CompanyName = cName; + } + if (item.DepartmentId.HasValue && departmentMap.TryGetValue(item.DepartmentId.Value, out var dName)) + { + item.DepartmentName = dName; + } + } + } + + return list; } /// @@ -31,6 +62,17 @@ public class OnboardingPositionService : DbRepository, IOnboardingPo .ToListAsync(); } + /// + /// 查询指定部门下全部已启用岗位。 + /// + public Task> ListEnabledByDepartment(long departmentId) + { + return Context.Queryable() + .Where(it => it.DepartmentId == departmentId && it.Status == "enable") + .OrderBy(it => it.PositionName) + .ToListAsync(); + } + /// /// 查询岗位详情。 /// @@ -44,7 +86,12 @@ public class OnboardingPositionService : DbRepository, IOnboardingPo /// public async Task Add(OnboardingPositionAddInput input) { + NormalizeInput(input); + if (!input.DepartmentId.HasValue) + throw Oops.Bah("请选择所属部门"); + await EnsureCompanyExists(input.CompanyId); + await EnsureDepartmentExists(input.CompanyId, input.DepartmentId.Value); await EnsureCodeUnique(input.CompanyId, input.PositionCode, 0); await InsertAsync(input.Adapt()); } @@ -54,8 +101,14 @@ public class OnboardingPositionService : DbRepository, IOnboardingPo /// public async Task Edit(OnboardingPositionEditInput input) { - await Detail(new BaseIdInput { Id = input.Id }); + NormalizeInput(input); + if (!input.DepartmentId.HasValue) + throw Oops.Bah("请选择所属部门"); + + var position = await Detail(new BaseIdInput { Id = input.Id }); + await EnsureScopeCanChange(position, input.CompanyId, input.DepartmentId.Value); await EnsureCompanyExists(input.CompanyId); + await EnsureDepartmentExists(input.CompanyId, input.DepartmentId.Value); await EnsureCodeUnique(input.CompanyId, input.PositionCode, input.Id); await UpdateAsync(input.Adapt()); } @@ -78,14 +131,75 @@ public class OnboardingPositionService : DbRepository, IOnboardingPo if (hasEmployees) throw Oops.Bah("岗位已有正式员工,不能删除"); + var hasOnboardingTemplates = await Context.Queryable() + .AnyAsync(item => input.Ids.Contains(item.PositionId)); + if (hasOnboardingTemplates) + throw Oops.Bah("岗位已被入职模板引用,不能删除"); + + var hasContractTemplates = await Context.Queryable() + .AnyAsync(item => input.Ids.Contains(item.PositionId)); + if (hasContractTemplates) + throw Oops.Bah("岗位已被合同模板引用,不能删除"); + await DeleteByIdsAsync(input.Ids.Cast().ToArray()); } private async Task EnsureCompanyExists(long companyId) { - var exists = await Context.Queryable().AnyAsync(it => it.Id == companyId); + var exists = await Context.Queryable() + .AnyAsync(it => it.Id == companyId && it.Status == "enable"); if (!exists) - throw Oops.Bah("所属公司不存在或已删除"); + throw Oops.Bah("所属公司不存在、已删除或未启用"); + } + + private async Task EnsureDepartmentExists(long companyId, long departmentId) + { + var exists = await Context.Queryable() + .AnyAsync(it => it.Id == departmentId + && it.CompanyId == companyId + && it.Status == "enable"); + if (!exists) + throw Oops.Bah("所属部门不存在、未启用或不属于该公司"); + } + + /// + /// 标准化岗位输入并拒绝非法状态。 + /// + private static void NormalizeInput(OnboardingPositionAddInput input) + { + input.PositionCode = input.PositionCode?.Trim(); + input.PositionName = input.PositionName?.Trim(); + if (string.IsNullOrWhiteSpace(input.PositionCode) + || string.IsNullOrWhiteSpace(input.PositionName)) + { + throw Oops.Bah("请填写岗位编码和岗位名称"); + } + + if (input.Status != "enable" && input.Status != "disable") + throw Oops.Bah("岗位状态不正确"); + } + + /// + /// 已被业务配置或历史数据引用的岗位不允许直接改变公司、部门归属。 + /// + private async Task EnsureScopeCanChange( + ObPosition position, + long companyId, + long departmentId) + { + if (position.CompanyId == companyId && position.DepartmentId == departmentId) + return; + + var hasTask = await Context.Queryable() + .AnyAsync(item => item.PositionId == position.Id); + var hasEmployee = await Context.Queryable() + .AnyAsync(item => item.PositionId == position.Id); + var hasOnboardingTemplate = await Context.Queryable() + .AnyAsync(item => item.PositionId == position.Id); + var hasContractTemplate = await Context.Queryable() + .AnyAsync(item => item.PositionId == position.Id); + if (hasTask || hasEmployee || hasOnboardingTemplate || hasContractTemplate) + throw Oops.Bah("岗位已被模板、任务或员工引用,不能直接变更所属公司、部门"); } private async Task EnsureCodeUnique(long companyId, string code, long id) diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/Dto/OnboardingPublicSubmissionInput.cs b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/Dto/OnboardingPublicSubmissionInput.cs index 07a3272..2da8218 100644 --- a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/Dto/OnboardingPublicSubmissionInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/Dto/OnboardingPublicSubmissionInput.cs @@ -1,38 +1,5 @@ namespace SimpleAdmin.Application; -/// -/// 单个动态字段填写值。 -/// -public class OnboardingDynamicValueInput -{ - /// - /// 动态字段 Id。 - /// - public long FieldId { get; set; } - - /// - /// 动态字段填写值。 - /// - public string FieldValue { get; set; } -} - -/// -/// 批量保存员工动态字段参数。 -/// -public class OnboardingSaveDynamicValuesInput -{ - /// - /// 员工填写链接中的明文 Token。 - /// - [global::System.ComponentModel.DataAnnotations.Required] - public string Token { get; set; } - - /// - /// 待保存的字段或变量值集合。 - /// - public List Values { get; set; } = new(); -} - /// /// 保存员工已上传材料参数。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/IOnboardingPublicSubmissionService.cs b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/IOnboardingPublicSubmissionService.cs index 0d43e8a..cc38ebc 100644 --- a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/IOnboardingPublicSubmissionService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/IOnboardingPublicSubmissionService.cs @@ -5,11 +5,6 @@ namespace SimpleAdmin.Application; /// public interface IOnboardingPublicSubmissionService : ITransient { - /// - /// 保存候选人填写的动态字段值。 - /// - Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input); - /// /// 关联候选人已经上传的系统文件与材料类型。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs index 9776b42..bc45890 100644 --- a/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/PublicSubmission/OnboardingPublicSubmissionService.cs @@ -14,15 +14,6 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ _snapshotService = snapshotService; } - /// - /// 保存候选人填写的动态补充字段值。 - /// - public async Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input) - { - await GetEditableTask(input.Token); - throw Oops.Bah("新版入职登记表不再使用旧动态字段接口,请通过员工基础信息接口保存补充变量"); - } - /// /// 关联已上传 OSS 文件与任务材料要求。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs index dc449e4..a30b223 100644 --- a/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/PublicTask/OnboardingPublicTaskService.cs @@ -128,6 +128,8 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService throw Oops.Bah("当前任务不可编辑"); } + ValidateEmployeeInfo(input); + var old = await _db.Queryable() .FirstAsync(item => item.TaskId == task.Id); var entity = input.Adapt(); @@ -161,6 +163,46 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService await _snapshotService.Build(task.Id, entity, invalidateGeneratedPreview: true); } + /// + /// 校验候选人核心身份信息,避免非法格式进入合同和正式员工档案。 + /// + private static void ValidateEmployeeInfo(OnboardingTaskPublicSaveInput input) + { + input.EmployeeName = input.EmployeeName?.Trim(); + input.Phone = input.Phone?.Trim(); + input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100) + throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符"); + if (!global::System.Text.RegularExpressions.Regex.IsMatch( + input.Phone ?? string.Empty, + "^1[3-9]\\d{9}$")) + { + throw Oops.Bah("请输入正确的 11 位手机号码"); + } + + if (!global::System.Text.RegularExpressions.Regex.IsMatch( + input.IdCardNumber ?? string.Empty, + "^\\d{17}[0-9X]$")) + { + throw Oops.Bah("请输入正确的 18 位身份证号码"); + } + + if (string.IsNullOrWhiteSpace(input.CustomVariablesJson)) + return; + + try + { + using var document = global::System.Text.Json.JsonDocument.Parse(input.CustomVariablesJson); + if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Object) + throw new global::System.Text.Json.JsonException(); + } + catch (global::System.Text.Json.JsonException) + { + throw Oops.Bah("员工补充变量格式不正确,请刷新页面后重新填写"); + } + } + /// /// 根据员工当前已填写信息校验变量并提前生成合同 DOCX。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs b/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs index 2a86371..f04eaeb 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/Dto/OnboardingTaskInput.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 创建新员工入职任务参数。 @@ -11,11 +11,18 @@ public class OnboardingTaskCreateInput [IdNotNull(ErrorMessage = "公司Id不能为空")] public long CompanyId { get; set; } + /// + /// 入职业务部门 Id。 + /// + [IdNotNull(ErrorMessage = "部门Id不能为空")] + public long DepartmentId { get; set; } + /// /// 入职任务所属岗位 Id。 /// [IdNotNull(ErrorMessage = "岗位Id不能为空")] public long PositionId { get; set; } + /// /// 本次任务使用的入职模板 Id。 /// @@ -89,4 +96,4 @@ public class OnboardingTaskCreatedOutput /// 员工填写链接过期时间。 /// public DateTime ExpiresAt { get; set; } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs index fb36245..834c44d 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs @@ -28,18 +28,25 @@ public class OnboardingTaskService : IOnboardingTaskService if (!companyEnabled) throw Oops.Bah("公司不存在或未启用"); + var departmentEnabled = await _db.Queryable() + .AnyAsync(item => item.Id == input.DepartmentId && item.CompanyId == input.CompanyId && item.Status == "enable"); + if (!departmentEnabled) + throw Oops.Bah("部门不存在、未启用或不属于该公司"); + var positionEnabled = await _db.Queryable() .AnyAsync(item => item.Id == input.PositionId && item.CompanyId == input.CompanyId + && item.DepartmentId == input.DepartmentId && item.Status == "enable"); if (!positionEnabled) - throw Oops.Bah("岗位不存在、未启用或不属于该公司"); + throw Oops.Bah("岗位不存在、未启用或不属于该部门/公司"); var onboardingTemplate = await _db.Queryable() .FirstAsync(item => item.Id == input.OnboardingTemplateId && item.CompanyId == input.CompanyId + && item.DepartmentId == input.DepartmentId && item.Status == "enable") - ?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司"); + ?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司、部门"); var templateSupportsPosition = await _db.Queryable() .AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id && item.PositionId == input.PositionId); @@ -62,6 +69,20 @@ public class OnboardingTaskService : IOnboardingTaskService 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 + || item.Status != "enable")) + { + throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板"); + } + + var contractPositionCount = await _db.Queryable() + .CountAsync(item => templateIds.Contains(item.ContractTemplateId) + && item.PositionId == input.PositionId); + if (contractPositionCount != templateIds.Count) + throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板"); + var versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).Distinct().ToList(); var enabledVersionCount = await _db.Queryable() .CountAsync(item => versionIds.Contains(item.Id) @@ -94,6 +115,7 @@ public class OnboardingTaskService : IOnboardingTaskService Id = taskId, TaskNo = $"OB{DateTime.Now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}", CompanyId = input.CompanyId, + DepartmentId = input.DepartmentId, PositionId = input.PositionId, OnboardingTemplateId = onboardingTemplate.Id, AssignedReviewerUserId = input.AssignedReviewerUserId, @@ -236,6 +258,12 @@ public class OnboardingTaskService : IOnboardingTaskService if (inputs.Count == 0) return; + var duplicateContractVersion = contracts + .GroupBy(item => item.TemplateVersionId) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateContractVersion != null) + throw Oops.Bah("入职模板中存在重复的合同版本,请重新保存入职模板后再创建任务"); + var contractByVersion = contracts.ToDictionary(item => item.TemplateVersionId); var contractIds = contracts.Select(item => item.Id).ToList(); var rows = await _db.Queryable() diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs index f42de5d..ae1fedd 100644 --- a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 入职任务分页查询参数。 @@ -19,6 +19,11 @@ public class OnboardingTaskPageInput : BasePageInput /// 入职业务公司 Id。 /// public long? CompanyId { get; set; } + + /// + /// 入职业务部门 Id。 + /// + public long? DepartmentId { get; set; } } /// @@ -71,4 +76,4 @@ public class OnboardingTaskMaterialDetailOutput : ObTaskMaterial /// 材料文件后缀,用于判断是否支持图片预览。 /// public string FileSuffix { get; set; } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs index beda0dc..6e8537a 100644 --- a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 实现入职任务查询业务能力。 @@ -23,6 +23,7 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService .WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo), item => item.TaskNo.Contains(input.TaskNo)) .WhereIF(!string.IsNullOrWhiteSpace(input.TaskStatus), item => item.TaskStatus == input.TaskStatus) .WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value) + .WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value) .OrderByDescending(item => item.CreateTime) .ToPagedListAsync(input.PageNum, input.PageSize); @@ -191,20 +192,26 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService { var taskList = tasks.ToList(); var companyIds = taskList.Select(t => t.CompanyId).Distinct().ToList(); + var departmentIds = taskList.Where(t => t.DepartmentId.HasValue).Select(t => t.DepartmentId.Value).Distinct().ToList(); var positionIds = taskList.Select(t => t.PositionId).Distinct().ToList(); var templateIds = taskList.Select(t => t.OnboardingTemplateId).Distinct().ToList(); var companies = await _db.Queryable().Where(c => companyIds.Contains(c.Id)).ToListAsync(); + var departments = departmentIds.Count == 0 + ? new List() + : await _db.Queryable().Where(d => departmentIds.Contains(d.Id)).ToListAsync(); var positions = await _db.Queryable().Where(p => positionIds.Contains(p.Id)).ToListAsync(); var templates = await _db.Queryable().Where(t => templateIds.Contains(t.Id)).ToListAsync(); var companyMap = companies.ToDictionary(c => c.Id, c => c.CompanyName); + var departmentMap = departments.ToDictionary(d => d.Id, d => d.DepartmentName); var positionMap = positions.ToDictionary(p => p.Id, p => p.PositionName); var templateMap = templates.ToDictionary(t => t.Id, t => t.TemplateName); foreach (var task in taskList) { if (companyMap.TryGetValue(task.CompanyId, out var cName)) task.CompanyName = cName; + if (task.DepartmentId.HasValue && departmentMap.TryGetValue(task.DepartmentId.Value, out var dName)) task.DepartmentName = dName; if (positionMap.TryGetValue(task.PositionId, out var pName)) task.PositionName = pName; if (templateMap.TryGetValue(task.OnboardingTemplateId, out var tName)) task.OnboardingTemplateName = tName; } diff --git a/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/IOnboardingVariableDefinitionService.cs b/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/IOnboardingVariableDefinitionService.cs index b3e846d..69c4c94 100644 --- a/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/IOnboardingVariableDefinitionService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/IOnboardingVariableDefinitionService.cs @@ -1,10 +1,32 @@ namespace SimpleAdmin.Application; +/// +/// 合同变量字典服务。 +/// public interface IOnboardingVariableDefinitionService : ITransient { + /// + /// 分页查询合同变量定义。 + /// Task> Page(OnboardingVariableDefinitionPageInput input); + + /// + /// 查询全部合同变量定义。 + /// Task> ListAll(); + + /// + /// 新增合同变量定义。 + /// Task Add(OnboardingVariableDefinitionAddInput input); + + /// + /// 编辑合同变量定义。 + /// Task Edit(OnboardingVariableDefinitionEditInput input); + + /// + /// 删除非内置合同变量定义。 + /// Task Delete(BaseIdListInput input); -} \ No newline at end of file +} diff --git a/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/OnboardingVariableDefinitionInput.cs b/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/OnboardingVariableDefinitionInput.cs index 52fa613..95af8a9 100644 --- a/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/OnboardingVariableDefinitionInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/OnboardingVariableDefinitionInput.cs @@ -2,32 +2,79 @@ namespace SimpleAdmin.Application; +/// +/// 合同变量字典分页查询参数。 +/// public class OnboardingVariableDefinitionPageInput : BasePageInput { + /// + /// 变量编码或名称关键字。 + /// public string Keyword { get; set; } + + /// + /// 变量分类:employee、hr、company 或 system。 + /// public string VariableCategory { get; set; } } +/// +/// 新增合同变量定义参数。 +/// public class OnboardingVariableDefinitionAddInput { + /// + /// 完整变量编码,例如 employee.EmployeeName 或 hr.BaseSalary。 + /// [Required(ErrorMessage = "变量编码不能为空")] public string VariableCode { get; set; } + /// + /// 后台展示的变量名称。 + /// [Required(ErrorMessage = "变量名称不能为空")] public string VariableName { get; set; } + /// + /// 变量分类:employee、hr、company 或 system。 + /// [Required(ErrorMessage = "变量分类不能为空")] public string VariableCategory { get; set; } + /// + /// 数据类型:string、number 或 date。 + /// public string DataType { get; set; } = "string"; + + /// + /// 未提供变量值时使用的默认值。 + /// public string DefaultValue { get; set; } + + /// + /// 是否必须填写。 + /// public bool Required { get; set; } + + /// + /// 展示排序值。 + /// public int SortCode { get; set; } = 100; + + /// + /// 变量用途说明。 + /// public string Remark { get; set; } } +/// +/// 编辑合同变量定义参数。 +/// public class OnboardingVariableDefinitionEditInput : OnboardingVariableDefinitionAddInput { + /// + /// 变量定义主键。 + /// [IdNotNull(ErrorMessage = "Id不能为空")] public long Id { get; set; } -} \ No newline at end of file +} diff --git a/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/OnboardingVariableDefinitionService.cs b/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/OnboardingVariableDefinitionService.cs index cdd43a2..6b55c66 100644 --- a/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/OnboardingVariableDefinitionService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/VariableDefinition/OnboardingVariableDefinitionService.cs @@ -1,26 +1,37 @@ namespace SimpleAdmin.Application; +/// +/// 实现合同变量字典的查询和维护。 +/// public class OnboardingVariableDefinitionService : IOnboardingVariableDefinitionService { private readonly SqlSugarScope _db; - private static bool _initialized = false; - private static readonly object _lock = new(); + /// + /// 初始化合同变量字典服务。 + /// public OnboardingVariableDefinitionService() { _db = DbContext.DB; } + /// public async Task> Page(OnboardingVariableDefinitionPageInput input) { return await _db.Queryable() - .WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), it => it.VariableCode.Contains(input.Keyword) || it.VariableName.Contains(input.Keyword)) - .WhereIF(!string.IsNullOrWhiteSpace(input.VariableCategory), it => it.VariableCategory == input.VariableCategory) + .WhereIF( + !string.IsNullOrWhiteSpace(input.Keyword), + item => item.VariableCode.Contains(input.Keyword) + || item.VariableName.Contains(input.Keyword)) + .WhereIF( + !string.IsNullOrWhiteSpace(input.VariableCategory), + item => item.VariableCategory == input.VariableCategory) .OrderBy(it => it.SortCode) .OrderBy(it => it.Id) .ToPagedListAsync(input.PageNum, input.PageSize); } + /// public async Task> ListAll() { return await _db.Queryable() @@ -29,26 +40,34 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition .ToListAsync(); } + /// public async Task Add(OnboardingVariableDefinitionAddInput input) { - var code = input.VariableCode.Trim(); - var exists = await _db.Queryable().AnyAsync(it => it.VariableCode == code); + NormalizeAndValidate(input); + + var exists = await _db.Queryable() + .AnyAsync(item => item.VariableCode == input.VariableCode); if (exists) - throw Oops.Bah($"变量编码“{code}”已存在"); + throw Oops.Bah($"变量编码“{input.VariableCode}”已存在"); var entity = input.Adapt(); - entity.VariableCode = code; entity.IsBuiltIn = false; await _db.Insertable(entity).ExecuteCommandAsync(); } + /// public async Task Edit(OnboardingVariableDefinitionEditInput input) { + NormalizeAndValidate(input); + var entity = await _db.Queryable().InSingleAsync(input.Id) ?? throw Oops.Bah("变量不存在"); + if (!string.Equals(entity.VariableCode, input.VariableCode, StringComparison.Ordinal)) + throw Oops.Bah("变量编码创建后不能修改"); + entity.VariableName = input.VariableName; - entity.DataType = input.DataType ?? "string"; + entity.DataType = input.DataType; entity.DefaultValue = input.DefaultValue; entity.Required = input.Required; entity.SortCode = input.SortCode; @@ -61,9 +80,11 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition await _db.Updateable(entity).ExecuteCommandAsync(); } + /// public async Task Delete(BaseIdListInput input) { - if (input.Ids.Count == 0) return; + if (input.Ids.Count == 0) + return; var hasBuiltIn = await _db.Queryable() .Where(it => input.Ids.Contains(it.Id) && it.IsBuiltIn) @@ -75,4 +96,40 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition .Where(it => input.Ids.Contains(it.Id)) .ExecuteCommandAsync(); } -} \ No newline at end of file + + /// + /// 规范化并校验合同变量定义,防止无效编码在生成合同时才暴露。 + /// + private static void NormalizeAndValidate(OnboardingVariableDefinitionAddInput input) + { + input.VariableCode = input.VariableCode?.Trim(); + input.VariableName = input.VariableName?.Trim(); + input.VariableCategory = input.VariableCategory?.Trim().ToLowerInvariant(); + input.DataType = string.IsNullOrWhiteSpace(input.DataType) + ? "string" + : input.DataType.Trim().ToLowerInvariant(); + input.DefaultValue = input.DefaultValue?.Trim(); + input.Remark = input.Remark?.Trim(); + + if (string.IsNullOrWhiteSpace(input.VariableCode)) + throw Oops.Bah("变量编码不能为空"); + + if (string.IsNullOrWhiteSpace(input.VariableName)) + throw Oops.Bah("变量名称不能为空"); + + var validCategories = new[] { "employee", "hr", "company", "system" }; + if (!validCategories.Contains(input.VariableCategory)) + throw Oops.Bah("变量分类只能是员工填写、人事填写、公司信息或系统生成"); + + var codePattern = $@"^{input.VariableCategory}\.[A-Za-z][A-Za-z0-9_]*$"; + var codeIsValid = global::System.Text.RegularExpressions.Regex.IsMatch( + input.VariableCode, + codePattern); + if (!codeIsValid) + throw Oops.Bah($"变量编码必须以“{input.VariableCategory}.”开头,后半段只能包含字母、数字和下划线"); + + var validDataTypes = new[] { "string", "number", "date" }; + if (!validDataTypes.Contains(input.DataType)) + throw Oops.Bah("数据类型只能是文本、数字或日期"); + } +} diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs index c8dd429..84f7e24 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs @@ -55,6 +55,15 @@ public class OnboardingContractTemplateController return await _service.Detail(input); } + /// + /// 查询入职模板当前公司、部门及岗位可选择的合同模板。 + /// + [HttpPost("list-available")] + public async Task ListAvailable([FromBody] OnboardingContractTemplateAvailableInput input) + { + return await _service.ListAvailable(input); + } + /// /// 新增合同模板。 /// @@ -135,4 +144,4 @@ public class OnboardingContractTemplateController { await _service.EditVariable(input); } -} \ No newline at end of file +} diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingDepartmentController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingDepartmentController.cs new file mode 100644 index 0000000..5ac31f7 --- /dev/null +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingDepartmentController.cs @@ -0,0 +1,71 @@ +namespace SimpleAdmin.Web.Core; + +/// +/// 提供入职业务部门相关 HTTP 接口。 +/// +[ApiDescriptionSettings("Application", Tag = "入职部门管理")] +[Route("biz/onboarding/department")] +[RolePermission] +public class OnboardingDepartmentController +{ + private readonly IOnboardingDepartmentService _service; + + public OnboardingDepartmentController(IOnboardingDepartmentService service) + { + _service = service; + } + + /// + /// 分页查询入职业务部门。 + /// + [HttpGet("page")] + public async Task Page([FromQuery] OnboardingDepartmentPageInput input) + { + return await _service.Page(input); + } + + /// + /// 查询指定公司的已启用部门。 + /// + [HttpGet("list-enabled")] + public async Task ListEnabled([FromQuery] long companyId) + { + return await _service.ListEnabled(companyId); + } + + /// + /// 查询部门详情。 + /// + [HttpGet("detail")] + public async Task Detail([FromQuery] BaseIdInput input) + { + return await _service.Detail(input); + } + + /// + /// 新增入职业务部门。 + /// + [HttpPost("add")] + public async Task Add([FromBody] OnboardingDepartmentAddInput input) + { + await _service.Add(input); + } + + /// + /// 编辑入职业务部门。 + /// + [HttpPost("edit")] + public async Task Edit([FromBody] OnboardingDepartmentEditInput input) + { + await _service.Edit(input); + } + + /// + /// 批量删除入职业务部门。 + /// + [HttpPost("delete")] + public async Task Delete([FromBody] BaseIdListInput input) + { + await _service.Delete(input); + } +} \ No newline at end of file diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPositionController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPositionController.cs index b56bf75..c0d3dd3 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPositionController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPositionController.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Web.Core; +namespace SimpleAdmin.Web.Core; /// /// 提供入职业务岗位相关 HTTP 接口。 @@ -33,6 +33,15 @@ public class OnboardingPositionController return await _service.ListEnabled(companyId); } + /// + /// 查询指定部门的已启用岗位。 + /// + [HttpGet("list-by-department")] + public async Task ListByDepartment([FromQuery] long departmentId) + { + return await _service.ListEnabledByDepartment(departmentId); + } + /// /// 查询岗位详情。 /// @@ -61,11 +70,11 @@ public class OnboardingPositionController } /// - /// 删除入职业务岗位。 + /// 批量删除入职业务岗位。 /// [HttpPost("delete")] public async Task Delete([FromBody] BaseIdListInput input) { await _service.Delete(input); } -} +} \ No newline at end of file diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPublicSubmissionController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPublicSubmissionController.cs index 998fef0..c63435e 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPublicSubmissionController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingPublicSubmissionController.cs @@ -17,24 +17,6 @@ public class OnboardingPublicSubmissionController _fileService = fileService; } - /// - /// 保存员工动态字段。 - /// - [HttpPost("save-dynamic-values")] - public async Task SaveDynamicValues([FromBody] OnboardingSaveDynamicValuesInput input) - { - await _service.SaveDynamicValues(input); - } - - /// - /// 保存员工已上传材料记录。 - /// - [HttpPost("save-material")] - public async Task SaveMaterial([FromBody] OnboardingSaveMaterialInput input) - { - await _service.SaveMaterial(input); - } - /// /// 删除当前入职任务中的一条已上传材料。 /// diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTemplateController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTemplateController.cs index 0e8f0ef..f9f12ea 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTemplateController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingTemplateController.cs @@ -52,11 +52,14 @@ public class OnboardingTemplateController } /// - /// 查询指定公司岗位可使用的入职模板。 + /// 查询指定公司、部门和岗位可使用的入职模板。 /// [HttpGet("list-enabled")] - public async Task ListEnabled([FromQuery] long companyId, [FromQuery] long positionId) + public async Task ListEnabled( + [FromQuery] long companyId, + [FromQuery] long departmentId, + [FromQuery] long positionId) { - return await _service.ListEnabled(companyId, positionId); + return await _service.ListEnabled(companyId, departmentId, positionId); } -} \ No newline at end of file +} diff --git a/database/migrations/20260826_onboarding_department_migration.sql b/database/migrations/20260826_onboarding_department_migration.sql new file mode 100644 index 0000000..7eb1ff4 --- /dev/null +++ b/database/migrations/20260826_onboarding_department_migration.sql @@ -0,0 +1,80 @@ +-- OnboardX 部门模块独立维护及层级关联迁移脚本 +-- 适用数据库:MySQL 8.0+ +-- 包含: +-- 1. 新建 ob_department 表 +-- 2. 在 ob_position / ob_onboarding_task / ob_employee 中补充 DepartmentId 字段 + +SET NAMES utf8mb4; + +-- 1. 新建入职业务部门表 +CREATE TABLE IF NOT EXISTS `ob_department` ( + `Id` BIGINT NOT NULL COMMENT '主键', + `CompanyId` BIGINT NOT NULL COMMENT '所属业务公司Id', + `DepartmentCode` VARCHAR(50) NOT NULL COMMENT '部门编码', + `DepartmentName` VARCHAR(200) NOT NULL COMMENT '部门名称', + `SortCode` INT NOT NULL DEFAULT 99 COMMENT '显示排序', + `Remark` VARCHAR(500) NULL COMMENT '部门说明', + `ExtJson` LONGTEXT NULL COMMENT '扩展信息', + `Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态', + `CreateTime` DATETIME(3) NULL COMMENT '创建时间', + `UpdateTime` DATETIME(3) NULL COMMENT '更新时间', + `CreateUserId` BIGINT NULL COMMENT '创建者Id', + `UpdateUserId` BIGINT NULL COMMENT '修改者Id', + `CreateUser` VARCHAR(200) NULL COMMENT '创建人', + `UpdateUser` VARCHAR(200) NULL COMMENT '更新人', + `IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_department_company_code` (`CompanyId`, `DepartmentCode`), + KEY `idx_ob_department_company_status` (`CompanyId`, `Status`, `IsDelete`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职业务部门'; + +-- 2. 定义安全补列存储过程(防重复执行) +DROP PROCEDURE IF EXISTS `ob_add_column_if_missing`; +DELIMITER $$ +CREATE PROCEDURE `ob_add_column_if_missing`( + IN p_table_name VARCHAR(128), + IN p_column_name VARCHAR(128), + IN p_column_definition VARCHAR(1000) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = p_table_name + AND COLUMN_NAME = p_column_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_column_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +-- 3. 在岗位表中新增 DepartmentId 字段 +CALL `ob_add_column_if_missing`( + 'ob_position', + 'DepartmentId', + 'BIGINT NULL COMMENT ''所属部门Id'' AFTER `CompanyId`' +); + +-- 4. 在入职任务表中新增 DepartmentId 字段 +CALL `ob_add_column_if_missing`( + 'ob_onboarding_task', + 'DepartmentId', + 'BIGINT NULL COMMENT ''所属部门Id'' AFTER `CompanyId`' +); + +-- 5. 在正式员工档案表中新增 DepartmentId 字段 +CALL `ob_add_column_if_missing`( + 'ob_employee', + 'DepartmentId', + 'BIGINT NULL COMMENT ''所属部门Id'' AFTER `CompanyId`' +); + +-- 清理临时存储过程 +DROP PROCEDURE IF EXISTS `ob_add_column_if_missing`; \ No newline at end of file diff --git a/database/migrations/20260827_onboarding_template_contract_scope_migration.sql b/database/migrations/20260827_onboarding_template_contract_scope_migration.sql new file mode 100644 index 0000000..3947477 --- /dev/null +++ b/database/migrations/20260827_onboarding_template_contract_scope_migration.sql @@ -0,0 +1,343 @@ +-- OnboardX 公司、部门、岗位、合同模板及入职模板层级升级脚本 +-- 适用数据库:MySQL 8.0+ +-- 说明: +-- 1. 可重复执行,不会覆盖已经人工维护的归属数据。 +-- 2. 仅对能够从现有岗位/入职模板关系中唯一确定的数据执行安全回填。 +-- 3. 脚本末尾会输出仍需人工处理的数据,请先处理完再启用相关模板。 + +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS `ob_scope_add_column_if_missing`; +DELIMITER $$ +CREATE PROCEDURE `ob_scope_add_column_if_missing`( + IN p_table_name VARCHAR(128), + IN p_column_name VARCHAR(128), + IN p_column_definition VARCHAR(1000) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = p_table_name + AND COLUMN_NAME = p_column_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', p_table_name, '` ADD COLUMN `', + p_column_name, '` ', p_column_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +DROP PROCEDURE IF EXISTS `ob_scope_add_index_if_missing`; +DELIMITER $$ +CREATE PROCEDURE `ob_scope_add_index_if_missing`( + IN p_table_name VARCHAR(128), + IN p_index_name VARCHAR(128), + IN p_index_definition VARCHAR(1000) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = p_table_name + AND INDEX_NAME = p_index_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', p_table_name, '` ADD ', p_index_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +DROP PROCEDURE IF EXISTS `ob_scope_add_unique_index_if_clean`; +DELIMITER $$ +CREATE PROCEDURE `ob_scope_add_unique_index_if_clean`( + IN p_table_name VARCHAR(128), + IN p_column_name VARCHAR(128), + IN p_index_name VARCHAR(128) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = p_table_name + AND INDEX_NAME = p_index_name + ) THEN + SET @duplicate_count = 0; + SET @check_sql = CONCAT( + 'SELECT COUNT(*) INTO @duplicate_count FROM (', + 'SELECT `', p_column_name, '` FROM `', p_table_name, '` ', + 'WHERE `', p_column_name, '` IS NOT NULL ', + 'AND LENGTH(TRIM(`', p_column_name, '`)) > 0 ', + 'GROUP BY `', p_column_name, '` HAVING COUNT(*) > 1', + ') duplicated_values' + ); + PREPARE stmt FROM @check_sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + + IF @duplicate_count = 0 THEN + SET @ddl = CONCAT( + 'ALTER TABLE `', p_table_name, '` ADD UNIQUE INDEX `', + p_index_name, '` (`', p_column_name, '`)' + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; + END IF; +END$$ +DELIMITER ; + +-- 入职模板增加所属部门。 +CALL `ob_scope_add_column_if_missing`( + 'ob_onboarding_template', + 'DepartmentId', + 'BIGINT NULL COMMENT ''所属部门Id'' AFTER `CompanyId`' +); + +-- 合同模板增加所属公司和部门。暂时允许 NULL,以兼容待人工归类的历史模板。 +CALL `ob_scope_add_column_if_missing`( + 'ob_contract_template', + 'CompanyId', + 'BIGINT NULL COMMENT ''所属公司Id'' AFTER `Id`' +); +CALL `ob_scope_add_column_if_missing`( + 'ob_contract_template', + 'DepartmentId', + 'BIGINT NULL COMMENT ''所属部门Id'' AFTER `CompanyId`' +); + +-- 兼容尚未执行上一版部门迁移的数据库,重复调用不会新增重复字段。 +CALL `ob_scope_add_column_if_missing`( + 'ob_position', + 'DepartmentId', + 'BIGINT NULL COMMENT ''所属部门Id'' AFTER `CompanyId`' +); +CALL `ob_scope_add_column_if_missing`( + 'ob_onboarding_task', + 'DepartmentId', + 'BIGINT NULL COMMENT ''所属部门Id'' AFTER `CompanyId`' +); +CALL `ob_scope_add_column_if_missing`( + 'ob_employee', + 'DepartmentId', + 'BIGINT NULL COMMENT ''所属部门Id'' AFTER `CompanyId`' +); + +-- 合同模板与岗位为多对多关系,一份通用合同可同时适用于多个岗位。 +CREATE TABLE IF NOT EXISTS `ob_contract_template_position` ( + `Id` BIGINT NOT NULL COMMENT '主键', + `ContractTemplateId` BIGINT NOT NULL COMMENT '合同模板Id', + `PositionId` BIGINT NOT NULL COMMENT '适用岗位Id', + `ExtJson` LONGTEXT NULL COMMENT '扩展信息', + `Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态', + `CreateTime` DATETIME(3) NULL COMMENT '创建时间', + `UpdateTime` DATETIME(3) NULL COMMENT '更新时间', + `CreateUserId` BIGINT NULL COMMENT '创建者Id', + `UpdateUserId` BIGINT NULL COMMENT '修改者Id', + `CreateUser` VARCHAR(200) NULL COMMENT '创建人', + `UpdateUser` VARCHAR(200) NULL COMMENT '更新人', + `IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_contract_template_position` (`ContractTemplateId`, `PositionId`), + KEY `idx_ob_contract_template_position_position` (`PositionId`, `ContractTemplateId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合同模板适用岗位'; + +CALL `ob_scope_add_index_if_missing`( + 'ob_position', + 'idx_ob_position_department_status', + 'INDEX `idx_ob_position_department_status` (`DepartmentId`, `Status`, `IsDelete`)' +); +CALL `ob_scope_add_index_if_missing`( + 'ob_onboarding_template', + 'idx_ob_onboarding_template_scope', + 'INDEX `idx_ob_onboarding_template_scope` (`CompanyId`, `DepartmentId`, `Status`, `IsDelete`)' +); +CALL `ob_scope_add_index_if_missing`( + 'ob_contract_template', + 'idx_ob_contract_template_scope', + 'INDEX `idx_ob_contract_template_scope` (`CompanyId`, `DepartmentId`, `Status`, `IsDelete`)' +); +CALL `ob_scope_add_index_if_missing`( + 'ob_onboarding_task', + 'idx_ob_onboarding_task_department', + 'INDEX `idx_ob_onboarding_task_department` (`DepartmentId`, `TaskStatus`, `IsDelete`)' +); +CALL `ob_scope_add_index_if_missing`( + 'ob_employee', + 'idx_ob_employee_department', + 'INDEX `idx_ob_employee_department` (`DepartmentId`, `IsDelete`)' +); + +-- 身份证号和手机号是正式员工防重复的数据库最终防线。 +-- 若历史数据已经重复,本次会安全跳过建索引,并由脚本末尾查询列出冲突数据。 +CALL `ob_scope_add_unique_index_if_clean`( + 'ob_employee', + 'IdCardNumber', + 'uk_ob_employee_id_card' +); +CALL `ob_scope_add_unique_index_if_clean`( + 'ob_employee', + 'Phone', + 'uk_ob_employee_phone' +); + +-- 安全回填入职模板部门:只有全部适用岗位均属于同一个非空部门时才回填。 +UPDATE `ob_onboarding_template` template +INNER JOIN ( + SELECT + relation.`OnboardingTemplateId`, + MIN(position.`DepartmentId`) AS `DepartmentId` + FROM `ob_onboarding_template_position` relation + INNER JOIN `ob_position` position ON position.`Id` = relation.`PositionId` + WHERE COALESCE(relation.`IsDelete`, 0) = 0 + AND COALESCE(position.`IsDelete`, 0) = 0 + GROUP BY relation.`OnboardingTemplateId` + HAVING COUNT(*) > 0 + AND COUNT(DISTINCT position.`DepartmentId`) = 1 + AND SUM(position.`DepartmentId` IS NULL) = 0 +) source ON source.`OnboardingTemplateId` = template.`Id` +SET template.`DepartmentId` = source.`DepartmentId` +WHERE template.`DepartmentId` IS NULL; + +-- 安全回填合同模板公司、部门:只有历史引用的入职模板归属完全一致时才回填。 +UPDATE `ob_contract_template` contract_template +INNER JOIN ( + SELECT + relation.`ContractTemplateId`, + MIN(template.`CompanyId`) AS `CompanyId`, + MIN(template.`DepartmentId`) AS `DepartmentId` + FROM `ob_onboarding_template_contract` relation + INNER JOIN `ob_onboarding_template` template + ON template.`Id` = relation.`OnboardingTemplateId` + WHERE COALESCE(relation.`IsDelete`, 0) = 0 + AND COALESCE(template.`IsDelete`, 0) = 0 + GROUP BY relation.`ContractTemplateId` + HAVING COUNT(DISTINCT template.`CompanyId`) = 1 + AND COUNT(DISTINCT template.`DepartmentId`) = 1 + AND SUM(template.`DepartmentId` IS NULL) = 0 +) source ON source.`ContractTemplateId` = contract_template.`Id` +SET contract_template.`CompanyId` = COALESCE(contract_template.`CompanyId`, source.`CompanyId`), + contract_template.`DepartmentId` = COALESCE(contract_template.`DepartmentId`, source.`DepartmentId`) +WHERE contract_template.`CompanyId` IS NULL + OR contract_template.`DepartmentId` IS NULL; + +-- 根据历史入职模板关系,回填合同模板已经实际使用过的岗位范围。 +INSERT IGNORE INTO `ob_contract_template_position` ( + `Id`, + `ContractTemplateId`, + `PositionId`, + `Status`, + `CreateTime`, + `IsDelete` +) +SELECT DISTINCT + CAST(CONV(SUBSTRING(MD5(CONCAT( + 'obctp:', contract_relation.`ContractTemplateId`, ':', position_relation.`PositionId` + )), 1, 15), 16, 10) AS UNSIGNED) AS `Id`, + contract_relation.`ContractTemplateId`, + position_relation.`PositionId`, + 'enable', + NOW(3), + 0 +FROM `ob_onboarding_template_contract` contract_relation +INNER JOIN `ob_onboarding_template_position` position_relation + ON position_relation.`OnboardingTemplateId` = contract_relation.`OnboardingTemplateId` +INNER JOIN `ob_contract_template` contract_template + ON contract_template.`Id` = contract_relation.`ContractTemplateId` +INNER JOIN `ob_position` position + ON position.`Id` = position_relation.`PositionId` +WHERE COALESCE(contract_relation.`IsDelete`, 0) = 0 + AND COALESCE(position_relation.`IsDelete`, 0) = 0 + AND contract_template.`CompanyId` = position.`CompanyId` + AND contract_template.`DepartmentId` = position.`DepartmentId`; + +DROP PROCEDURE IF EXISTS `ob_scope_add_unique_index_if_clean`; +DROP PROCEDURE IF EXISTS `ob_scope_add_index_if_missing`; +DROP PROCEDURE IF EXISTS `ob_scope_add_column_if_missing`; + +-- 结果 1:仍未明确部门归属的岗位。必须在岗位管理中补齐。 +SELECT + position.`Id`, + position.`PositionCode`, + position.`PositionName`, + position.`CompanyId` +FROM `ob_position` position +WHERE position.`DepartmentId` IS NULL + AND COALESCE(position.`IsDelete`, 0) = 0; + +-- 结果 2:仍未明确部门归属的入职模板。必须编辑并重新保存。 +SELECT + template.`Id`, + template.`TemplateCode`, + template.`TemplateName`, + template.`CompanyId` +FROM `ob_onboarding_template` template +WHERE template.`DepartmentId` IS NULL + AND COALESCE(template.`IsDelete`, 0) = 0; + +-- 结果 3:仍未明确公司/部门或没有适用岗位的合同模板。必须编辑并重新保存。 +SELECT + contract_template.`Id`, + contract_template.`TemplateCode`, + contract_template.`TemplateName`, + contract_template.`CompanyId`, + contract_template.`DepartmentId`, + COUNT(position_relation.`Id`) AS `PositionCount` +FROM `ob_contract_template` contract_template +LEFT JOIN `ob_contract_template_position` position_relation + ON position_relation.`ContractTemplateId` = contract_template.`Id` + AND COALESCE(position_relation.`IsDelete`, 0) = 0 +WHERE COALESCE(contract_template.`IsDelete`, 0) = 0 +GROUP BY + contract_template.`Id`, + contract_template.`TemplateCode`, + contract_template.`TemplateName`, + contract_template.`CompanyId`, + contract_template.`DepartmentId` +HAVING contract_template.`CompanyId` IS NULL + OR contract_template.`DepartmentId` IS NULL + OR COUNT(position_relation.`Id`) = 0; + +-- 结果 4:检查层级不一致的数据,正常情况下应返回 0 行。 +SELECT + contract_template.`Id` AS `ContractTemplateId`, + contract_template.`TemplateName`, + position.`Id` AS `PositionId`, + position.`PositionName` +FROM `ob_contract_template` contract_template +INNER JOIN `ob_contract_template_position` relation + ON relation.`ContractTemplateId` = contract_template.`Id` +INNER JOIN `ob_position` position ON position.`Id` = relation.`PositionId` +WHERE contract_template.`CompanyId` <> position.`CompanyId` + OR contract_template.`DepartmentId` <> position.`DepartmentId`; + +-- 结果 5:正式员工身份证号或手机号重复时,唯一索引会被安全跳过;请清理后重新运行脚本。 +SELECT + 'IdCardNumber' AS `ConflictField`, + employee.`IdCardNumber` AS `ConflictValue`, + COUNT(*) AS `ConflictCount` +FROM `ob_employee` employee +WHERE NULLIF(TRIM(employee.`IdCardNumber`), '') IS NOT NULL +GROUP BY employee.`IdCardNumber` +HAVING COUNT(*) > 1 +UNION ALL +SELECT + 'Phone' AS `ConflictField`, + employee.`Phone` AS `ConflictValue`, + COUNT(*) AS `ConflictCount` +FROM `ob_employee` employee +WHERE NULLIF(TRIM(employee.`Phone`), '') IS NOT NULL +GROUP BY employee.`Phone` +HAVING COUNT(*) > 1;