Compare commits

...
6 Commits
Author SHA1 Message Date
fuenmao 9ae5e0bc54 11 2026-08-26 11:00:15 +08:00
fuenmao be20a31332 111 2026-08-25 20:37:17 +08:00
fuenmao 38801533e9 调整 2026-08-25 13:54:16 +08:00
fuenmao 085bf5e2ab 逻辑调整 2026-08-25 10:20:53 +08:00
fuenmao 4679bd6c13 oss板块 2026-08-24 18:16:08 +08:00
fuenmao 0931e1f343 相关bug调整 2026-08-24 16:36:47 +08:00
65 changed files with 4344 additions and 1443 deletions
@@ -37,6 +37,11 @@ public class ObOnboardingTask : BaseEntity
/// </summary> /// </summary>
public long PositionId { get; set; } public long PositionId { get; set; }
/// <summary>
/// 创建任务时锁定的入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary> /// <summary>
/// 创建任务时指定的审核人用户 Id。 /// 创建任务时指定的审核人用户 Id。
/// </summary> /// </summary>
@@ -0,0 +1,29 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板,统一组织公司、岗位、合同、补充字段和上传材料。
/// </summary>
[SugarTable("ob_onboarding_template", TableDescription = "入职模板")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplate : BaseEntity
{
/// <summary>
/// 入职模板业务编码。
/// </summary>
public string TemplateCode { get; set; }
/// <summary>
/// 入职模板显示名称。
/// </summary>
public string TemplateName { get; set; }
/// <summary>
/// 所属入职业务公司 Id。
/// </summary>
public long CompanyId { get; set; }
/// <summary>
/// 模板用途和适用范围说明。
/// </summary>
public string Description { get; set; }
}
@@ -0,0 +1,39 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板需要使用的合同文件及锁定版本。
/// </summary>
[SugarTable("ob_onboarding_template_contract", TableDescription = "入职模板合同配置")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplateContract : BaseEntity
{
/// <summary>
/// 入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 合同模板主档 Id。
/// </summary>
public long ContractTemplateId { get; set; }
/// <summary>
/// 锁定的合同模板版本 Id。
/// </summary>
public long ContractTemplateVersionId { get; set; }
/// <summary>
/// 员工是否必须查看并确认该合同。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 合同打印份数。
/// </summary>
public int PrintCopies { get; set; } = 1;
/// <summary>
/// 显示和生成顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -0,0 +1,29 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板需要员工填写的动态补充字段。
/// </summary>
[SugarTable("ob_onboarding_template_form_field", TableDescription = "入职模板补充字段")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplateFormField : BaseEntity
{
/// <summary>
/// 入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 动态补充字段 Id。
/// </summary>
public long FieldId { get; set; }
/// <summary>
/// 员工是否必须填写。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 手机端显示顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -0,0 +1,29 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板需要员工上传的材料配置。
/// </summary>
[SugarTable("ob_onboarding_template_material", TableDescription = "入职模板上传材料")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplateMaterial : BaseEntity
{
/// <summary>
/// 入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 上传材料类型 Id。
/// </summary>
public long MaterialTypeId { get; set; }
/// <summary>
/// 员工是否必须上传。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 手机端显示顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -0,0 +1,19 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板适用岗位关系。
/// </summary>
[SugarTable("ob_onboarding_template_position", TableDescription = "入职模板适用岗位")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplatePosition : BaseEntity
{
/// <summary>
/// 入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 适用岗位 Id。
/// </summary>
public long PositionId { get; set; }
}
@@ -47,6 +47,11 @@ public class ObTaskContract : BaseEntity
/// </summary> /// </summary>
public bool Required { get; set; } public bool Required { get; set; }
/// <summary>
/// 任务创建时冻结的合同打印份数。
/// </summary>
public int PrintCopies { get; set; } = 1;
/// <summary> /// <summary>
/// 任务合同当前状态。 /// 任务合同当前状态。
/// </summary> /// </summary>
@@ -11,9 +11,9 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 候选人在入职任务中提交的固定人员基础信息。 /// 候选人在入职任务中提交的《入职登记表》标准员工档案信息。
/// </summary> /// </summary>
[SugarTable("ob_task_employee_info", TableDescription = "候选人提交的固定基础信息")] [SugarTable("ob_task_employee_info", TableDescription = "候选人填写的入职登记表")]
[Tenant(SqlSugarConst.DB_DEFAULT)] [Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObTaskEmployeeInfo : BaseEntity public class ObTaskEmployeeInfo : BaseEntity
{ {
@@ -23,45 +23,57 @@ public class ObTaskEmployeeInfo : BaseEntity
public long TaskId { get; set; } public long TaskId { get; set; }
/// <summary> /// <summary>
/// 员工编号。 /// 员工编号(选填/人事分配)。
/// </summary> /// </summary>
public string EmployeeNo { get; set; } public string EmployeeNo { get; set; }
/// <summary> // ==================== 1. 基本个人信息 ====================
/// 内部人员编号。
/// </summary>
public string InternalNo { get; set; }
/// <summary> /// <summary>
/// 员工姓名。 /// 员工姓名。
/// </summary> /// </summary>
public string EmployeeName { get; set; } public string EmployeeName { get; set; }
/// <summary>
/// 所属部门名称。
/// </summary>
public string DepartmentName { get; set; }
/// <summary> /// <summary>
/// 性别。 /// 性别。
/// </summary> /// </summary>
public string Gender { get; set; } public string Gender { get; set; }
/// <summary> /// <summary>
/// 手机号码。 /// 民族。
/// </summary> /// </summary>
public string Phone { get; set; } public string Nation { get; set; }
/// <summary> /// <summary>
/// 身份证号码。 /// 出生年月日。
/// </summary> /// </summary>
public string IdCardNumber { get; set; } public string Birthday { get; set; }
/// <summary> /// <summary>
/// 毕业院校。 /// 户籍所在地。
/// </summary> /// </summary>
public string College { get; set; } public string RegisteredResidence { get; set; }
/// <summary>
/// 身高 (cm)。
/// </summary>
public string Height { get; set; }
/// <summary>
/// 体重 (kg)。
/// </summary>
public string Weight { get; set; }
/// <summary>
/// 健康状况。
/// </summary>
public string HealthCondition { get; set; }
/// <summary>
/// 婚育情况。
/// </summary>
public string MaritalStatus { get; set; }
// ==================== 2. 学历与资质 ====================
/// <summary> /// <summary>
/// 最高学历。 /// 最高学历。
/// </summary> /// </summary>
@@ -73,82 +85,125 @@ public class ObTaskEmployeeInfo : BaseEntity
public string Major { get; set; } public string Major { get; set; }
/// <summary> /// <summary>
/// 出生日期。 /// 毕业院校。
/// </summary> /// </summary>
public DateTime? BirthDate { get; set; } public string GraduatedSchool { get; set; }
/// <summary> /// <summary>
/// 户籍所在地。 /// 职业资格。
/// </summary> /// </summary>
public string RegisteredResidence { get; set; } public string VocationalQualification { get; set; }
/// <summary> /// <summary>
/// 民族。 /// 资格等级。
/// </summary> /// </summary>
public string Nation { get; set; } public string QualificationLevel { get; set; }
/// <summary> /// <summary>
/// 现居住或联系地址。 /// 有何技术特长。
/// </summary> /// </summary>
public string ContactAddress { get; set; } public string TechnicalSpecialty { get; set; }
// ==================== 3. 联系方式与薪酬账户 ====================
/// <summary>
/// 联系电话/手机号。
/// </summary>
public string Phone { get; set; }
/// <summary> /// <summary>
/// 入职日期。 /// 电子邮箱。
/// </summary> /// </summary>
public DateTime? EntryDate { get; set; } public string Email { get; set; }
/// <summary> /// <summary>
/// 合同开始日期。 /// 身份证号码。
/// </summary> /// </summary>
public DateTime? ContractStartDate { get; set; } public string IdCardNumber { get; set; }
/// <summary> /// <summary>
/// 合同结束日期。 /// 暂住地址 / 现居住地址。
/// </summary> /// </summary>
public DateTime? ContractEndDate { get; set; } public string CurrentAddress { get; set; }
/// <summary> /// <summary>
/// 试用期开始日期。 /// 房东联系方式。
/// </summary> /// </summary>
public DateTime? ProbationStartDate { get; set; } public string LandlordContact { get; set; }
/// <summary>
/// 试用期结束日期。
/// </summary>
public DateTime? ProbationEndDate { get; set; }
/// <summary>
/// 转正日期。
/// </summary>
public DateTime? RegularizationDate { get; set; }
/// <summary>
/// 社保缴纳公司。
/// </summary>
public string SocialInsuranceCompany { get; set; }
/// <summary>
/// 合同性质。
/// </summary>
public string ContractNature { get; set; }
/// <summary> /// <summary>
/// 工资银行卡号。 /// 工资银行卡号。
/// </summary> /// </summary>
public string BankCardNumber { get; set; } public string BankCardNumber { get; set; }
/// <summary>
/// 银行卡开户名。
/// </summary>
public string BankAccountName { get; set; }
/// <summary> /// <summary>
/// 开户银行名称。 /// 开户银行名称。
/// </summary> /// </summary>
public string BankName { get; set; } public string BankName { get; set; }
/// <summary> /// <summary>
/// 业务备注。 /// 银行卡开户人姓名。
/// </summary> /// </summary>
public string Remark { get; set; } public string BankAccountName { get; set; }
// ==================== 4. 紧急联系人 ====================
/// <summary>
/// 紧急联系人姓名。
/// </summary>
public string EmergencyContact { get; set; }
/// <summary>
/// 紧急联系人电话。
/// </summary>
public string EmergencyPhone { get; set; }
/// <summary>
/// 紧急联系人联系地址。
/// </summary>
public string EmergencyAddress { get; set; }
// ==================== 5. 动态多条经历(JSON 存储) ====================
/// <summary>
/// 教育学习经历 JSON 列表: [ { timeRange, location, content, result } ]
/// </summary>
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string EducationHistoryJson { get; set; }
/// <summary>
/// 主要工作经历 JSON 列表: [ { timeRange, companyName, salary, position, leaveReason } ]
/// </summary>
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string WorkHistoryJson { get; set; }
/// <summary>
/// 家庭成员 JSON 列表: [ { name, relationship, age, company, phone } ]
/// </summary>
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string FamilyMembersJson { get; set; }
// ==================== 6. 入职情况与说明 ====================
/// <summary>
/// 入职部门。
/// </summary>
public string OnboardingDepartment { get; set; }
/// <summary>
/// 入职时间。
/// </summary>
public string OnboardingDate { get; set; }
/// <summary>
/// 入职岗位。
/// </summary>
public string OnboardingPosition { get; set; }
/// <summary>
/// 其他说明。
/// </summary>
public string OtherRemarks { get; set; }
/// <summary>
/// 员工补充自定义变量键值 JSON: { "VariableName": "Value" }
/// </summary>
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string CustomVariablesJson { get; set; }
} }
@@ -0,0 +1,49 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职任务创建时冻结的动态补充字段要求。
/// </summary>
[SugarTable("ob_task_form_requirement", TableDescription = "任务补充字段快照")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObTaskFormRequirement : BaseEntity
{
/// <summary>
/// 入职任务 Id。
/// </summary>
public long TaskId { get; set; }
/// <summary>
/// 原动态字段 Id。
/// </summary>
public long FieldId { get; set; }
/// <summary>
/// 冻结的字段业务编码。
/// </summary>
public string FieldCode { get; set; }
/// <summary>
/// 冻结的字段显示名称。
/// </summary>
public string FieldName { get; set; }
/// <summary>
/// 冻结的数据类型。
/// </summary>
public string DataType { get; set; }
/// <summary>
/// 冻结的选项 JSON。
/// </summary>
public string OptionsJson { get; set; }
/// <summary>
/// 是否必须填写。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 手机端显示顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -0,0 +1,54 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职任务创建时冻结的上传材料要求。
/// </summary>
[SugarTable("ob_task_material_requirement", TableDescription = "任务上传材料快照")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObTaskMaterialRequirement : BaseEntity
{
/// <summary>
/// 入职任务 Id。
/// </summary>
public long TaskId { get; set; }
/// <summary>
/// 原材料类型 Id。
/// </summary>
public long MaterialTypeId { get; set; }
/// <summary>
/// 冻结的材料编码。
/// </summary>
public string MaterialCode { get; set; }
/// <summary>
/// 冻结的材料名称。
/// </summary>
public string MaterialName { get; set; }
/// <summary>
/// 冻结的允许扩展名。
/// </summary>
public string AllowedExtensions { get; set; }
/// <summary>
/// 冻结的最大文件数量。
/// </summary>
public int MaxFileCount { get; set; }
/// <summary>
/// 冻结的单文件大小上限,单位 MB。
/// </summary>
public int? MaxFileSizeMb { get; set; }
/// <summary>
/// 是否必须上传。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 手机端显示顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -0,0 +1,55 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 全局合同变量字典定义表。
/// 包含标准入职登记表变量、人事合同变量及企业自定义变量。
/// </summary>
[SugarTable("ob_variable_definition", TableDescription = "合同变量字典定义")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObVariableDefinition : BaseEntity
{
/// <summary>
/// 变量全编码,如 employee.EmployeeName, hr.BaseSalary, company.CompanyName, system.TaskNo。
/// </summary>
public string VariableCode { get; set; }
/// <summary>
/// 变量中文显示名称,如“员工姓名”、“基本工资”。
/// </summary>
public string VariableName { get; set; }
/// <summary>
/// 变量作用域分类:employee(员工变量), hr(人事参数), company(公司变量), system(系统变量)。
/// </summary>
public string VariableCategory { get; set; }
/// <summary>
/// 数据类型:string, number, date, multiline。
/// </summary>
public string DataType { get; set; } = "string";
/// <summary>
/// 默认值或占位提示。
/// </summary>
public string DefaultValue { get; set; }
/// <summary>
/// 是否为入职登记表内置字段(内置字段不可删,且第1步自动收集)。
/// </summary>
public bool IsBuiltIn { get; set; } = false;
/// <summary>
/// 是否必填。
/// </summary>
public bool Required { get; set; } = false;
/// <summary>
/// 排序码。
/// </summary>
public int SortCode { get; set; } = 100;
/// <summary>
/// 变量说明/备注。
/// </summary>
public string Remark { get; set; }
}
@@ -139,6 +139,10 @@ public static class TaskContractStatus
/// </summary> /// </summary>
public static class ContractVariableType public static class ContractVariableType
{ {
/// <summary>
/// 导入模板时尚未确认来源的变量。
/// </summary>
public const string Unresolved = "unresolved";
/// <summary> /// <summary>
/// 值来源于候选人或正式员工信息。 /// 值来源于候选人或正式员工信息。
/// </summary> /// </summary>
@@ -28,12 +28,12 @@ public class OnboardingAuditService : IOnboardingAuditService
.FirstAsync(item => item.TaskId == task.Id) .FirstAsync(item => item.TaskId == task.Id)
?? throw Oops.Bah("候选人资料不存在"); ?? throw Oops.Bah("候选人资料不存在");
var hasMaterialNeedSupplement = await _db.Queryable<ObTaskMaterial>() var hasUnapprovedMaterial = await _db.Queryable<ObTaskMaterial>()
.AnyAsync(item => item.TaskId == task.Id .AnyAsync(item => item.TaskId == task.Id
&& item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement); && item.ReviewStatus != TaskMaterialReviewStatus.Passed);
if (hasMaterialNeedSupplement) if (hasUnapprovedMaterial)
{ {
throw Oops.Bah("仍有资料需要补充,不能审核通过"); throw Oops.Bah("仍有资料未审核通过,不能审核通过入职任务");
} }
// 审核通过是创建正式员工的唯一入口,因此必须在入库前拦截身份证号或手机号重复。 // 审核通过是创建正式员工的唯一入口,因此必须在入库前拦截身份证号或手机号重复。
@@ -44,14 +44,25 @@ public class OnboardingAuditService : IOnboardingAuditService
throw Oops.Bah("该身份证号或手机号已存在正式员工"); throw Oops.Bah("该身份证号或手机号已存在正式员工");
} }
var extraValues = await _db.Queryable<ObTaskFormValue>()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
var employee = employeeInfo.Adapt<ObEmployee>(); var employee = employeeInfo.Adapt<ObEmployee>();
employee.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); employee.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
employee.CompanyId = task.CompanyId; employee.CompanyId = task.CompanyId;
employee.PositionId = task.PositionId; employee.PositionId = task.PositionId;
employee.ExtraInfoJson = global::System.Text.Json.JsonSerializer.Serialize(extraValues); employee.DepartmentName = employeeInfo.OnboardingDepartment;
employee.College = employeeInfo.GraduatedSchool;
employee.BirthDate = ParseDate(employeeInfo.Birthday);
employee.ContactAddress = employeeInfo.CurrentAddress;
employee.EntryDate = ParseDate(employeeInfo.OnboardingDate);
employee.Remark = employeeInfo.OtherRemarks;
employee.ExtraInfoJson = global::System.Text.Json.JsonSerializer.Serialize(employeeInfo);
employee.Status = CommonStatusConst.ENABLE;
employee.CreateTime = DateTime.Now;
employee.CreateUserId = UserManager.UserId;
employee.CreateUser = UserManager.Name;
employee.UpdateTime = null;
employee.UpdateUserId = null;
employee.UpdateUser = null;
employee.IsDelete = false;
// 员工档案、任务状态和审核记录必须同时成功,避免出现“已通过但没有员工档案”的中间状态。 // 员工档案、任务状态和审核记录必须同时成功,避免出现“已通过但没有员工档案”的中间状态。
await _db.Ado.BeginTranAsync(); await _db.Ado.BeginTranAsync();
@@ -124,6 +135,16 @@ public class OnboardingAuditService : IOnboardingAuditService
return task; return task;
} }
/// <summary>
/// 将登记表中的日期文本转换为正式员工档案日期。
/// </summary>
private static DateTime? ParseDate(string value)
{
return DateTime.TryParse(value, out var result)
? result.Date
: null;
}
private Task WriteAudit(ObOnboardingTask task, string action, string opinion) private Task WriteAudit(ObOnboardingTask task, string action, string opinion)
{ {
return _db.Insertable(new ObTaskAudit return _db.Insertable(new ObTaskAudit
@@ -5,6 +5,11 @@ namespace SimpleAdmin.Application;
/// </summary> /// </summary>
public class OnboardingCompanyPageInput : BasePageInput public class OnboardingCompanyPageInput : BasePageInput
{ {
/// <summary>
/// 公司业务编码。
/// </summary>
public virtual string CompanyCode { get; set; }
/// <summary> /// <summary>
/// 公司名称。 /// 公司名称。
/// </summary> /// </summary>
@@ -11,9 +11,8 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
public async Task<SqlSugarPagedList<ObCompany>> Page(OnboardingCompanyPageInput input) public async Task<SqlSugarPagedList<ObCompany>> Page(OnboardingCompanyPageInput input)
{ {
return await Context.Queryable<ObCompany>() return await Context.Queryable<ObCompany>()
.WhereIF( .WhereIF(!string.IsNullOrWhiteSpace(input.CompanyCode), it => it.CompanyCode.Contains(input.CompanyCode))
!string.IsNullOrWhiteSpace(input.CompanyName), .WhereIF(!string.IsNullOrWhiteSpace(input.CompanyName), it => it.CompanyName.Contains(input.CompanyName))
it => it.CompanyName.Contains(input.CompanyName) || it.CompanyCode.Contains(input.CompanyName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}") .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderBy(it => it.CreateTime) .OrderBy(it => it.CreateTime)
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 实现入职合同文件生成业务能力。 /// 实现入职合同预览和归档 DOCX 文件生成能力。
/// </summary> /// </summary>
public class OnboardingContractDocumentService : IOnboardingContractDocumentService public class OnboardingContractDocumentService : IOnboardingContractDocumentService
{ {
@@ -15,7 +15,7 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
} }
/// <summary> /// <summary>
/// 根据已锁定的模板版本和变量快照生成任务合同 DOCX 文件。 /// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
/// </summary> /// </summary>
public async Task<long> Generate(OnboardingGenerateContractInput input) public async Task<long> Generate(OnboardingGenerateContractInput input)
{ {
@@ -23,10 +23,12 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
?? throw Oops.Bah("任务合同不存在"); ?? throw Oops.Bah("任务合同不存在");
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(contract.TaskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(contract.TaskId)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill
if (task.TaskStatus != OnboardingTaskStatus.Approved) || task.TaskStatus == OnboardingTaskStatus.Rejected
throw Oops.Bah("仅审核通过的任务可生成归档合同"); || task.TaskStatus == OnboardingTaskStatus.Reviewing
|| task.TaskStatus == OnboardingTaskStatus.Approved;
if (!statusAllowsGeneration)
throw Oops.Bah("当前任务状态不能生成合同");
if (contract.GeneratedDocxFileId.HasValue) if (contract.GeneratedDocxFileId.HasValue)
return contract.GeneratedDocxFileId.Value; return contract.GeneratedDocxFileId.Value;
@@ -34,72 +36,43 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
?? throw Oops.Bah("合同版本不存在"); ?? throw Oops.Bah("合同版本不存在");
var source = await _db.Queryable<SysFile>().InSingleAsync(version.SourceDocxFileId) var source = await _db.Queryable<SysFile>().InSingleAsync(version.SourceDocxFileId)
?? throw Oops.Bah("合同源文件不存在"); ?? throw Oops.Bah("合同源文件不存在");
var variables = await _db.Queryable<ObTaskContractVariable>() var definitions = await _db.Queryable<ObTemplateVariable>()
.Where(x => x.TaskContractId == contract.Id) .Where(item => item.TemplateVersionId == version.Id)
.ToListAsync(); .ToListAsync();
var variables = await _db.Queryable<ObTaskContractVariable>()
.Where(item => item.TaskContractId == contract.Id)
.ToListAsync();
var missing = definitions
.Where(item => item.Required)
.FirstOrDefault(definition => string.IsNullOrWhiteSpace(
variables.FirstOrDefault(variable => variable.VariableCode == definition.VariableCode)?.VariableValue));
if (missing != null)
throw Oops.Bah($"合同变量“{missing.VariableName}”尚未填写,暂时不能预览合同");
var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id }) var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id })
?? throw Oops.Bah("无法读取合同源文件"); ?? throw Oops.Bah("无法读取合同源文件");
await using var sourceStream = sourceResult.FileStream; await using var sourceStream = sourceResult.FileStream;
using var sourceBytes = new MemoryStream(); using var sourceBytes = new MemoryStream();
await sourceStream.CopyToAsync(sourceBytes); await sourceStream.CopyToAsync(sourceBytes);
// 合同只读取任务变量快照,确保模板或员工档案后续修改不会影响已生成文件。 var rendered = DocxTemplateProcessor.Render(
var rendered = ReplaceDocxText(
sourceBytes.ToArray(), sourceBytes.ToArray(),
variables.ToDictionary(x => x.VariableCode, x => x.VariableValue ?? string.Empty)); variables.ToDictionary(
item => item.VariableCode,
item => item.VariableValue ?? string.Empty,
StringComparer.OrdinalIgnoreCase));
var name = $"{task.TaskNo}_{contract.ContractName}.docx"; var name = $"{task.TaskNo}_{contract.ContractName}.docx";
var fileId = await _fileService.StoreGeneratedFile(source.Engine, name, rendered); var fileId = await _fileService.StoreGeneratedFile(
SysDictConst.FILE_ENGINE_ALIYUN_OSS,
name,
rendered);
contract.GeneratedDocxFileId = fileId; contract.GeneratedDocxFileId = fileId;
contract.GeneratedAt = DateTime.Now; contract.GeneratedAt = DateTime.Now;
contract.ContractStatus = task.TaskStatus == OnboardingTaskStatus.Approved
? TaskContractStatus.OfflinePendingSign
: TaskContractStatus.PendingConfirm;
await _db.Updateable(contract).ExecuteCommandAsync(); await _db.Updateable(contract).ExecuteCommandAsync();
return fileId; return fileId;
} }
}
private static byte[] ReplaceDocxText(byte[] docx, Dictionary<string, string> values)
{
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
global::System.IO.Compression.ZipArchiveMode.Read);
using var output = new MemoryStream();
using var target = new global::System.IO.Compression.ZipArchive(
output,
global::System.IO.Compression.ZipArchiveMode.Create,
true);
// DOCX 本质是 ZIP 包,仅替换正文、页眉和页脚 XML 中的文本节点,其他资源原样复制。
foreach (var entry in archive.Entries)
{
var next = target.CreateEntry(entry.FullName, global::System.IO.Compression.CompressionLevel.Optimal);
using var reader = entry.Open();
using var writer = next.Open();
var isWordTextXml = entry.FullName == "word/document.xml"
|| ((entry.FullName.StartsWith("word/header") || entry.FullName.StartsWith("word/footer"))
&& entry.FullName.EndsWith(".xml"));
if (!isWordTextXml)
{
reader.CopyTo(writer);
continue;
}
using var textReader = new StreamReader(reader);
var document = global::System.Xml.Linq.XDocument.Parse(textReader.ReadToEnd());
var word = global::System.Xml.Linq.XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main");
foreach (var node in document.Descendants(word + "t"))
{
foreach (var item in values)
node.Value = node.Value.Replace("{{" + item.Key + "}}", item.Value ?? string.Empty);
}
using var textWriter = new StreamWriter(writer);
textWriter.Write(document.ToString(global::System.Xml.Linq.SaveOptions.DisableFormatting));
}
return output.ToArray();
}
}
@@ -9,5 +9,5 @@ public interface IOnboardingContractSnapshotService : ITransient
/// <summary> /// <summary>
/// 根据任务、公司和候选人资料构建或刷新合同变量快照。 /// 根据任务、公司和候选人资料构建或刷新合同变量快照。
/// </summary> /// </summary>
Task Build(long taskId, ObTaskEmployeeInfo employeeInfo = null); Task Build(long taskId, ObTaskEmployeeInfo employeeInfo = null, bool invalidateGeneratedPreview = false);
} }
@@ -1,32 +1,49 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary>冻结任务合同变量;DOCX 生成阶段只读取本快照,不再读取模板实时值。</summary>
/// <summary> /// <summary>
/// 实现任务合同变量快照业务能力。 /// 构建任务合同变量快照,合同生成阶段只读取本快照。
/// </summary> /// </summary>
public class OnboardingContractSnapshotService : IOnboardingContractSnapshotService public class OnboardingContractSnapshotService : IOnboardingContractSnapshotService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
public OnboardingContractSnapshotService() => _db = DbContext.DB;
public OnboardingContractSnapshotService()
{
_db = DbContext.DB;
}
/// <summary> /// <summary>
/// 根据任务、公司和候选人资料构建或刷新合同变量快照。 /// 根据任务、公司、员工固定信息和动态补充字段构建合同变量快照。
/// </summary> /// </summary>
public async Task Build(long taskId, ObTaskEmployeeInfo employeeInfo = null) public async Task Build(
long taskId,
ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false)
{ {
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId) ?? throw Oops.Bah("入职任务不存在"); var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
var company = await _db.Queryable<ObCompany>().InSingleAsync(task.CompanyId) ?? throw Oops.Bah("入职公司不存在"); ?? throw Oops.Bah("入职任务不存在");
employeeInfo ??= await _db.Queryable<ObTaskEmployeeInfo>().FirstAsync(x => x.TaskId == taskId); var company = await _db.Queryable<ObCompany>().InSingleAsync(task.CompanyId)
var contracts = await _db.Queryable<ObTaskContract>().Where(x => x.TaskId == taskId).ToListAsync(); ?? throw Oops.Bah("入职公司不存在");
employeeInfo ??= await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == taskId);
// 员工补充合同变量统一从 CustomVariablesJson 读取,不再访问已移除的旧动态字段表。
var dynamicValueMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == taskId)
.ToListAsync();
foreach (var contract in contracts) foreach (var contract in contracts)
{ {
if (contract.GeneratedDocxFileId.HasValue || contract.GeneratedPdfFileId.HasValue) throw Oops.Bah("合同已生成,不能重建变量快照"); var definitions = await _db.Queryable<ObTemplateVariable>()
var definitions = await _db.Queryable<ObTemplateVariable>().Where(x => x.TemplateVersionId == contract.TemplateVersionId).OrderBy(x => x.SortCode).ToListAsync(); .Where(item => item.TemplateVersionId == contract.TemplateVersionId)
var oldRows = await _db.Queryable<ObTaskContractVariable>().Where(x => x.TaskContractId == contract.Id).ToListAsync(); .OrderBy(item => item.SortCode)
.ToListAsync();
var oldRows = await _db.Queryable<ObTaskContractVariable>()
.Where(item => item.TaskContractId == contract.Id)
.ToListAsync();
var rows = definitions.Select(definition => var rows = definitions.Select(definition =>
{ {
var old = oldRows.FirstOrDefault(x => x.VariableCode == definition.VariableCode); var old = oldRows.FirstOrDefault(item => item.VariableCode == definition.VariableCode);
// 人事手工填写的工资、补贴等合同参数优先级最高,刷新自动变量时不能覆盖。
var preserveManual = old?.IsManualOverride == true; var preserveManual = old?.IsManualOverride == true;
return new ObTaskContractVariable return new ObTaskContractVariable
{ {
@@ -36,54 +53,155 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
VariableName = definition.VariableName, VariableName = definition.VariableName,
VariableType = definition.VariableType, VariableType = definition.VariableType,
DataType = definition.DataType, DataType = definition.DataType,
VariableValue = preserveManual ? old.VariableValue : ResolveValue(definition, employeeInfo, company, task), VariableValue = preserveManual
SourceType = preserveManual ? ContractVariableSourceType.HrInput : GetSourceType(definition.VariableType), ? old.VariableValue
: ResolveValue(definition, employeeInfo, dynamicValueMap, company, task),
SourceType = preserveManual
? ContractVariableSourceType.HrInput
: GetSourceType(definition.VariableType),
IsManualOverride = preserveManual IsManualOverride = preserveManual
}; };
}).ToList(); }).ToList();
await _db.Deleteable<ObTaskContractVariable>().Where(x => x.TaskContractId == contract.Id).ExecuteCommandAsync();
if (rows.Count > 0) await _db.Insertable(rows).ExecuteCommandAsync(); await _db.Deleteable<ObTaskContractVariable>()
.Where(item => item.TaskContractId == contract.Id)
.ExecuteCommandAsync();
if (rows.Count > 0)
await _db.Insertable(rows).ExecuteCommandAsync();
if (invalidateGeneratedPreview && task.TaskStatus is OnboardingTaskStatus.PendingFill or OnboardingTaskStatus.Rejected)
{
contract.GeneratedDocxFileId = null;
contract.GeneratedPdfFileId = null;
contract.GeneratedAt = null;
contract.ReadAt = null;
contract.ConfirmedAt = null;
contract.ContractStatus = TaskContractStatus.PendingGenerate;
await _db.Updateable(contract).ExecuteCommandAsync();
}
} }
} }
private static string GetSourceType(string variableType) => variableType switch private static string GetSourceType(string variableType)
{ {
ContractVariableType.Employee => ContractVariableSourceType.Employee, return variableType switch
ContractVariableType.Company => ContractVariableSourceType.Company, {
ContractVariableType.System => ContractVariableSourceType.System, ContractVariableType.Employee => ContractVariableSourceType.Employee,
_ => ContractVariableSourceType.HrInput ContractVariableType.Company => ContractVariableSourceType.Company,
}; ContractVariableType.System => ContractVariableSourceType.System,
_ => ContractVariableSourceType.HrInput
};
}
private static string ResolveValue(ObTemplateVariable definition, ObTaskEmployeeInfo info, ObCompany company, ObOnboardingTask task) private static string ResolveValue(
ObTemplateVariable definition,
ObTaskEmployeeInfo employeeInfo,
IReadOnlyDictionary<string, string> dynamicValues,
ObCompany company,
ObOnboardingTask task)
{ {
var sourceField = GetSourceField(definition);
if (definition.VariableType == ContractVariableType.Employee) if (definition.VariableType == ContractVariableType.Employee)
return GetPropertyValue(info, definition.EmployeeField) ?? definition.DefaultValue; {
var fixedValue = GetPropertyValue(employeeInfo, ResolveEmployeeField(sourceField), definition.FormatRule);
if (!string.IsNullOrWhiteSpace(fixedValue))
return fixedValue;
// 检查员工自定义补充变量 JSON
if (!string.IsNullOrWhiteSpace(employeeInfo?.CustomVariablesJson))
{
try
{
using var doc = global::System.Text.Json.JsonDocument.Parse(employeeInfo.CustomVariablesJson);
if (doc.RootElement.TryGetProperty(sourceField, out var prop)
|| doc.RootElement.TryGetProperty(definition.VariableCode, out prop))
{
var str = prop.GetString();
if (!string.IsNullOrWhiteSpace(str)) return str;
}
}
catch { }
}
return dynamicValues.TryGetValue(sourceField, out var dynamicValue)
? dynamicValue
: definition.DefaultValue;
}
if (definition.VariableType == ContractVariableType.Company) if (definition.VariableType == ContractVariableType.Company)
return GetPropertyValue(company, ResolveCompanyField(definition)) ?? definition.DefaultValue; return GetPropertyValue(company, ResolveCompanyField(sourceField), definition.FormatRule) ?? definition.DefaultValue;
if (definition.VariableType == ContractVariableType.System && string.Equals(definition.VariableCode, "TASK_NO", StringComparison.OrdinalIgnoreCase))
return task.TaskNo; if (definition.VariableType == ContractVariableType.System)
{
return sourceField.ToUpperInvariant() switch
{
"TASKNO" or "TASK_NO" or "CONTRACTNO" or "CONTRACT_NO" => task.TaskNo,
"CURRENTDATE" or "CURRENT_DATE" or "SIGNDATE" or "SIGN_DATE" => DateTime.Now.ToString("yyyy年MM月dd日"),
_ => definition.DefaultValue
};
}
return definition.DefaultValue; return definition.DefaultValue;
} }
private static string GetPropertyValue(object source, string propertyName) private static string GetSourceField(ObTemplateVariable definition)
{ {
if (source == null || string.IsNullOrWhiteSpace(propertyName)) return null; if (!string.IsNullOrWhiteSpace(definition.EmployeeField))
return source.GetType().GetProperty(propertyName, global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.IgnoreCase)?.GetValue(source)?.ToString(); return definition.EmployeeField;
var separatorIndex = definition.VariableCode?.IndexOf('.') ?? -1;
return separatorIndex >= 0
? definition.VariableCode[(separatorIndex + 1)..]
: definition.VariableCode;
} }
private static string ResolveCompanyField(ObTemplateVariable definition) private static string GetPropertyValue(object source, string propertyName, string formatRule = null)
{ {
if (!string.IsNullOrWhiteSpace(definition.EmployeeField)) return definition.EmployeeField; if (source == null || string.IsNullOrWhiteSpace(propertyName))
return definition.VariableCode.ToUpperInvariant() switch return null;
var value = source.GetType()
.GetProperty(
propertyName,
global::System.Reflection.BindingFlags.Public
| global::System.Reflection.BindingFlags.Instance
| global::System.Reflection.BindingFlags.IgnoreCase)
?.GetValue(source);
if (value == null)
return null;
if (value is DateTime dateTime)
return dateTime.ToString(string.IsNullOrWhiteSpace(formatRule) ? "yyyy年MM月dd日" : formatRule);
if (value is DateTimeOffset dateTimeOffset)
return dateTimeOffset.ToString(string.IsNullOrWhiteSpace(formatRule) ? "yyyy年MM月dd日" : formatRule);
if (!string.IsNullOrWhiteSpace(formatRule) && value is IFormattable formattable)
return formattable.ToString(formatRule, null);
return value.ToString();
}
private static string ResolveEmployeeField(string sourceField)
{
return sourceField.ToUpperInvariant() switch
{ {
"COMPANY_CODE" => nameof(ObCompany.CompanyCode), "FULLNAME" or "FULL_NAME" or "NAME" => nameof(ObTaskEmployeeInfo.EmployeeName),
"COMPANY_NAME" => nameof(ObCompany.CompanyName), "MOBILE" or "MOBILEPHONE" or "MOBILE_PHONE" => nameof(ObTaskEmployeeInfo.Phone),
"LEGAL_NAME" or "COMPANY_LEGAL_NAME" => nameof(ObCompany.LegalName), "IDNUMBER" or "ID_NUMBER" or "IDENTITYNUMBER" => nameof(ObTaskEmployeeInfo.IdCardNumber),
"UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode), _ => sourceField
"LEGAL_REPRESENTATIVE" => nameof(ObCompany.LegalRepresentative),
"REGISTERED_ADDRESS" => nameof(ObCompany.RegisteredAddress),
"CONTACT_PHONE" => nameof(ObCompany.ContactPhone),
_ => definition.VariableCode
}; };
} }
} private static string ResolveCompanyField(string sourceField)
{
return sourceField.ToUpperInvariant() switch
{
"COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.CompanyCode),
"COMPANYNAME" or "COMPANY_NAME" => nameof(ObCompany.CompanyName),
"LEGALNAME" or "LEGAL_NAME" or "COMPANYLEGALNAME" => nameof(ObCompany.LegalName),
"UNIFIEDSOCIALCREDITCODE" or "UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
"LEGALREPRESENTATIVE" or "LEGAL_REPRESENTATIVE" => nameof(ObCompany.LegalRepresentative),
"ADDRESS" or "COMPANYADDRESS" or "COMPANY_ADDRESS"
or "REGISTEREDADDRESS" or "REGISTERED_ADDRESS" => nameof(ObCompany.RegisteredAddress),
"PHONE" or "COMPANYPHONE" or "COMPANY_PHONE"
or "CONTACTPHONE" or "CONTACT_PHONE" => nameof(ObCompany.ContactPhone),
_ => sourceField
};
}
}
@@ -0,0 +1,160 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 负责从 DOCX 中识别占位符并在不依赖 Word 进程的情况下替换文本节点。
/// </summary>
public static class DocxTemplateProcessor
{
private static readonly global::System.Text.RegularExpressions.Regex PlaceholderRegex = new(
@"\{\{\s*(?<code>[A-Za-z][A-Za-z0-9_.-]*)\s*\}\}",
global::System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly global::System.Xml.Linq.XNamespace WordNamespace =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
/// <summary>
/// 扫描正文、表格、页眉和页脚,返回去重后的变量编码。
/// </summary>
public static List<string> ExtractVariableCodes(byte[] docx)
{
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
global::System.IO.Compression.ZipArchiveMode.Read);
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
{
using var stream = entry.Open();
var document = global::System.Xml.Linq.XDocument.Load(stream);
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
{
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
codes.Add(match.Groups["code"].Value.Trim());
}
}
return codes.OrderBy(code => code).ToList();
}
/// <summary>
/// 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。
/// </summary>
public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values)
{
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
global::System.IO.Compression.ZipArchiveMode.Read);
using var output = new MemoryStream();
using var target = new global::System.IO.Compression.ZipArchive(
output,
global::System.IO.Compression.ZipArchiveMode.Create,
true);
foreach (var entry in archive.Entries)
{
var next = target.CreateEntry(
entry.FullName,
global::System.IO.Compression.CompressionLevel.Optimal);
using var sourceStream = entry.Open();
using var targetStream = next.Open();
if (!IsWordTextXml(entry.FullName))
{
sourceStream.CopyTo(targetStream);
continue;
}
var document = global::System.Xml.Linq.XDocument.Load(sourceStream);
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
ReplaceParagraphVariables(paragraph, values);
document.Save(targetStream, global::System.Xml.Linq.SaveOptions.DisableFormatting);
}
target.Dispose();
return output.ToArray();
}
private static void ReplaceParagraphVariables(
global::System.Xml.Linq.XElement paragraph,
IReadOnlyDictionary<string, string> values)
{
var replacements = 0;
while (replacements < 1000)
{
var nodes = paragraph.Descendants(WordNamespace + "t").ToList();
var text = string.Concat(nodes.Select(node => node.Value));
var match = PlaceholderRegex.Matches(text)
.Cast<global::System.Text.RegularExpressions.Match>()
.LastOrDefault(item => values.ContainsKey(item.Groups["code"].Value.Trim()));
if (match == null)
return;
var code = match.Groups["code"].Value.Trim();
ReplaceRange(nodes, match.Index, match.Length, values[code] ?? string.Empty);
replacements++;
}
throw Oops.Bah("合同模板单个段落中的变量数量异常");
}
private static void ReplaceRange(
List<global::System.Xml.Linq.XElement> nodes,
int start,
int length,
string replacement)
{
var end = start + length;
var offset = 0;
global::System.Xml.Linq.XElement startNode = null;
global::System.Xml.Linq.XElement endNode = null;
var startIndex = 0;
var endIndex = 0;
foreach (var node in nodes)
{
var nextOffset = offset + node.Value.Length;
if (startNode == null && start >= offset && start < nextOffset)
{
startNode = node;
startIndex = start - offset;
}
if (end > offset && end <= nextOffset)
{
endNode = node;
endIndex = end - offset;
break;
}
offset = nextOffset;
}
if (startNode == null || endNode == null)
return;
if (ReferenceEquals(startNode, endNode))
{
startNode.Value = startNode.Value[..startIndex]
+ replacement
+ startNode.Value[endIndex..];
return;
}
var startNodePosition = nodes.IndexOf(startNode);
var endNodePosition = nodes.IndexOf(endNode);
startNode.Value = startNode.Value[..startIndex] + replacement;
for (var index = startNodePosition + 1; index < endNodePosition; index++)
nodes[index].Value = string.Empty;
endNode.Value = endNode.Value[endIndex..];
}
private static bool IsWordTextXml(string entryName)
{
return entryName == "word/document.xml"
|| ((entryName.StartsWith("word/header", StringComparison.OrdinalIgnoreCase)
|| entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase))
&& entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
}
}
@@ -1,73 +0,0 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 合同适用范围分页查询参数。
/// </summary>
public class OnboardingContractScopePageInput : BasePageInput
{
/// <summary>
/// 入职业务公司 Id。
/// </summary>
public long? CompanyId { get; set; }
/// <summary>
/// 岗位 Id;为空时表示公司级配置。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 合同模板 Id。
/// </summary>
public long? TemplateId { get; set; }
}
/// <summary>
/// 新增合同适用范围参数。
/// </summary>
public class OnboardingContractScopeAddInput
{
/// <summary>
/// 入职业务公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "公司Id不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 岗位 Id;为空时表示公司级配置。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 合同模板 Id。
/// </summary>
[IdNotNull(ErrorMessage = "模板Id不能为空")]
public long TemplateId { get; set; }
/// <summary>
/// 合同模板版本 Id。
/// </summary>
[IdNotNull(ErrorMessage = "模板版本Id不能为空")]
public long TemplateVersionId { get; set; }
/// <summary>
/// 是否必填、必传或必签。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 显示排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 编辑合同适用范围参数。
/// </summary>
public class OnboardingContractScopeEditInput : OnboardingContractScopeAddInput
{
/// <summary>
/// 业务数据 Id。
/// </summary>
[IdNotNull(ErrorMessage = "适用范围Id不能为空")]
public long Id { get; set; }
}
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 定义合同模板、版本、变量和适用范围业务能力。 /// 定义合同模板、DOCX 版本和自动识别变量的管理能力。
/// </summary> /// </summary>
public interface IOnboardingContractTemplateService : ITransient public interface IOnboardingContractTemplateService : ITransient
{ {
@@ -9,59 +9,54 @@ public interface IOnboardingContractTemplateService : ITransient
/// 分页查询合同模板主档案。 /// 分页查询合同模板主档案。
/// </summary> /// </summary>
Task<SqlSugarPagedList<ObContractTemplate>> Page(OnboardingContractTemplatePageInput input); Task<SqlSugarPagedList<ObContractTemplate>> Page(OnboardingContractTemplatePageInput input);
/// <summary> /// <summary>
/// 查询合同模板详情。 /// 查询合同模板详情。
/// </summary> /// </summary>
Task<ObContractTemplate> Detail(BaseIdInput input); Task<ObContractTemplate> Detail(BaseIdInput input);
/// <summary> /// <summary>
/// 新增合同模板主档案。 /// 新增合同模板主档案。
/// </summary> /// </summary>
Task Add(OnboardingContractTemplateAddInput input); Task Add(OnboardingContractTemplateAddInput input);
/// <summary> /// <summary>
/// 编辑合同模板主档案。 /// 编辑合同模板主档案。
/// </summary> /// </summary>
Task Edit(OnboardingContractTemplateEditInput input); Task Edit(OnboardingContractTemplateEditInput input);
/// <summary> /// <summary>
/// 删除未被版本、适用范围或任务合同引用的模板。 /// 删除未被版本、入职模板或任务合同引用的模板。
/// </summary> /// </summary>
Task Delete(BaseIdListInput input); Task Delete(BaseIdListInput input);
/// <summary> /// <summary>
/// 分页查询指定合同模板的版本。 /// 分页查询指定合同模板的版本。
/// </summary> /// </summary>
Task<SqlSugarPagedList<ObContractTemplateVersion>> VersionPage(OnboardingContractVersionPageInput input); Task<SqlSugarPagedList<ObContractTemplateVersion>> VersionPage(OnboardingContractVersionPageInput input);
/// <summary> /// <summary>
/// 新增合同模板 DOCX 版本。 /// 新增合同模板 DOCX 版本并自动识别变量。
/// </summary> /// </summary>
Task AddVersion(OnboardingContractVersionAddInput input); Task AddVersion(OnboardingContractVersionAddInput input);
/// <summary> /// <summary>
/// 启用或停用合同模板版本。 /// 启用或停用合同模板版本。
/// </summary> /// </summary>
Task SetVersionStatus(OnboardingContractVersionStatusInput input); Task SetVersionStatus(OnboardingContractVersionStatusInput input);
/// <summary> /// <summary>
/// 分页查询指定模板版本的变量定义。 /// 删除未被入职模板或任务使用的合同版本。
/// </summary>
Task DeleteVersion(BaseIdInput input);
/// <summary>
/// 分页查询指定模板版本自动识别出的变量。
/// </summary> /// </summary>
Task<SqlSugarPagedList<ObTemplateVariable>> VariablePage(OnboardingTemplateVariablePageInput input); Task<SqlSugarPagedList<ObTemplateVariable>> VariablePage(OnboardingTemplateVariablePageInput input);
/// <summary> /// <summary>
/// 为草稿模板版本新增变量。 /// 编辑并确认模板版本中的变量定义。
/// </summary>
Task AddVariable(OnboardingTemplateVariableAddInput input);
/// <summary>
/// 编辑草稿模板版本中的变量。
/// </summary> /// </summary>
Task EditVariable(OnboardingTemplateVariableEditInput input); Task EditVariable(OnboardingTemplateVariableEditInput input);
/// <summary> }
/// 删除草稿模板版本中的变量。
/// </summary>
Task DeleteVariable(BaseIdListInput input); Task<SqlSugarPagedList<ObContractScope>> ScopePage(OnboardingContractScopePageInput input);
/// <summary>
/// 新增公司或岗位的合同适用范围。
/// </summary>
Task AddScope(OnboardingContractScopeAddInput input);
/// <summary>
/// 编辑合同适用范围。
/// </summary>
Task EditScope(OnboardingContractScopeEditInput input);
/// <summary>
/// 删除合同适用范围。
/// </summary>
Task DeleteScope(BaseIdListInput input);}
@@ -5,6 +5,13 @@ namespace SimpleAdmin.Application;
/// </summary> /// </summary>
public class OnboardingContractTemplateService : DbRepository<ObContractTemplate>, IOnboardingContractTemplateService public class OnboardingContractTemplateService : DbRepository<ObContractTemplate>, IOnboardingContractTemplateService
{ {
private readonly IFileService _fileService;
public OnboardingContractTemplateService(IFileService fileService)
{
_fileService = fileService;
}
/// <summary> /// <summary>
/// 分页查询合同模板主档案。 /// 分页查询合同模板主档案。
/// </summary> /// </summary>
@@ -49,29 +56,92 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
} }
/// <summary> /// <summary>
/// 删除未被版本、适用范围或任务合同引用的模板。 /// 删除未被入职模板或任务合同引用的模板(级联删除未使用的版本和变量)。
/// </summary> /// </summary>
public async Task Delete(BaseIdListInput input) public async Task Delete(BaseIdListInput input)
{ {
if (input.Ids.Count == 0) if (input.Ids.Count == 0)
return; return;
var hasVersions = await Context.Queryable<ObContractTemplateVersion>() var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => input.Ids.Contains(it.TemplateId)); .AnyAsync(it => input.Ids.Contains(it.ContractTemplateId));
if (hasVersions) if (hasOnboardingTemplates)
throw Oops.Bah("模板已有版本,不能删除"); throw Oops.Bah("模板已被入职模板引用,不能删除,请先在入职模板中移除关联");
var hasScopes = await Context.Queryable<ObContractScope>()
.AnyAsync(it => input.Ids.Contains(it.TemplateId));
if (hasScopes)
throw Oops.Bah("模板已配置适用范围,不能删除");
var hasContracts = await Context.Queryable<ObTaskContract>() var hasContracts = await Context.Queryable<ObTaskContract>()
.AnyAsync(it => input.Ids.Contains(it.TemplateId)); .AnyAsync(it => input.Ids.Contains(it.TemplateId));
if (hasContracts) if (hasContracts)
throw Oops.Bah("模板已生成入职合同,不能删除"); throw Oops.Bah("模板已生成入职任务合同,不能删除,可改为停用");
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray()); var versionIds = await Context.Queryable<ObContractTemplateVersion>()
.Where(it => input.Ids.Contains(it.TemplateId))
.Select(it => it.Id)
.ToListAsync();
await Context.Ado.BeginTranAsync();
try
{
if (versionIds.Count > 0)
{
await Context.Deleteable<ObTemplateVariable>()
.Where(it => versionIds.Contains(it.TemplateVersionId))
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplateVersion>()
.In(versionIds)
.ExecuteCommandAsync();
}
await Context.Deleteable<ObContractScope>()
.Where(it => input.Ids.Contains(it.TemplateId))
.ExecuteCommandAsync();
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
await Context.Ado.CommitTranAsync();
}
catch
{
await Context.Ado.RollbackTranAsync();
throw;
}
}
/// <summary>
/// 删除未被入职模板或任务使用的合同版本。
/// </summary>
public async Task DeleteVersion(BaseIdInput input)
{
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("合同版本不存在");
var isUsedInOnboardingTemplate = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => it.ContractTemplateVersionId == version.Id);
if (isUsedInOnboardingTemplate)
throw Oops.Bah("该版本已被入职模板引用,不能删除,请先在入职模板中移除关联");
var isUsedInTask = await Context.Queryable<ObTaskContract>()
.AnyAsync(it => it.TemplateVersionId == version.Id);
if (isUsedInTask)
throw Oops.Bah("该版本已用于实际入职任务,不能删除,可改为停用");
await Context.Ado.BeginTranAsync();
try
{
await Context.Deleteable<ObTemplateVariable>()
.Where(it => it.TemplateVersionId == version.Id)
.ExecuteCommandAsync();
await Context.Deleteable<ObContractScope>()
.Where(it => it.TemplateVersionId == version.Id)
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplateVersion>()
.In(version.Id)
.ExecuteCommandAsync();
await Context.Ado.CommitTranAsync();
}
catch
{
await Context.Ado.RollbackTranAsync();
throw;
}
} }
/// <summary> /// <summary>
@@ -92,18 +162,60 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
public async Task AddVersion(OnboardingContractVersionAddInput input) public async Task AddVersion(OnboardingContractVersionAddInput input)
{ {
await Detail(new BaseIdInput { Id = input.TemplateId }); await Detail(new BaseIdInput { Id = input.TemplateId });
var file = await Context.Queryable<SysFile>().InSingleAsync(input.SourceDocxFileId) var file = await Context.Queryable<SysFile>().InSingleAsync(input.SourceDocxFileId)
?? throw Oops.Bah("DOCX源文件不存在"); ?? throw Oops.Bah("DOCX 源文件不存在");
if (!string.Equals(file.Suffix, "docx", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(file.Suffix, "docx", StringComparison.OrdinalIgnoreCase))
throw Oops.Bah("合同模板仅支持DOCX文件"); throw Oops.Bah("合同模板仅支持 DOCX 文件");
var versionExists = await Context.Queryable<ObContractTemplateVersion>() var versionExists = await Context.Queryable<ObContractTemplateVersion>()
.AnyAsync(it => it.TemplateId == input.TemplateId && it.VersionNo == input.VersionNo); .AnyAsync(item => item.TemplateId == input.TemplateId && item.VersionNo == input.VersionNo);
if (versionExists) if (versionExists)
throw Oops.Bah("该模板版本号已存在"); throw Oops.Bah("该模板版本号已存在");
await Context.Insertable(input.Adapt<ObContractTemplateVersion>()).ExecuteCommandAsync(); var sourceResult = await _fileService.Download(new BaseIdInput { Id = file.Id })
?? throw Oops.Bah("无法读取 DOCX 源文件");
await using var sourceStream = sourceResult.FileStream;
using var sourceBytes = new MemoryStream();
await sourceStream.CopyToAsync(sourceBytes);
var bytes = sourceBytes.ToArray();
var variableCodes = DocxTemplateProcessor.ExtractVariableCodes(bytes);
// 严格比对变量字典库,防止手误和非法占位符
var dictList = await Context.Queryable<ObVariableDefinition>().ToListAsync();
var dictMap = dictList.ToDictionary(it => it.VariableCode, it => it, StringComparer.OrdinalIgnoreCase);
var undefinedCodes = variableCodes.Where(code => !dictMap.ContainsKey(code)).ToList();
if (undefinedCodes.Count > 0)
{
throw Oops.Bah($"DOCX 模板包含未在字典库中注册的占位符:{string.Join(",", undefinedCodes)}。请检查拼写或先在【变量字典】中添加该变量。");
}
var versionId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var version = input.Adapt<ObContractTemplateVersion>();
version.Id = versionId;
version.VersionStatus = ContractTemplateVersionStatus.Draft;
version.FileHash = string.IsNullOrWhiteSpace(input.FileHash)
? Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(bytes))
: input.FileHash;
var variables = variableCodes.Select((code, index) =>
{
var def = dictMap.TryGetValue(code, out var d) ? d : null;
return BuildDetectedVariable(versionId, code, def, index);
}).ToList();
await Context.Ado.BeginTranAsync();
try
{
await Context.Insertable(version).ExecuteCommandAsync();
if (variables.Count > 0)
await Context.Insertable(variables).ExecuteCommandAsync();
await Context.Ado.CommitTranAsync();
}
catch
{
await Context.Ado.RollbackTranAsync();
throw;
}
} }
/// <summary> /// <summary>
@@ -122,10 +234,11 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (input.VersionStatus == ContractTemplateVersionStatus.Enabled) if (input.VersionStatus == ContractTemplateVersionStatus.Enabled)
{ {
var hasVariables = await Context.Queryable<ObTemplateVariable>() var hasUnresolvedVariables = await Context.Queryable<ObTemplateVariable>()
.AnyAsync(it => it.TemplateVersionId == input.Id); .AnyAsync(item => item.TemplateVersionId == input.Id
if (!hasVariables) && item.VariableType == ContractVariableType.Unresolved);
throw Oops.Bah("请先配置模板变量后再启用"); if (hasUnresolvedVariables)
throw Oops.Bah("仍有未识别来源的合同变量,请逐项确认后再启用");
} }
version.VersionStatus = input.VersionStatus; version.VersionStatus = input.VersionStatus;
@@ -143,19 +256,8 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
.OrderBy(it => it.VariableCode) .OrderBy(it => it.VariableCode)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
} }
/// <summary> /// <summary>
/// 为草稿模板版本新增变量。 /// 编辑模板版本中自动识别出的变量,不允许修改 DOCX 占位符编码。
/// </summary>
public async Task AddVariable(OnboardingTemplateVariableAddInput input)
{
await EnsureVersionDraft(input.TemplateVersionId);
await EnsureVariableUnique(input.TemplateVersionId, input.VariableCode, 0);
await Context.Insertable(input.Adapt<ObTemplateVariable>()).ExecuteCommandAsync();
}
/// <summary>
/// 编辑草稿模板版本中的变量。
/// </summary> /// </summary>
public async Task EditVariable(OnboardingTemplateVariableEditInput input) public async Task EditVariable(OnboardingTemplateVariableEditInput input)
{ {
@@ -163,77 +265,68 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
?? throw Oops.Bah("模板变量不存在"); ?? throw Oops.Bah("模板变量不存在");
if (variable.TemplateVersionId != input.TemplateVersionId) if (variable.TemplateVersionId != input.TemplateVersionId)
throw Oops.Bah("不允许变更变量所属版本"); throw Oops.Bah("不允许变更变量所属版本");
if (!string.Equals(variable.VariableCode, input.VariableCode, StringComparison.Ordinal))
throw Oops.Bah("DOCX 占位符编码由系统识别,不能修改");
await EnsureVersionDraft(input.TemplateVersionId); var isUsedInTask = await Context.Queryable<ObTaskContract>()
await EnsureVariableUnique(input.TemplateVersionId, input.VariableCode, input.Id); .AnyAsync(it => it.TemplateVersionId == input.TemplateVersionId);
await Context.Updateable(input.Adapt<ObTemplateVariable>()).ExecuteCommandAsync(); if (isUsedInTask)
throw Oops.Bah("该版本已生成实际入职任务合同,变量配置已锁定");
var entity = input.Adapt<ObTemplateVariable>();
entity.VariableCode = variable.VariableCode;
await Context.Updateable(entity).ExecuteCommandAsync();
} }
/// <summary> private static ObTemplateVariable BuildDetectedVariable(long versionId, string code, ObVariableDefinition def, int index)
/// 删除草稿模板版本中的变量。
/// </summary>
public async Task DeleteVariable(BaseIdListInput input)
{ {
if (input.Ids.Count == 0) var separatorIndex = code.IndexOf('.');
return; var prefix = separatorIndex > 0 ? code[..separatorIndex].ToLowerInvariant() : string.Empty;
var sourceField = separatorIndex > 0 ? code[(separatorIndex + 1)..] : code;
var variableType = prefix switch
{
"employee" => ContractVariableType.Employee,
"company" => ContractVariableType.Company,
"hr" => ContractVariableType.Contract,
"system" => ContractVariableType.System,
_ => ContractVariableType.Unresolved
};
var versionIds = await Context.Queryable<ObTemplateVariable>() var varName = def != null && !string.IsNullOrWhiteSpace(def.VariableName) ? def.VariableName : sourceField;
.Where(it => input.Ids.Contains(it.Id)) var dataType = def != null && !string.IsNullOrWhiteSpace(def.DataType) ? def.DataType : InferDataType(sourceField);
.Select(it => it.TemplateVersionId)
.ToListAsync();
foreach (var versionId in versionIds.Distinct()) return new ObTemplateVariable
await EnsureVersionDraft(versionId); {
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
await Context.Deleteable<ObTemplateVariable>().In(input.Ids).ExecuteCommandAsync(); TemplateVersionId = versionId,
VariableCode = code,
VariableName = varName,
VariableType = variableType,
DataType = dataType,
EmployeeField = variableType is ContractVariableType.Employee or ContractVariableType.Company
? sourceField
: null,
Required = def?.Required ?? true,
HrEditable = variableType == ContractVariableType.Contract,
SortCode = (index + 1) * 10,
Remark = def?.Remark
};
} }
/// <summary> private static string InferDataType(string sourceField)
/// 分页查询合同适用范围。
/// </summary>
public async Task<SqlSugarPagedList<ObContractScope>> ScopePage(OnboardingContractScopePageInput input)
{ {
return await Context.Queryable<ObContractScope>() var normalized = sourceField.ToLowerInvariant();
.WhereIF(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value) if (normalized.Contains("date") || normalized.EndsWith("at"))
.WhereIF(input.PositionId.HasValue, it => it.PositionId == input.PositionId.Value) return "date";
.WhereIF(input.TemplateId.HasValue, it => it.TemplateId == input.TemplateId.Value) if (normalized.Contains("salary")
.OrderBy(it => it.SortCode) || normalized.Contains("amount")
.ToPagedListAsync(input.PageNum, input.PageSize); || normalized.Contains("wage")
} || normalized.Contains("copies"))
{
return "decimal";
}
/// <summary> return "string";
/// 新增公司或岗位的合同适用范围。
/// </summary>
public async Task AddScope(OnboardingContractScopeAddInput input)
{
await EnsureScopeValid(input.CompanyId, input.PositionId, input.TemplateId, input.TemplateVersionId);
await EnsureScopeUnique(input.CompanyId, input.PositionId, input.TemplateVersionId, 0);
await Context.Insertable(input.Adapt<ObContractScope>()).ExecuteCommandAsync();
}
/// <summary>
/// 编辑合同适用范围。
/// </summary>
public async Task EditScope(OnboardingContractScopeEditInput input)
{
var scopeExists = await Context.Queryable<ObContractScope>().AnyAsync(it => it.Id == input.Id);
if (!scopeExists)
throw Oops.Bah("合同适用范围不存在");
await EnsureScopeValid(input.CompanyId, input.PositionId, input.TemplateId, input.TemplateVersionId);
await EnsureScopeUnique(input.CompanyId, input.PositionId, input.TemplateVersionId, input.Id);
await Context.Updateable(input.Adapt<ObContractScope>()).ExecuteCommandAsync();
}
/// <summary>
/// 删除合同适用范围。
/// </summary>
public async Task DeleteScope(BaseIdListInput input)
{
if (input.Ids.Count == 0)
return;
await Context.Deleteable<ObContractScope>().In(input.Ids).ExecuteCommandAsync();
} }
private async Task EnsureCodeUnique(string code, long id) private async Task EnsureCodeUnique(string code, long id)
@@ -243,52 +336,4 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (exists) if (exists)
throw Oops.Bah("模板编码已存在"); throw Oops.Bah("模板编码已存在");
} }
private async Task EnsureVersionDraft(long versionId)
{
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(versionId)
?? throw Oops.Bah("模板版本不存在");
if (version.VersionStatus != ContractTemplateVersionStatus.Draft)
throw Oops.Bah("只有草稿版本可维护变量");
}
private async Task EnsureVariableUnique(long versionId, string code, long id)
{
var exists = await Context.Queryable<ObTemplateVariable>()
.AnyAsync(it => it.TemplateVersionId == versionId && it.VariableCode == code && it.Id != id);
if (exists)
throw Oops.Bah("该版本变量编码已存在");
}
private async Task EnsureScopeValid(long companyId, long? positionId, long templateId, long versionId)
{
var companyExists = await Context.Queryable<ObCompany>()
.AnyAsync(it => it.Id == companyId && it.Status == "enable");
if (!companyExists)
throw Oops.Bah("所属公司不存在或未启用");
if (positionId.HasValue)
{
var positionExists = await Context.Queryable<ObPosition>()
.AnyAsync(it => it.Id == positionId.Value && it.CompanyId == companyId && it.Status == "enable");
if (!positionExists)
throw Oops.Bah("岗位不存在、未启用或不属于该公司");
}
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(versionId)
?? throw Oops.Bah("合同版本不存在");
if (version.TemplateId != templateId || version.VersionStatus != ContractTemplateVersionStatus.Enabled)
throw Oops.Bah("只能配置该模板的已启用合同版本");
}
private async Task EnsureScopeUnique(long companyId, long? positionId, long versionId, long id)
{
var exists = await Context.Queryable<ObContractScope>()
.AnyAsync(it => it.CompanyId == companyId
&& it.PositionId == positionId
&& it.TemplateVersionId == versionId
&& it.Id != id);
if (exists)
throw Oops.Bah("该公司、岗位和合同版本的适用范围已存在");
}
} }
@@ -91,79 +91,3 @@ public class OnboardingMaterialTypeSaveInput
/// </summary> /// </summary>
public string Status { get; set; } = "enable"; public string Status { get; set; } = "enable";
} }
/// <summary>
/// 保存公司岗位动态字段要求参数。
/// </summary>
public class OnboardingFormRequirementSaveInput
{
/// <summary>
/// 业务数据 Id。
/// </summary>
public long? Id { get; set; }
/// <summary>
/// 入职业务公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "公司Id不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 岗位 Id;为空时表示公司级配置。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 动态字段 Id。
/// </summary>
[IdNotNull(ErrorMessage = "字段Id不能为空")]
public long FieldId { get; set; }
/// <summary>
/// 是否必填、必传或必签。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 显示排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 保存公司岗位材料要求参数。
/// </summary>
public class OnboardingMaterialRequirementSaveInput
{
/// <summary>
/// 业务数据 Id。
/// </summary>
public long? Id { get; set; }
/// <summary>
/// 入职业务公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "公司Id不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 岗位 Id;为空时表示公司级配置。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 材料类型 Id。
/// </summary>
[IdNotNull(ErrorMessage = "资料类型Id不能为空")]
public long MaterialTypeId { get; set; }
/// <summary>
/// 是否必填、必传或必签。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 显示排序码。
/// </summary>
public int? SortCode { get; set; }
}
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 定义动态字段、材料类型及公司岗位要求配置业务能力。 /// 定义员工动态补充字段与上传材料类型的基础配置能力。
/// </summary> /// </summary>
public interface IOnboardingEntryConfigService : ITransient public interface IOnboardingEntryConfigService : ITransient
{ {
@@ -9,48 +9,29 @@ public interface IOnboardingEntryConfigService : ITransient
/// 查询全部动态入职字段。 /// 查询全部动态入职字段。
/// </summary> /// </summary>
Task<List<ObFormField>> ListFormFields(); Task<List<ObFormField>> ListFormFields();
/// <summary> /// <summary>
/// 新增或编辑动态入职字段。 /// 新增或编辑动态入职字段。
/// </summary> /// </summary>
Task SaveFormField(OnboardingFormFieldSaveInput input); Task SaveFormField(OnboardingFormFieldSaveInput input);
/// <summary> /// <summary>
/// 删除未被填写要求引用的动态字段。 /// 删除未被入职模板引用的动态字段。
/// </summary> /// </summary>
Task DeleteFormFields(BaseIdListInput input); Task DeleteFormFields(BaseIdListInput input);
/// <summary> /// <summary>
/// 查询全部入职材料类型。 /// 查询全部入职材料类型。
/// </summary> /// </summary>
Task<List<ObMaterialType>> ListMaterialTypes(); Task<List<ObMaterialType>> ListMaterialTypes();
/// <summary> /// <summary>
/// 新增或编辑入职材料类型。 /// 新增或编辑入职材料类型。
/// </summary> /// </summary>
Task SaveMaterialType(OnboardingMaterialTypeSaveInput input); Task SaveMaterialType(OnboardingMaterialTypeSaveInput input);
/// <summary> /// <summary>
/// 删除未被要求或上传记录引用的材料类型。 /// 删除未被入职模板或上传记录引用的材料类型。
/// </summary> /// </summary>
Task DeleteMaterialTypes(BaseIdListInput input); Task DeleteMaterialTypes(BaseIdListInput input);
/// <summary> }
/// 查询指定公司的动态字段填写要求。
/// </summary>
Task<List<ObFormRequirement>> ListFormRequirements(long companyId);
/// <summary>
/// 新增或编辑公司岗位动态字段要求。
/// </summary>
Task SaveFormRequirement(OnboardingFormRequirementSaveInput input);
/// <summary>
/// 删除动态字段填写要求。
/// </summary>
Task DeleteFormRequirements(BaseIdListInput input);
/// <summary>
/// 查询指定公司的材料上传要求。
/// </summary>
Task<List<ObMaterialRequirement>> ListMaterialRequirements(long companyId);
/// <summary>
/// 新增或编辑公司岗位材料要求。
/// </summary>
Task SaveMaterialRequirement(OnboardingMaterialRequirementSaveInput input);
/// <summary>
/// 删除材料上传要求。
/// </summary>
Task DeleteMaterialRequirements(BaseIdListInput input);
}
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 维护员工端动态字段、材料类型及公司岗位要求。 /// 维护员工端动态补充字段和材料类型;与公司、岗位的组合关系由入职模板统一管理。
/// </summary> /// </summary>
public class OnboardingEntryConfigService : IOnboardingEntryConfigService public class OnboardingEntryConfigService : IOnboardingEntryConfigService
{ {
@@ -27,8 +27,12 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
/// </summary> /// </summary>
public async Task SaveFormField(OnboardingFormFieldSaveInput input) public async Task SaveFormField(OnboardingFormFieldSaveInput input)
{ {
var fieldCodeExists = await _db.Queryable<ObFormField>() var fieldCodeQuery = _db.Queryable<ObFormField>()
.AnyAsync(x => x.FieldCode == input.FieldCode && x.Id != input.Id); .Where(x => x.FieldCode == input.FieldCode);
if (input.Id.HasValue)
fieldCodeQuery = fieldCodeQuery.Where(x => x.Id != input.Id.Value);
var fieldCodeExists = await fieldCodeQuery.AnyAsync();
if (fieldCodeExists) if (fieldCodeExists)
throw Oops.Bah("字段编码已存在"); throw Oops.Bah("字段编码已存在");
@@ -38,7 +42,7 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
FieldCode = input.FieldCode, FieldCode = input.FieldCode,
FieldName = input.FieldName, FieldName = input.FieldName,
DataType = input.DataType, DataType = input.DataType,
OptionsJson = input.OptionsJson, OptionsJson = NormalizeOptionsJson(input.OptionsJson),
ValidationRule = input.ValidationRule, ValidationRule = input.ValidationRule,
Placeholder = input.Placeholder, Placeholder = input.Placeholder,
Status = input.Status Status = input.Status
@@ -57,6 +61,27 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
await _db.Insertable(entity).ExecuteCommandAsync(); await _db.Insertable(entity).ExecuteCommandAsync();
} }
/// <summary>
/// 将空白的可选项转换为 null,避免 MySQL JSON 字段写入空字符串时报错。
/// </summary>
private static string NormalizeOptionsJson(string optionsJson)
{
if (string.IsNullOrWhiteSpace(optionsJson))
return null;
var normalizedJson = optionsJson.Trim();
try
{
using var jsonDocument = global::System.Text.Json.JsonDocument.Parse(normalizedJson);
return normalizedJson;
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah("可选项不是有效的 JSON 格式");
}
}
/// <summary> /// <summary>
/// 删除未被填写要求引用的动态字段。 /// 删除未被填写要求引用的动态字段。
/// </summary> /// </summary>
@@ -67,8 +92,10 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
var isReferenced = await _db.Queryable<ObFormRequirement>() var isReferenced = await _db.Queryable<ObFormRequirement>()
.AnyAsync(x => input.Ids.Contains(x.FieldId)); .AnyAsync(x => input.Ids.Contains(x.FieldId));
if (isReferenced) var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateFormField>()
throw Oops.Bah("字段已被填写要求引用,不能删除"); .AnyAsync(x => input.Ids.Contains(x.FieldId));
if (isReferenced || isReferencedByTemplate)
throw Oops.Bah("字段已被入职模板引用,不能删除");
await _db.Deleteable<ObFormField>().In(input.Ids).ExecuteCommandAsync(); await _db.Deleteable<ObFormField>().In(input.Ids).ExecuteCommandAsync();
} }
@@ -130,8 +157,10 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
var isReferenced = await _db.Queryable<ObMaterialRequirement>() var isReferenced = await _db.Queryable<ObMaterialRequirement>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId)); .AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (isReferenced) var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateMaterial>()
throw Oops.Bah("资料类型已被资料要求引用,不能删除"); .AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (isReferenced || isReferencedByTemplate)
throw Oops.Bah("资料类型已被入职模板引用,不能删除");
var hasUploads = await _db.Queryable<ObTaskMaterial>() var hasUploads = await _db.Queryable<ObTaskMaterial>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId)); .AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
@@ -140,142 +169,4 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
await _db.Deleteable<ObMaterialType>().In(input.Ids).ExecuteCommandAsync(); await _db.Deleteable<ObMaterialType>().In(input.Ids).ExecuteCommandAsync();
} }
}
/// <summary>
/// 查询指定公司的动态字段填写要求。
/// </summary>
public Task<List<ObFormRequirement>> ListFormRequirements(long companyId)
{
return _db.Queryable<ObFormRequirement>()
.Where(x => x.CompanyId == companyId)
.OrderBy(x => x.SortCode)
.ToListAsync();
}
/// <summary>
/// 新增或编辑公司岗位动态字段要求。
/// </summary>
public async Task SaveFormRequirement(OnboardingFormRequirementSaveInput input)
{
await EnsureCompanyAndPosition(input.CompanyId, input.PositionId);
var fieldExists = await _db.Queryable<ObFormField>()
.AnyAsync(x => x.Id == input.FieldId && x.Status == "enable");
if (!fieldExists)
throw Oops.Bah("动态字段不存在或未启用");
var requirementExists = await _db.Queryable<ObFormRequirement>()
.AnyAsync(x => x.CompanyId == input.CompanyId
&& x.PositionId == input.PositionId
&& x.FieldId == input.FieldId
&& x.Id != input.Id);
if (requirementExists)
throw Oops.Bah("该公司、岗位和字段的要求已存在");
var entity = new ObFormRequirement
{
Id = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
CompanyId = input.CompanyId,
PositionId = input.PositionId,
FieldId = input.FieldId,
Required = input.Required,
SortCode = input.SortCode
};
if (input.Id.HasValue)
{
await _db.Updateable(entity).ExecuteCommandAsync();
return;
}
await _db.Insertable(entity).ExecuteCommandAsync();
}
/// <summary>
/// 删除动态字段填写要求。
/// </summary>
public Task DeleteFormRequirements(BaseIdListInput input)
{
if (input.Ids.Count == 0)
return Task.CompletedTask;
return _db.Deleteable<ObFormRequirement>().In(input.Ids).ExecuteCommandAsync();
}
/// <summary>
/// 查询指定公司的材料上传要求。
/// </summary>
public Task<List<ObMaterialRequirement>> ListMaterialRequirements(long companyId)
{
return _db.Queryable<ObMaterialRequirement>()
.Where(x => x.CompanyId == companyId)
.OrderBy(x => x.SortCode)
.ToListAsync();
}
/// <summary>
/// 新增或编辑公司岗位材料要求。
/// </summary>
public async Task SaveMaterialRequirement(OnboardingMaterialRequirementSaveInput input)
{
await EnsureCompanyAndPosition(input.CompanyId, input.PositionId);
var materialTypeExists = await _db.Queryable<ObMaterialType>()
.AnyAsync(x => x.Id == input.MaterialTypeId && x.Status == "enable");
if (!materialTypeExists)
throw Oops.Bah("资料类型不存在或未启用");
var requirementExists = await _db.Queryable<ObMaterialRequirement>()
.AnyAsync(x => x.CompanyId == input.CompanyId
&& x.PositionId == input.PositionId
&& x.MaterialTypeId == input.MaterialTypeId
&& x.Id != input.Id);
if (requirementExists)
throw Oops.Bah("该公司、岗位和资料类型的要求已存在");
var entity = new ObMaterialRequirement
{
Id = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
CompanyId = input.CompanyId,
PositionId = input.PositionId,
MaterialTypeId = input.MaterialTypeId,
Required = input.Required,
SortCode = input.SortCode
};
if (input.Id.HasValue)
{
await _db.Updateable(entity).ExecuteCommandAsync();
return;
}
await _db.Insertable(entity).ExecuteCommandAsync();
}
/// <summary>
/// 删除材料上传要求。
/// </summary>
public Task DeleteMaterialRequirements(BaseIdListInput input)
{
if (input.Ids.Count == 0)
return Task.CompletedTask;
return _db.Deleteable<ObMaterialRequirement>().In(input.Ids).ExecuteCommandAsync();
}
private async Task EnsureCompanyAndPosition(long companyId, long? positionId)
{
var companyExists = await _db.Queryable<ObCompany>()
.AnyAsync(x => x.Id == companyId && x.Status == "enable");
if (!companyExists)
throw Oops.Bah("公司不存在或未启用");
if (!positionId.HasValue)
return;
var positionExists = await _db.Queryable<ObPosition>()
.AnyAsync(x => x.Id == positionId.Value && x.CompanyId == companyId && x.Status == "enable");
if (!positionExists)
throw Oops.Bah("岗位不存在、未启用或不属于该公司");
}
}
@@ -14,4 +14,9 @@ public interface IOnboardingTaskLifecycleService : ITransient
/// 撤销尚未归档的入职任务并使员工链接失效。 /// 撤销尚未归档的入职任务并使员工链接失效。
/// </summary> /// </summary>
Task Revoke(OnboardingAuditActionInput input); Task Revoke(OnboardingAuditActionInput input);
/// <summary>
/// 删除已撤销的入职任务及其全部关联快照数据。
/// </summary>
Task Delete(BaseIdInput input);
} }
@@ -55,6 +55,48 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
await Log(task, OnboardingAuditAction.Revoke, input.AuditOpinion); await Log(task, OnboardingAuditAction.Revoke, input.AuditOpinion);
} }
/// <summary>
/// 删除已撤销的入职任务及其全部关联快照数据。
/// </summary>
public async Task Delete(BaseIdInput input)
{
var task = await Authorize(input.Id);
if (task.TaskStatus != OnboardingTaskStatus.Revoked)
{
throw Oops.Bah("仅已撤销的任务可以删除");
}
await _db.Ado.BeginTranAsync();
try
{
var taskContractIds = await _db.Queryable<ObTaskContract>()
.Where(x => x.TaskId == task.Id)
.Select(x => x.Id)
.ToListAsync();
if (taskContractIds.Count > 0)
{
await _db.Deleteable<ObTaskContractVariable>()
.Where(x => taskContractIds.Contains(x.TaskContractId))
.ExecuteCommandAsync();
}
await _db.Deleteable<ObTaskContract>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskLink>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskMaterialRequirement>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskMaterial>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskEmployeeInfo>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskAudit>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObOnboardingTask>().In(task.Id).ExecuteCommandAsync();
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
}
private async Task<ObOnboardingTask> Authorize(long taskId) private async Task<ObOnboardingTask> Authorize(long taskId)
{ {
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
@@ -0,0 +1,213 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板分页查询参数。
/// </summary>
public class OnboardingTemplatePageInput : BasePageInput
{
/// <summary>
/// 模板编码或名称关键词。
/// </summary>
public string Keyword { get; set; }
/// <summary>
/// 所属公司 Id。
/// </summary>
public long? CompanyId { get; set; }
/// <summary>
/// 适用岗位 Id。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 启用状态。
/// </summary>
public string Status { get; set; }
}
/// <summary>
/// 保存入职模板参数。
/// </summary>
public class OnboardingTemplateSaveInput
{
/// <summary>
/// 编辑时传入的入职模板 Id。
/// </summary>
public long? Id { get; set; }
/// <summary>
/// 入职模板业务编码。
/// </summary>
public string TemplateCode { get; set; }
/// <summary>
/// 入职模板名称。
/// </summary>
public string TemplateName { get; set; }
/// <summary>
/// 所属公司 Id。
/// </summary>
public long CompanyId { get; set; }
/// <summary>
/// 适用岗位 Id 列表。
/// </summary>
public List<long> PositionIds { get; set; } = new();
/// <summary>
/// 合同配置列表。
/// </summary>
public List<OnboardingTemplateContractInput> Contracts { get; set; } = new();
/// <summary>
/// 动态补充字段列表。
/// </summary>
public List<OnboardingTemplateFormFieldInput> FormFields { get; set; } = new();
/// <summary>
/// 上传材料列表。
/// </summary>
public List<OnboardingTemplateMaterialInput> Materials { get; set; } = new();
/// <summary>
/// 模板状态。
/// </summary>
public string Status { get; set; } = "enable";
/// <summary>
/// 模板说明。
/// </summary>
public string Description { get; set; }
}
/// <summary>
/// 入职模板合同配置参数。
/// </summary>
public class OnboardingTemplateContractInput
{
/// <summary>
/// 合同模板 Id。
/// </summary>
public long ContractTemplateId { get; set; }
/// <summary>
/// 合同模板版本 Id。
/// </summary>
public long ContractTemplateVersionId { get; set; }
/// <summary>
/// 是否必须查看确认。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 打印份数。
/// </summary>
public int PrintCopies { get; set; } = 1;
/// <summary>
/// 排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 入职模板补充字段参数。
/// </summary>
public class OnboardingTemplateFormFieldInput
{
/// <summary>
/// 动态字段 Id。
/// </summary>
public long FieldId { get; set; }
/// <summary>
/// 是否必填。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 入职模板上传材料参数。
/// </summary>
public class OnboardingTemplateMaterialInput
{
/// <summary>
/// 材料类型 Id。
/// </summary>
public long MaterialTypeId { get; set; }
/// <summary>
/// 是否必传。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 入职模板列表输出。
/// </summary>
public class OnboardingTemplateOutput : ObOnboardingTemplate
{
/// <summary>
/// 所属公司名称。
/// </summary>
public string CompanyName { get; set; }
/// <summary>
/// 适用岗位 Id 列表。
/// </summary>
public List<long> PositionIds { get; set; } = new();
/// <summary>
/// 适用岗位名称列表。
/// </summary>
public List<string> PositionNames { get; set; } = new();
/// <summary>
/// 合同配置数量。
/// </summary>
public int ContractCount { get; set; }
/// <summary>
/// 动态补充字段数量。
/// </summary>
public int FormFieldCount { get; set; }
/// <summary>
/// 上传材料数量。
/// </summary>
public int MaterialCount { get; set; }
}
/// <summary>
/// 入职模板详情输出。
/// </summary>
public class OnboardingTemplateDetailOutput : OnboardingTemplateOutput
{
/// <summary>
/// 合同配置明细。
/// </summary>
public List<ObOnboardingTemplateContract> Contracts { get; set; } = new();
/// <summary>
/// 动态补充字段配置明细。
/// </summary>
public List<ObOnboardingTemplateFormField> FormFields { get; set; } = new();
/// <summary>
/// 上传材料配置明细。
/// </summary>
public List<ObOnboardingTemplateMaterial> Materials { get; set; } = new();
}
@@ -0,0 +1,32 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板聚合配置服务。
/// </summary>
public interface IOnboardingTemplateService : ITransient
{
/// <summary>
/// 分页查询入职模板。
/// </summary>
Task<SqlSugarPagedList<OnboardingTemplateOutput>> Page(OnboardingTemplatePageInput input);
/// <summary>
/// 查询入职模板详情。
/// </summary>
Task<OnboardingTemplateDetailOutput> Detail(BaseIdInput input);
/// <summary>
/// 新增或编辑入职模板及全部关联配置。
/// </summary>
Task<long> Save(OnboardingTemplateSaveInput input);
/// <summary>
/// 删除未被任务使用的入职模板。
/// </summary>
Task Delete(BaseIdListInput input);
/// <summary>
/// 查询公司岗位可使用的入职模板。
/// </summary>
Task<List<OnboardingTemplateOutput>> ListEnabled(long companyId, long positionId);
}
@@ -0,0 +1,356 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职模板聚合配置及公司岗位匹配能力。
/// </summary>
public class OnboardingTemplateService : IOnboardingTemplateService
{
private readonly SqlSugarScope _db;
public OnboardingTemplateService()
{
_db = DbContext.DB;
}
/// <inheritdoc />
public async Task<SqlSugarPagedList<OnboardingTemplateOutput>> Page(OnboardingTemplatePageInput input)
{
var query = _db.Queryable<ObOnboardingTemplate>()
.Where(item => !item.IsDelete)
.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(!string.IsNullOrWhiteSpace(input.Status), item => item.Status == input.Status);
if (input.PositionId.HasValue)
{
var templateIds = await _db.Queryable<ObOnboardingTemplatePosition>()
.Where(item => item.PositionId == input.PositionId.Value)
.Select(item => item.OnboardingTemplateId)
.ToListAsync();
query = query.Where(item => templateIds.Contains(item.Id));
}
var page = await query
.OrderByDescending(item => item.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
var rows = await BuildOutputs(page.List.ToList());
return new SqlSugarPagedList<OnboardingTemplateOutput>
{
PageNum = page.PageNum,
PageSize = page.PageSize,
Total = page.Total,
Pages = page.Pages,
HasPrevPages = page.HasPrevPages,
HasNextPages = page.HasNextPages,
List = rows
};
}
/// <inheritdoc />
public async Task<OnboardingTemplateDetailOutput> Detail(BaseIdInput input)
{
var template = await _db.Queryable<ObOnboardingTemplate>().InSingleAsync(input.Id)
?? throw Oops.Bah("入职模板不存在");
var summary = (await BuildOutputs(new List<ObOnboardingTemplate> { template })).Single();
return new OnboardingTemplateDetailOutput
{
Id = summary.Id,
TemplateCode = summary.TemplateCode,
TemplateName = summary.TemplateName,
CompanyId = summary.CompanyId,
CompanyName = summary.CompanyName,
Description = summary.Description,
Status = summary.Status,
CreateTime = summary.CreateTime,
UpdateTime = summary.UpdateTime,
PositionIds = summary.PositionIds,
PositionNames = summary.PositionNames,
ContractCount = summary.ContractCount,
FormFieldCount = summary.FormFieldCount,
MaterialCount = summary.MaterialCount,
Contracts = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.OnboardingTemplateId == template.Id)
.OrderBy(item => item.SortCode)
.ToListAsync(),
FormFields = new List<ObOnboardingTemplateFormField>(),
Materials = await _db.Queryable<ObOnboardingTemplateMaterial>()
.Where(item => item.OnboardingTemplateId == template.Id)
.OrderBy(item => item.SortCode)
.ToListAsync()
};
}
/// <inheritdoc />
public async Task<long> Save(OnboardingTemplateSaveInput input)
{
NormalizeAndValidateInput(input);
await ValidateReferences(input);
var templateId = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var codeExists = await _db.Queryable<ObOnboardingTemplate>()
.AnyAsync(item => item.TemplateCode == input.TemplateCode && item.Id != templateId);
if (codeExists)
throw Oops.Bah("入职模板编码已存在");
var entity = new ObOnboardingTemplate
{
Id = templateId,
TemplateCode = input.TemplateCode.Trim(),
TemplateName = input.TemplateName.Trim(),
CompanyId = input.CompanyId,
Description = input.Description,
Status = input.Status
};
await _db.Ado.BeginTranAsync();
try
{
if (input.Id.HasValue)
{
var exists = await _db.Queryable<ObOnboardingTemplate>().AnyAsync(item => item.Id == templateId);
if (!exists)
throw Oops.Bah("入职模板不存在");
await _db.Updateable(entity).IgnoreColumns(ignoreAllNullColumns: true).ExecuteCommandAsync();
await DeleteRelations(templateId);
}
else
{
await _db.Insertable(entity).ExecuteCommandAsync();
}
await InsertRelations(templateId, input);
await _db.Ado.CommitTranAsync();
return templateId;
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
}
/// <inheritdoc />
public async Task Delete(BaseIdListInput input)
{
if (input.Ids.Count == 0)
return;
var isUsed = await _db.Queryable<ObOnboardingTask>()
.AnyAsync(item => input.Ids.Contains(item.OnboardingTemplateId));
if (isUsed)
throw Oops.Bah("入职模板已生成任务,不能删除,可以改为停用");
await _db.Ado.BeginTranAsync();
try
{
foreach (var id in input.Ids)
await DeleteRelations(id);
await _db.Deleteable<ObOnboardingTemplate>()
.Where(it => input.Ids.Contains(it.Id))
.ExecuteCommandAsync();
await _db.Updateable<ObOnboardingTemplate>()
.SetColumns(it => it.IsDelete == true)
.Where(it => input.Ids.Contains(it.Id))
.ExecuteCommandAsync();
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
}
/// <inheritdoc />
public async Task<List<OnboardingTemplateOutput>> ListEnabled(long companyId, long positionId)
{
var templateIds = await _db.Queryable<ObOnboardingTemplatePosition>()
.Where(item => item.PositionId == positionId)
.Select(item => item.OnboardingTemplateId)
.ToListAsync();
var templates = await _db.Queryable<ObOnboardingTemplate>()
.Where(item => item.CompanyId == companyId
&& item.Status == "enable"
&& templateIds.Contains(item.Id))
.OrderBy(item => item.TemplateName)
.ToListAsync();
return await BuildOutputs(templates);
}
private static void NormalizeAndValidateInput(OnboardingTemplateSaveInput input)
{
input.TemplateCode = input.TemplateCode?.Trim();
input.TemplateName = input.TemplateName?.Trim();
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>();
input.Contracts = input.Contracts?
.GroupBy(item => item.ContractTemplateVersionId)
.Select(group => group.First())
.ToList() ?? new List<OnboardingTemplateContractInput>();
input.FormFields = input.FormFields?
.GroupBy(item => item.FieldId)
.Select(group => group.First())
.ToList() ?? new List<OnboardingTemplateFormFieldInput>();
input.Materials = input.Materials?
.GroupBy(item => item.MaterialTypeId)
.Select(group => group.First())
.ToList() ?? new List<OnboardingTemplateMaterialInput>();
if (string.IsNullOrWhiteSpace(input.TemplateCode) || string.IsNullOrWhiteSpace(input.TemplateName))
throw Oops.Bah("请填写入职模板编码和名称");
if (input.CompanyId <= 0)
throw Oops.Bah("请选择所属公司");
if (input.PositionIds.Count == 0)
throw Oops.Bah("请至少选择一个适用岗位");
if (input.Contracts.Count == 0)
throw Oops.Bah("请至少选择一份合同文件");
if (input.Status != "enable" && input.Status != "disable")
throw Oops.Bah("入职模板状态不正确");
if (input.Contracts.Any(item => item.PrintCopies <= 0))
throw Oops.Bah("合同打印份数必须大于 0");
}
private async Task ValidateReferences(OnboardingTemplateSaveInput input)
{
var companyEnabled = await _db.Queryable<ObCompany>()
.AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable");
if (!companyEnabled)
throw Oops.Bah("所属公司不存在或未启用");
var positionCount = await _db.Queryable<ObPosition>()
.CountAsync(item => input.PositionIds.Contains(item.Id)
&& item.CompanyId == input.CompanyId
&& item.Status == "enable");
if (positionCount != input.PositionIds.Count)
throw Oops.Bah("存在不属于该公司或未启用的岗位");
var versionIds = input.Contracts.Select(item => item.ContractTemplateVersionId).ToList();
var versions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
if (versions.Count != versionIds.Count
|| input.Contracts.Any(contract => versions.All(version => version.Id != contract.ContractTemplateVersionId
|| version.TemplateId != contract.ContractTemplateId)))
{
throw Oops.Bah("合同模板与版本不匹配");
}
if (input.Status == "enable")
{
if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled))
throw Oops.Bah("启用入职模板前,请先启用全部合同版本");
}
var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList();
if (materialIds.Count > 0)
{
var materialCount = await _db.Queryable<ObMaterialType>()
.CountAsync(item => materialIds.Contains(item.Id) && item.Status == "enable");
if (materialCount != materialIds.Count)
throw Oops.Bah("存在未启用或不存在的上传材料类型");
}
}
private async Task InsertRelations(long templateId, OnboardingTemplateSaveInput input)
{
var positions = input.PositionIds.Select(positionId => new ObOnboardingTemplatePosition
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
OnboardingTemplateId = templateId,
PositionId = positionId
}).ToList();
await _db.Insertable(positions).ExecuteCommandAsync();
var contracts = input.Contracts.Select(item => new ObOnboardingTemplateContract
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
OnboardingTemplateId = templateId,
ContractTemplateId = item.ContractTemplateId,
ContractTemplateVersionId = item.ContractTemplateVersionId,
Required = item.Required,
PrintCopies = item.PrintCopies,
SortCode = item.SortCode
}).ToList();
await _db.Insertable(contracts).ExecuteCommandAsync();
if (input.Materials != null && input.Materials.Count > 0)
{
var materials = input.Materials.Select(item => new ObOnboardingTemplateMaterial
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
OnboardingTemplateId = templateId,
MaterialTypeId = item.MaterialTypeId,
Required = item.Required,
SortCode = item.SortCode
}).ToList();
await _db.Insertable(materials).ExecuteCommandAsync();
}
}
private async Task DeleteRelations(long templateId)
{
await _db.Deleteable<ObOnboardingTemplatePosition>()
.Where(item => item.OnboardingTemplateId == templateId)
.ExecuteCommandAsync();
await _db.Deleteable<ObOnboardingTemplateContract>()
.Where(item => item.OnboardingTemplateId == templateId)
.ExecuteCommandAsync();
await _db.Deleteable<ObOnboardingTemplateMaterial>()
.Where(item => item.OnboardingTemplateId == templateId)
.ExecuteCommandAsync();
}
private async Task<List<OnboardingTemplateOutput>> BuildOutputs(List<ObOnboardingTemplate> templates)
{
if (templates.Count == 0)
return new List<OnboardingTemplateOutput>();
var templateIds = templates.Select(item => item.Id).ToList();
var companyIds = templates.Select(item => item.CompanyId).Distinct().ToList();
var companies = await _db.Queryable<ObCompany>()
.Where(item => companyIds.Contains(item.Id))
.ToListAsync();
var relations = await _db.Queryable<ObOnboardingTemplatePosition>()
.Where(item => templateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
var positionIds = relations.Select(item => item.PositionId).Distinct().ToList();
var positions = await _db.Queryable<ObPosition>()
.Where(item => positionIds.Contains(item.Id))
.ToListAsync();
var contracts = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => templateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
var materials = await _db.Queryable<ObOnboardingTemplateMaterial>()
.Where(item => templateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
return templates.Select(template =>
{
var templatePositions = relations.Where(item => item.OnboardingTemplateId == template.Id).ToList();
return new OnboardingTemplateOutput
{
Id = template.Id,
TemplateCode = template.TemplateCode,
TemplateName = template.TemplateName,
CompanyId = template.CompanyId,
CompanyName = companies.FirstOrDefault(item => item.Id == template.CompanyId)?.CompanyName,
Description = template.Description,
Status = template.Status,
CreateTime = template.CreateTime,
UpdateTime = template.UpdateTime,
PositionIds = templatePositions.Select(item => item.PositionId).ToList(),
PositionNames = templatePositions
.Select(relation => positions.FirstOrDefault(position => position.Id == relation.PositionId)?.PositionName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToList(),
ContractCount = contracts.Count(item => item.OnboardingTemplateId == template.Id),
FormFieldCount = 0,
MaterialCount = materials.Count(item => item.OnboardingTemplateId == template.Id)
};
}).ToList();
}
}
@@ -10,6 +10,11 @@ public class OnboardingPositionPageInput : BasePageInput
/// </summary> /// </summary>
public long? CompanyId { get; set; } public long? CompanyId { get; set; }
/// <summary>
/// 岗位编码。
/// </summary>
public virtual string PositionCode { get; set; }
/// <summary> /// <summary>
/// 岗位名称。 /// 岗位名称。
/// </summary> /// </summary>
@@ -12,9 +12,8 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
{ {
return await Context.Queryable<ObPosition>() return await Context.Queryable<ObPosition>()
.WhereIF(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value) .WhereIF(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value)
.WhereIF( .WhereIF(!string.IsNullOrWhiteSpace(input.PositionCode), it => it.PositionCode.Contains(input.PositionCode))
!string.IsNullOrWhiteSpace(input.PositionName), .WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName))
it => it.PositionName.Contains(input.PositionName) || it.PositionCode.Contains(input.PositionName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}") .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderBy(it => it.PositionName) .OrderBy(it => it.PositionName)
@@ -55,6 +55,23 @@ public class OnboardingSaveMaterialInput
public long FileId { get; set; } public long FileId { get; set; }
} }
/// <summary>
/// 删除员工在当前可编辑任务中上传的一条材料。
/// </summary>
public class OnboardingDeleteMaterialInput
{
/// <summary>
/// 员工填写链接中的明文 Token。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string Token { get; set; }
/// <summary>
/// 任务材料记录 Id。
/// </summary>
public long TaskMaterialId { get; set; }
}
/// <summary> /// <summary>
/// 员工确认阅读合同参数。 /// 员工确认阅读合同参数。
/// </summary> /// </summary>
@@ -9,18 +9,27 @@ public interface IOnboardingPublicSubmissionService : ITransient
/// 保存候选人填写的动态字段值。 /// 保存候选人填写的动态字段值。
/// </summary> /// </summary>
Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input); Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input);
/// <summary> /// <summary>
/// 关联候选人已经上传的系统文件与材料类型。 /// 关联候选人已经上传的系统文件与材料类型。
/// </summary> /// </summary>
Task SaveMaterial(OnboardingSaveMaterialInput input); Task SaveMaterial(OnboardingSaveMaterialInput input);
/// <summary>
/// 删除可编辑任务中的一条已上传材料关联。
/// </summary>
Task DeleteMaterial(OnboardingDeleteMaterialInput input);
/// <summary> /// <summary>
/// 记录候选人已阅读并确认指定任务合同。 /// 记录候选人已阅读并确认指定任务合同。
/// </summary> /// </summary>
Task ConfirmContract(OnboardingConfirmContractInput input); Task ConfirmContract(OnboardingConfirmContractInput input);
/// <summary> /// <summary>
/// 校验全部必填资料后提交入职任务进入审核。 /// 校验全部必填资料后提交入职任务进入审核。
/// </summary> /// </summary>
Task Submit(OnboardingSubmitInput input); Task Submit(OnboardingSubmitInput input);
/// <summary> /// <summary>
/// 在保存文件前校验材料适用范围、扩展名、大小和数量。 /// 在保存文件前校验材料适用范围、扩展名、大小和数量。
/// </summary> /// </summary>
@@ -1,92 +1,66 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 实现员工端入职资料提交业务能力。 /// 实现员工端动态资料、上传材料、合同确认和最终提交能力。
/// </summary> /// </summary>
public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IOnboardingContractSnapshotService _snapshotService; private readonly IOnboardingContractSnapshotService _snapshotService;
public OnboardingPublicSubmissionService( public OnboardingPublicSubmissionService(IOnboardingContractSnapshotService snapshotService)
IOnboardingContractSnapshotService snapshotService)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_snapshotService = snapshotService; _snapshotService = snapshotService;
} }
/// <summary> /// <summary>
/// 保存候选人填写的动态字段值。 /// 保存候选人填写的动态补充字段值。
/// </summary> /// </summary>
public async Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input) public async Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input)
{ {
var task = await GetEditableTask(input.Token); await GetEditableTask(input.Token);
var requirements = await GetEffectiveFormRequirements(task); throw Oops.Bah("新版入职登记表不再使用旧动态字段接口,请通过员工基础信息接口保存补充变量");
var fieldIds = input.Values.Select(item => item.FieldId).Distinct().ToList();
var hasInapplicableField = fieldIds.Any(id => requirements.All(requirement => requirement.FieldId != id));
if (hasInapplicableField)
throw Oops.Bah("填写字段不适用于当前公司或岗位");
var fields = await _db.Queryable<ObFormField>()
.Where(field => fieldIds.Contains(field.Id) && field.Status == "enable")
.ToListAsync();
foreach (var value in input.Values)
{
var field = fields.FirstOrDefault(item => item.Id == value.FieldId)
?? throw Oops.Bah("填写字段不存在或已停用");
var old = await _db.Queryable<ObTaskFormValue>()
.FirstAsync(item => item.TaskId == task.Id && item.FieldId == field.Id);
var row = new ObTaskFormValue
{
Id = old?.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = task.Id,
FieldId = field.Id,
FieldCode = field.FieldCode,
FieldName = field.FieldName,
FieldValue = value.FieldValue,
SubmitRound = task.SubmitCount + 1
};
if (old == null)
{
await _db.Insertable(row).ExecuteCommandAsync();
}
else
{
await _db.Updateable(row).ExecuteCommandAsync();
}
}
} }
/// <summary> /// <summary>
/// 关联候选人已经上传的系统文件与材料类型。 /// 关联已上传 OSS 文件与任务材料要求。
/// </summary> /// </summary>
public async Task SaveMaterial(OnboardingSaveMaterialInput input) public async Task SaveMaterial(OnboardingSaveMaterialInput input)
{ {
var task = await GetEditableTask(input.Token); var task = await GetEditableTask(input.Token);
var requirements = await GetEffectiveMaterialRequirements(task); var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
var materialIsApplicable = requirements.Any(requirement => requirement.MaterialTypeId == input.MaterialTypeId); .FirstAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
if (!materialIsApplicable) ?? throw Oops.Bah("材料类型不属于当前入职模板");
throw Oops.Bah("资料类型不适用于当前公司或岗位");
var material = await _db.Queryable<ObMaterialType>()
.FirstAsync(item => item.Id == input.MaterialTypeId && item.Status == "enable")
?? throw Oops.Bah("资料类型不存在或已停用");
var fileExists = await _db.Queryable<SysFile>().AnyAsync(item => item.Id == input.FileId); var fileExists = await _db.Queryable<SysFile>().AnyAsync(item => item.Id == input.FileId);
if (!fileExists) if (!fileExists)
throw Oops.Bah("上传文件不存在"); throw Oops.Bah("上传文件不存在");
var materialCount = await _db.Queryable<ObTaskMaterial>() var existingMaterials = await _db.Queryable<ObTaskMaterial>()
.CountAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId); .Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
if (materialCount >= material.MaxFileCount) .ToListAsync();
throw Oops.Bah("该资料已达到最大上传数量"); var supplementMaterial = existingMaterials.FirstOrDefault(
item => item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement);
if (supplementMaterial != null)
{
supplementMaterial.FileId = input.FileId;
supplementMaterial.SubmitRound = task.SubmitCount + 1;
supplementMaterial.UploadedAt = DateTime.Now;
supplementMaterial.ReviewStatus = TaskMaterialReviewStatus.Pending;
supplementMaterial.ReviewRemark = null;
await _db.Updateable(supplementMaterial).ExecuteCommandAsync();
return;
}
if (existingMaterials.Count >= requirement.MaxFileCount)
throw Oops.Bah("该材料已达到最大上传数量");
await _db.Insertable(new ObTaskMaterial await _db.Insertable(new ObTaskMaterial
{ {
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = task.Id, TaskId = task.Id,
MaterialTypeId = material.Id, MaterialTypeId = requirement.MaterialTypeId,
MaterialName = material.MaterialName, MaterialName = requirement.MaterialName,
FileId = input.FileId, FileId = input.FileId,
SubmitRound = task.SubmitCount + 1, SubmitRound = task.SubmitCount + 1,
UploadedAt = DateTime.Now UploadedAt = DateTime.Now
@@ -94,7 +68,26 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
} }
/// <summary> /// <summary>
/// 记录候选人已阅读并确认指定任务合同。 /// 任务仍可编辑时删除一条已上传材料。
/// </summary>
public async Task DeleteMaterial(
OnboardingDeleteMaterialInput input)
{
var task = await GetEditableTask(input.Token);
var material = await _db.Queryable<ObTaskMaterial>()
.FirstAsync(item =>
item.Id == input.TaskMaterialId
&& item.TaskId == task.Id);
if (material == null)
throw Oops.Bah("上传材料不存在");
await _db.Deleteable<ObTaskMaterial>()
.In(material.Id)
.ExecuteCommandAsync();
}
/// <summary>
/// 记录候选人已经查看并确认动态生成的合同。
/// </summary> /// </summary>
public async Task ConfirmContract(OnboardingConfirmContractInput input) public async Task ConfirmContract(OnboardingConfirmContractInput input)
{ {
@@ -102,15 +95,16 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
var contract = await _db.Queryable<ObTaskContract>() var contract = await _db.Queryable<ObTaskContract>()
.FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == task.Id) .FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == task.Id)
?? throw Oops.Bah("合同不存在"); ?? throw Oops.Bah("合同不存在");
if (!contract.GeneratedDocxFileId.HasValue || !contract.ReadAt.HasValue)
throw Oops.Bah("请先查看已经生成的合同内容");
contract.ReadAt ??= DateTime.Now;
contract.ConfirmedAt = DateTime.Now; contract.ConfirmedAt = DateTime.Now;
contract.ContractStatus = TaskContractStatus.Confirmed; contract.ContractStatus = TaskContractStatus.Confirmed;
await _db.Updateable(contract).ExecuteCommandAsync(); await _db.Updateable(contract).ExecuteCommandAsync();
} }
/// <summary> /// <summary>
/// 校验全部必填资料后提交入职任务进入审核。 /// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
/// </summary> /// </summary>
public async Task Submit(OnboardingSubmitInput input) public async Task Submit(OnboardingSubmitInput input)
{ {
@@ -120,26 +114,15 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
var basicInfoIncomplete = info == null var basicInfoIncomplete = info == null
|| string.IsNullOrWhiteSpace(info.EmployeeName) || string.IsNullOrWhiteSpace(info.EmployeeName)
|| string.IsNullOrWhiteSpace(info.Phone) || string.IsNullOrWhiteSpace(info.Phone)
|| string.IsNullOrWhiteSpace(info.IdCardNumber); || string.IsNullOrWhiteSpace(info.IdCardNumber)
|| string.IsNullOrWhiteSpace(info.BankCardNumber)
|| string.IsNullOrWhiteSpace(info.BankName);
if (basicInfoIncomplete) if (basicInfoIncomplete)
throw Oops.Bah("请先完整填写基本信息"); throw Oops.Bah("请先完整填写基本信息");
await _snapshotService.Build(task.Id, info); await _snapshotService.Build(task.Id, info);
// 提交时统一完成动态字段、材料、合同变量和合同确认校验,保存阶段只负责暂存。 var requiredMaterials = (await GetMaterialRequirements(task.Id))
var requiredFields = (await GetEffectiveFormRequirements(task))
.Where(item => item.Required)
.Select(item => item.FieldId)
.ToList();
var values = await _db.Queryable<ObTaskFormValue>()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
var hasMissingRequiredField = requiredFields.Any(
id => values.All(item => item.FieldId != id || string.IsNullOrWhiteSpace(item.FieldValue)));
if (hasMissingRequiredField)
throw Oops.Bah("仍有必填动态信息未填写");
var requiredMaterials = (await GetEffectiveMaterialRequirements(task))
.Where(item => item.Required) .Where(item => item.Required)
.Select(item => item.MaterialTypeId) .Select(item => item.MaterialTypeId)
.ToList(); .ToList();
@@ -149,14 +132,14 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
var hasMissingRequiredMaterial = requiredMaterials.Any( var hasMissingRequiredMaterial = requiredMaterials.Any(
id => materials.All(item => item.MaterialTypeId != id)); id => materials.All(item => item.MaterialTypeId != id));
if (hasMissingRequiredMaterial) if (hasMissingRequiredMaterial)
throw Oops.Bah("仍有必传资料未上传"); throw Oops.Bah("仍有必传材料未上传");
await EnsureRequiredContractVariables(task); await EnsureRequiredContractVariables(task);
var hasUnconfirmedRequiredContract = await _db.Queryable<ObTaskContract>() var hasUnconfirmedRequiredContract = await _db.Queryable<ObTaskContract>()
.AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null); .AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null);
if (hasUnconfirmedRequiredContract) if (hasUnconfirmedRequiredContract)
throw Oops.Bah("请确认阅读全部必签合同"); throw Oops.Bah("请查看并确认全部必签合同");
task.TaskStatus = OnboardingTaskStatus.Reviewing; task.TaskStatus = OnboardingTaskStatus.Reviewing;
task.SubmitCount++; task.SubmitCount++;
@@ -171,42 +154,40 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
AuditRound = task.SubmitCount, AuditRound = task.SubmitCount,
AuditAction = OnboardingAuditAction.Submit, AuditAction = OnboardingAuditAction.Submit,
AuditOpinion = "员工提交入职资料", AuditOpinion = "员工提交入职资料",
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new { info, values, materials }), SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new { info, materials }),
AuditedAt = DateTime.Now AuditedAt = DateTime.Now
}).ExecuteCommandAsync(); }).ExecuteCommandAsync();
} }
/// <summary> /// <summary>
/// 在保存文件前校验材料适用范围、扩展名、大小和数量。 /// 上传 OSS 前按照任务材料快照校验扩展名、文件大小和数量。
/// </summary> /// </summary>
public async Task ValidateMaterialUpload(OnboardingMaterialUploadValidationInput input) public async Task ValidateMaterialUpload(OnboardingMaterialUploadValidationInput input)
{ {
var task = await GetEditableTask(input.Token); var task = await GetEditableTask(input.Token);
var requirements = await GetEffectiveMaterialRequirements(task); var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
var materialIsApplicable = requirements.Any(requirement => requirement.MaterialTypeId == input.MaterialTypeId); .FirstAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
if (!materialIsApplicable) ?? throw Oops.Bah("材料类型不属于当前入职模板");
throw Oops.Bah("资料类型不适用于当前公司或岗位");
var material = await _db.Queryable<ObMaterialType>()
.FirstAsync(item => item.Id == input.MaterialTypeId && item.Status == "enable")
?? throw Oops.Bah("资料类型不存在或已停用");
var suffix = Path.GetExtension(input.FileName).TrimStart('.').ToLowerInvariant(); var suffix = Path.GetExtension(input.FileName).TrimStart('.').ToLowerInvariant();
var allowedExtensions = (material.AllowedExtensions ?? string.Empty) var allowedExtensions = (requirement.AllowedExtensions ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(item => item.TrimStart('.').ToLowerInvariant()); .Select(item => item.TrimStart('.').ToLowerInvariant());
var suffixNotAllowed = allowedExtensions.Any() && !allowedExtensions.Contains(suffix); var suffixNotAllowed = allowedExtensions.Any() && !allowedExtensions.Contains(suffix);
if (suffixNotAllowed) if (suffixNotAllowed)
throw Oops.Bah("该资料不允许上传此文件类型"); throw Oops.Bah("该材料不允许上传此文件类型");
var fileTooLarge = material.MaxFileSizeMb.HasValue var fileTooLarge = requirement.MaxFileSizeMb.HasValue
&& input.FileLength > material.MaxFileSizeMb.Value * 1024L * 1024L; && input.FileLength > requirement.MaxFileSizeMb.Value * 1024L * 1024L;
if (fileTooLarge) if (fileTooLarge)
throw Oops.Bah("文件超过允许的大小"); throw Oops.Bah("文件超过允许的大小");
var materialCount = await _db.Queryable<ObTaskMaterial>() var materials = await _db.Queryable<ObTaskMaterial>()
.CountAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId); .Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
if (materialCount >= material.MaxFileCount) .ToListAsync();
throw Oops.Bah("该资料已达到最大上传数量"); var canReplaceSupplement = materials.Any(
item => item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement);
if (materials.Count >= requirement.MaxFileCount && !canReplaceSupplement)
throw Oops.Bah("该材料已达到最大上传数量");
} }
private async Task EnsureRequiredContractVariables(ObOnboardingTask task) private async Task EnsureRequiredContractVariables(ObOnboardingTask task)
@@ -234,34 +215,19 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
throw Oops.Bah($"合同“{missing.contract.ContractName}”的必填变量“{missing.definition.VariableName}”尚未填写"); throw Oops.Bah($"合同“{missing.contract.ContractName}”的必填变量“{missing.definition.VariableName}”尚未填写");
} }
private async Task<List<ObFormRequirement>> GetEffectiveFormRequirements(ObOnboardingTask task) private Task<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId)
{ {
var requirements = await _db.Queryable<ObFormRequirement>() return _db.Queryable<ObTaskMaterialRequirement>()
.Where(item => item.CompanyId == task.CompanyId .Where(item => item.TaskId == taskId)
&& (item.PositionId == null || item.PositionId == task.PositionId)) .OrderBy(item => item.SortCode)
.ToListAsync(); .ToListAsync();
return requirements
.GroupBy(item => item.FieldId)
.Select(group => group.FirstOrDefault(item => item.PositionId == task.PositionId) ?? group.First())
.ToList();
}
private async Task<List<ObMaterialRequirement>> GetEffectiveMaterialRequirements(ObOnboardingTask task)
{
var requirements = await _db.Queryable<ObMaterialRequirement>()
.Where(item => item.CompanyId == task.CompanyId
&& (item.PositionId == null || item.PositionId == task.PositionId))
.ToListAsync();
return requirements
.GroupBy(item => item.MaterialTypeId)
.Select(group => group.FirstOrDefault(item => item.PositionId == task.PositionId) ?? group.First())
.ToList();
} }
private async Task<ObOnboardingTask> GetEditableTask(string token) private async Task<ObOnboardingTask> GetEditableTask(string token)
{ {
if (string.IsNullOrWhiteSpace(token))
throw Oops.Bah("入职链接 Token 不能为空");
var hash = Convert.ToHexString( var hash = Convert.ToHexString(
global::System.Security.Cryptography.SHA256.HashData( global::System.Security.Cryptography.SHA256.HashData(
global::System.Text.Encoding.UTF8.GetBytes(token))); global::System.Text.Encoding.UTF8.GetBytes(token)));
@@ -280,4 +246,4 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
return task; return task;
} }
} }
@@ -1,22 +1,25 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 员工手机端入口;仅凭一次性任务链接访问,绝不创建正式员工。 /// 员工手机端公开入口,仅凭一次性任务链接访问入职资料。
/// </summary> /// </summary>
public class OnboardingPublicTaskService : IOnboardingPublicTaskService public class OnboardingPublicTaskService : IOnboardingPublicTaskService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IOnboardingContractSnapshotService _snapshotService; private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingContractDocumentService _contractDocumentService;
public OnboardingPublicTaskService( public OnboardingPublicTaskService(
IOnboardingContractSnapshotService snapshotService) IOnboardingContractSnapshotService snapshotService,
IOnboardingContractDocumentService contractDocumentService)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_snapshotService = snapshotService; _snapshotService = snapshotService;
_contractDocumentService = contractDocumentService;
} }
/// <summary> /// <summary>
/// 通过有效 Token 获取员工端任务、字段、材料和合同信息。 /// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。
/// </summary> /// </summary>
public async Task<OnboardingTaskPublicDetailOutput> Detail(OnboardingTaskPublicAccessInput input) public async Task<OnboardingTaskPublicDetailOutput> Detail(OnboardingTaskPublicAccessInput input)
{ {
@@ -27,135 +30,140 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
link.AccessCount++; link.AccessCount++;
link.LastAccessedAt = DateTime.Now; link.LastAccessedAt = DateTime.Now;
await _db.Updateable(link).ExecuteCommandAsync(); await _db.Updateable(link).ExecuteCommandAsync();
var materialRequirements = await _db.Queryable<ObTaskMaterialRequirement>()
.Where(item => item.TaskId == task.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var formRequirements = await GetEffectiveFormRequirements(task); var contracts = await _db.Queryable<ObTaskContract>()
var materialRequirements = await GetEffectiveMaterialRequirements(task); .Where(item => item.TaskId == task.Id)
var fields = await _db.Queryable<ObFormField>()
.Where(x => formRequirements.Select(r => r.FieldId).Contains(x.Id) && x.Status == "enable")
.ToListAsync();
var materialTypes = await _db.Queryable<ObMaterialType>()
.Where(x => materialRequirements.Select(r => r.MaterialTypeId).Contains(x.Id) && x.Status == "enable")
.ToListAsync(); .ToListAsync();
var templateVersionIds = contracts
.Select(item => item.TemplateVersionId)
.Distinct()
.ToList();
var contractVariables = new List<OnboardingPublicContractVariableOutput>();
if (templateVersionIds.Count > 0)
{
var employeeVariables = await _db.Queryable<ObTemplateVariable>()
.Where(item => templateVersionIds.Contains(item.TemplateVersionId)
&& item.VariableType == ContractVariableType.Employee)
.OrderBy(item => item.SortCode)
.ToListAsync();
contractVariables = employeeVariables.Select(item => new OnboardingPublicContractVariableOutput
{
VariableCode = item.VariableCode,
VariableName = item.VariableName,
VariableType = item.VariableType,
DataType = item.DataType,
DefaultValue = item.DefaultValue,
OptionsJson = item.OptionsJson,
Required = item.Required
}).ToList();
}
return new OnboardingTaskPublicDetailOutput return new OnboardingTaskPublicDetailOutput
{ {
Task = task, Task = task,
EmployeeInfo = await _db.Queryable<ObTaskEmployeeInfo>().FirstAsync(it => it.TaskId == task.Id), EmployeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
Contracts = await _db.Queryable<ObTaskContract>() .FirstAsync(item => item.TaskId == task.Id),
.Where(it => it.TaskId == task.Id) Contracts = contracts,
.ToListAsync(), DynamicValues = new List<ObTaskFormValue>(),
DynamicValues = await _db.Queryable<ObTaskFormValue>()
.Where(x => x.TaskId == task.Id)
.ToListAsync(),
Materials = await _db.Queryable<ObTaskMaterial>() Materials = await _db.Queryable<ObTaskMaterial>()
.Where(x => x.TaskId == task.Id) .Where(item => item.TaskId == task.Id)
.ToListAsync(), .ToListAsync(),
FormFields = formRequirements FormFields = new List<OnboardingPublicFormFieldOutput>(),
.OrderBy(x => x.SortCode) MaterialRequirements = materialRequirements.Select(requirement => new OnboardingPublicMaterialOutput
.Select(requirement => fields.FirstOrDefault(field => field.Id == requirement.FieldId)) {
.Where(field => field != null) MaterialTypeId = requirement.MaterialTypeId,
.Select(field => new OnboardingPublicFormFieldOutput MaterialName = requirement.MaterialName,
{ AllowedExtensions = requirement.AllowedExtensions,
FieldId = field.Id, MaxFileCount = requirement.MaxFileCount,
FieldCode = field.FieldCode, Required = requirement.Required
FieldName = field.FieldName, }).ToList(),
DataType = field.DataType, ContractVariables = contractVariables
OptionsJson = field.OptionsJson,
Required = formRequirements.First(requirement => requirement.FieldId == field.Id).Required
})
.ToList(),
MaterialRequirements = materialRequirements
.OrderBy(x => x.SortCode)
.Select(requirement => materialTypes.FirstOrDefault(material => material.Id == requirement.MaterialTypeId))
.Where(material => material != null)
.Select(material => new OnboardingPublicMaterialOutput
{
MaterialTypeId = material.Id,
MaterialName = material.MaterialName,
AllowedExtensions = material.AllowedExtensions,
MaxFileCount = material.MaxFileCount,
Required = materialRequirements.First(requirement => requirement.MaterialTypeId == material.Id).Required
})
.ToList()
}; };
} }
/// <summary> /// <summary>
/// 保存候选人固定基础信息并刷新合同变量快照。 /// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。
/// </summary> /// </summary>
public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input) public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input)
{ {
var link = await GetActiveLink(input.Token); var link = await GetActiveLink(input.Token);
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
if (task.TaskStatus != OnboardingTaskStatus.PendingFill && task.TaskStatus != OnboardingTaskStatus.Rejected) if (task.TaskStatus != OnboardingTaskStatus.PendingFill
&& task.TaskStatus != OnboardingTaskStatus.Rejected)
{
throw Oops.Bah("当前任务不可编辑"); throw Oops.Bah("当前任务不可编辑");
}
var old = await _db.Queryable<ObTaskEmployeeInfo>().FirstAsync(it => it.TaskId == task.Id); var old = await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == task.Id);
var entity = input.Adapt<ObTaskEmployeeInfo>(); var entity = input.Adapt<ObTaskEmployeeInfo>();
entity.TaskId = task.Id; entity.TaskId = task.Id;
entity.IsDelete = false;
if (old == null) if (old == null)
{ {
entity.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); entity.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
entity.Status = CommonStatusConst.ENABLE;
entity.CreateTime = DateTime.Now;
entity.CreateUserId = null;
entity.CreateUser = null;
await _db.Insertable(entity).ExecuteCommandAsync(); await _db.Insertable(entity).ExecuteCommandAsync();
} }
else else
{ {
entity.Id = old.Id; entity.Id = old.Id;
entity.Status = old.Status;
entity.CreateTime = old.CreateTime;
entity.CreateUserId = old.CreateUserId;
entity.CreateUser = old.CreateUser;
entity.ExtJson = old.ExtJson;
entity.IsDelete = old.IsDelete;
entity.UpdateTime = DateTime.Now;
entity.UpdateUserId = null;
entity.UpdateUser = null;
await _db.Updateable(entity).ExecuteCommandAsync(); await _db.Updateable(entity).ExecuteCommandAsync();
} }
await _snapshotService.Build(task.Id, entity); await _snapshotService.Build(task.Id, entity, invalidateGeneratedPreview: true);
} }
/// <summary> /// <summary>
/// 校验任务归属后返回合同模板源文件 Id。 /// 根据员工当前已填写信息生成合同预览,并返回已替换变量的 DOCX 文件 Id。
/// </summary> /// </summary>
public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input) public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input)
{ {
var link = await GetActiveLink(input.Token); var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>() var contract = await _db.Queryable<ObTaskContract>()
.FirstAsync(x => x.Id == input.TaskContractId && x.TaskId == link.TaskId) .FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == link.TaskId)
?? throw Oops.Bah("合同不存在"); ?? throw Oops.Bah("合同不存在");
var version = await _db.Queryable<ObContractTemplateVersion>().InSingleAsync(contract.TemplateVersionId)
?? throw Oops.Bah("合同模板版本不存在");
return version.SourceDocxFileId; await _snapshotService.Build(link.TaskId);
} var fileId = await _contractDocumentService.Generate(new OnboardingGenerateContractInput
{
TaskContractId = contract.Id
});
private async Task<List<ObFormRequirement>> GetEffectiveFormRequirements(ObOnboardingTask task) contract.ReadAt ??= DateTime.Now;
{ if (contract.ContractStatus == TaskContractStatus.PendingGenerate)
var requirements = await _db.Queryable<ObFormRequirement>() contract.ContractStatus = TaskContractStatus.PendingConfirm;
.Where(x => x.CompanyId == task.CompanyId && (x.PositionId == null || x.PositionId == task.PositionId)) await _db.Updateable(contract).ExecuteCommandAsync();
.ToListAsync(); return fileId;
// 同一字段同时存在公司级和岗位级配置时,岗位配置覆盖公司默认配置。
return requirements
.GroupBy(x => x.FieldId)
.Select(group => group.FirstOrDefault(x => x.PositionId == task.PositionId) ?? group.First())
.ToList();
}
private async Task<List<ObMaterialRequirement>> GetEffectiveMaterialRequirements(ObOnboardingTask task)
{
var requirements = await _db.Queryable<ObMaterialRequirement>()
.Where(x => x.CompanyId == task.CompanyId && (x.PositionId == null || x.PositionId == task.PositionId))
.ToListAsync();
// 同一材料同时存在公司级和岗位级配置时,岗位配置覆盖公司默认配置。
return requirements
.GroupBy(x => x.MaterialTypeId)
.Select(group => group.FirstOrDefault(x => x.PositionId == task.PositionId) ?? group.First())
.ToList();
} }
private async Task<ObTaskLink> GetActiveLink(string token) private async Task<ObTaskLink> GetActiveLink(string token)
{ {
if (string.IsNullOrWhiteSpace(token))
throw Oops.Bah("入职链接 Token 不能为空");
var hash = Convert.ToHexString( var hash = Convert.ToHexString(
global::System.Security.Cryptography.SHA256.HashData( global::System.Security.Cryptography.SHA256.HashData(
global::System.Text.Encoding.UTF8.GetBytes(token))); global::System.Text.Encoding.UTF8.GetBytes(token)));
var link = await _db.Queryable<ObTaskLink>().FirstAsync(it => it.TokenHash == hash) var link = await _db.Queryable<ObTaskLink>().FirstAsync(item => item.TokenHash == hash)
?? throw Oops.Bah("链接不存在"); ?? throw Oops.Bah("链接不存在");
if (link.LinkStatus != OnboardingLinkStatus.Active || link.ExpiresAt <= DateTime.Now) if (link.LinkStatus != OnboardingLinkStatus.Active || link.ExpiresAt <= DateTime.Now)
@@ -163,4 +171,4 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
return link; return link;
} }
} }
@@ -16,6 +16,16 @@ public class OnboardingTaskCreateInput
/// </summary> /// </summary>
[IdNotNull(ErrorMessage = "岗位Id不能为空")] [IdNotNull(ErrorMessage = "岗位Id不能为空")]
public long PositionId { get; set; } public long PositionId { get; set; }
/// <summary>
/// 本次任务使用的入职模板 Id。
/// </summary>
[IdNotNull(ErrorMessage = "入职模板Id不能为空")]
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 创建任务时由人事预先填写的合同变量。
/// </summary>
public List<OnboardingInitialContractVariableInput> ContractVariables { get; set; } = new();
/// <summary> /// <summary>
/// 创建任务时指定的审核人用户 Id。 /// 创建任务时指定的审核人用户 Id。
@@ -33,6 +43,28 @@ public class OnboardingTaskCreateInput
public string Remark { get; set; } public string Remark { get; set; }
} }
/// <summary>
/// 创建任务时填写的单个合同变量。
/// </summary>
public class OnboardingInitialContractVariableInput
{
/// <summary>
/// 变量所属合同模板版本 Id。
/// </summary>
[IdNotNull(ErrorMessage = "合同模板版本Id不能为空")]
public long ContractTemplateVersionId { get; set; }
/// <summary>
/// DOCX 中的完整变量编码,例如 hr.MonthlySalary。
/// </summary>
public string VariableCode { get; set; }
/// <summary>
/// 人事录入的变量值。
/// </summary>
public string VariableValue { get; set; }
}
/// <summary> /// <summary>
/// 入职任务创建结果。 /// 入职任务创建结果。
/// </summary> /// </summary>
@@ -96,6 +96,47 @@ public class OnboardingPublicFormFieldOutput
public bool Required { get; set; } public bool Required { get; set; }
} }
/// <summary>
/// 员工端需要填写的合同员工变量。
/// </summary>
public class OnboardingPublicContractVariableOutput
{
/// <summary>
/// 合同变量编码。
/// </summary>
public string VariableCode { get; set; }
/// <summary>
/// 合同变量显示名称。
/// </summary>
public string VariableName { get; set; }
/// <summary>
/// 变量来源类型。
/// </summary>
public string VariableType { get; set; }
/// <summary>
/// 变量数据类型。
/// </summary>
public string DataType { get; set; }
/// <summary>
/// 变量默认值。
/// </summary>
public string DefaultValue { get; set; }
/// <summary>
/// 选择型变量的可选项 JSON。
/// </summary>
public string OptionsJson { get; set; }
/// <summary>
/// 是否必填。
/// </summary>
public bool Required { get; set; }
}
/// <summary> /// <summary>
/// 员工端材料上传要求。 /// 员工端材料上传要求。
/// </summary> /// </summary>
@@ -166,4 +207,9 @@ public class OnboardingTaskPublicDetailOutput
/// 候选人已上传材料列表。 /// 候选人已上传材料列表。
/// </summary> /// </summary>
public List<ObTaskMaterial> Materials { get; set; } public List<ObTaskMaterial> Materials { get; set; }
/// <summary>
/// 合同中由员工填写的扩展变量。
/// </summary>
public List<OnboardingPublicContractVariableOutput> ContractVariables { get; set; }
} }
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 实现入职任务创建业务能力。 /// 实现基于入职模板创建任务及冻结配置快照的业务能力。
/// </summary> /// </summary>
public class OnboardingTaskService : IOnboardingTaskService public class OnboardingTaskService : IOnboardingTaskService
{ {
@@ -19,42 +19,63 @@ public class OnboardingTaskService : IOnboardingTaskService
} }
/// <summary> /// <summary>
/// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。 /// 校验公司、岗位和入职模板后创建任务,并冻结合同及材料要求。
/// </summary> /// </summary>
public async Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input) public async Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input)
{ {
var companyEnabled = await _db.Queryable<ObCompany>() var companyEnabled = await _db.Queryable<ObCompany>()
.AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable"); .AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable");
if (!companyEnabled) if (!companyEnabled)
{
throw Oops.Bah("公司不存在或未启用"); throw Oops.Bah("公司不存在或未启用");
}
var positionEnabled = await _db.Queryable<ObPosition>() var positionEnabled = await _db.Queryable<ObPosition>()
.AnyAsync(item => item.Id == input.PositionId .AnyAsync(item => item.Id == input.PositionId
&& item.CompanyId == input.CompanyId && item.CompanyId == input.CompanyId
&& item.Status == "enable"); && item.Status == "enable");
if (!positionEnabled) if (!positionEnabled)
{
throw Oops.Bah("岗位不存在、未启用或不属于该公司"); throw Oops.Bah("岗位不存在、未启用或不属于该公司");
}
var scopes = await _db.Queryable<ObContractScope>() var onboardingTemplate = await _db.Queryable<ObOnboardingTemplate>()
.Where(item => item.CompanyId == input.CompanyId .FirstAsync(item => item.Id == input.OnboardingTemplateId
&& (item.PositionId == null || item.PositionId == input.PositionId)) && item.CompanyId == input.CompanyId
&& item.Status == "enable")
?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司");
var templateSupportsPosition = await _db.Queryable<ObOnboardingTemplatePosition>()
.AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id
&& item.PositionId == input.PositionId);
if (!templateSupportsPosition)
throw Oops.Bah("所选入职模板不适用于该岗位");
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
if (contractConfigs.Count == 0)
throw Oops.Bah("入职模板尚未配置合同文件");
var materialConfigs = await _db.Queryable<ObOnboardingTemplateMaterial>()
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
.OrderBy(item => item.SortCode)
.ToListAsync(); .ToListAsync();
// 同一版本同时存在“全公司”和“指定岗位”范围时,岗位专属配置优先,避免同一合同生成两次。 var templateIds = contractConfigs.Select(item => item.ContractTemplateId).Distinct().ToList();
scopes = scopes var contractTemplates = await _db.Queryable<ObContractTemplate>()
.GroupBy(item => item.TemplateVersionId) .Where(item => templateIds.Contains(item.Id))
.Select(group => group.FirstOrDefault(item => item.PositionId == input.PositionId) ?? group.First()) .ToListAsync();
.ToList(); var versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).Distinct().ToList();
if (scopes.Count == 0) var enabledVersionCount = await _db.Queryable<ObContractTemplateVersion>()
{ .CountAsync(item => versionIds.Contains(item.Id)
throw Oops.Bah("该公司和岗位尚未配置合同适用范围"); && item.VersionStatus == ContractTemplateVersionStatus.Enabled);
} if (enabledVersionCount != versionIds.Count)
throw Oops.Bah("入职模板包含未启用的合同版本,请先调整模板配置");
var materialIds = materialConfigs.Select(item => item.MaterialTypeId).Distinct().ToList();
var materialTypes = materialIds.Count == 0
? new List<ObMaterialType>()
: await _db.Queryable<ObMaterialType>().Where(item => materialIds.Contains(item.Id)).ToListAsync();
if (materialTypes.Count != materialIds.Count)
throw Oops.Bah("入职模板包含不存在的材料类型,请先调整模板配置");
var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var expiryConfig = await _configService.GetByConfigKey( var expiryConfig = await _configService.GetByConfigKey(
CateGoryConst.CONFIG_BIZ_DEFINE, CateGoryConst.CONFIG_BIZ_DEFINE,
"onboard.link.expireHours"); "onboard.link.expireHours");
@@ -65,6 +86,7 @@ public class OnboardingTaskService : IOnboardingTaskService
throw Oops.Bah("请在系统配置中设置 onboard.link.expireHours(正整数小时)"); throw Oops.Bah("请在系统配置中设置 onboard.link.expireHours(正整数小时)");
} }
var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var expiresAt = DateTime.Now.AddHours(expiryHours); var expiresAt = DateTime.Now.AddHours(expiryHours);
var token = Guid.NewGuid().ToString("N"); var token = Guid.NewGuid().ToString("N");
var task = new ObOnboardingTask var task = new ObOnboardingTask
@@ -73,13 +95,15 @@ public class OnboardingTaskService : IOnboardingTaskService
TaskNo = $"OB{DateTime.Now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}", TaskNo = $"OB{DateTime.Now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}",
CompanyId = input.CompanyId, CompanyId = input.CompanyId,
PositionId = input.PositionId, PositionId = input.PositionId,
OnboardingTemplateId = onboardingTemplate.Id,
AssignedReviewerUserId = input.AssignedReviewerUserId, AssignedReviewerUserId = input.AssignedReviewerUserId,
LinkExpireAt = expiresAt, LinkExpireAt = expiresAt,
TaskStatus = OnboardingTaskStatus.PendingFill, TaskStatus = OnboardingTaskStatus.PendingFill,
Remark = input.Remark Remark = input.Remark
}; };
// 任务、链接、合同实例和变量快照属于同一创建动作,必须保证事务一致性。 ValidateInitialContractDateRanges(input.ContractVariables);
await _db.Ado.BeginTranAsync(); await _db.Ado.BeginTranAsync();
try try
{ {
@@ -93,27 +117,56 @@ public class OnboardingTaskService : IOnboardingTaskService
ExpiresAt = expiresAt ExpiresAt = expiresAt
}).ExecuteCommandAsync(); }).ExecuteCommandAsync();
var templateIds = scopes.Select(item => item.TemplateId).Distinct().ToList(); // 初始化空白的员工入职登记表
var templates = await _db.Queryable<ObContractTemplate>() await _db.Insertable(new ObTaskEmployeeInfo
.Where(item => templateIds.Contains(item.Id))
.ToListAsync();
var contracts = scopes.Select(scope =>
{ {
var template = templates.First(item => item.Id == scope.TemplateId); Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
Status = CommonStatusConst.ENABLE,
CreateTime = DateTime.Now
}).ExecuteCommandAsync();
var contracts = contractConfigs.Select(config =>
{
var template = contractTemplates.First(item => item.Id == config.ContractTemplateId);
return new ObTaskContract return new ObTaskContract
{ {
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId, TaskId = taskId,
TemplateId = scope.TemplateId, TemplateId = config.ContractTemplateId,
TemplateVersionId = scope.TemplateVersionId, TemplateVersionId = config.ContractTemplateVersionId,
ContractName = template.TemplateName, ContractName = template.TemplateName,
ContractType = template.ContractType, ContractType = template.ContractType,
Required = scope.Required Required = config.Required,
PrintCopies = config.PrintCopies
}; };
}).ToList(); }).ToList();
await _db.Insertable(contracts).ExecuteCommandAsync(); await _db.Insertable(contracts).ExecuteCommandAsync();
if (materialConfigs.Count > 0)
{
var materialSnapshots = materialConfigs.Select(config =>
{
var material = materialTypes.First(item => item.Id == config.MaterialTypeId);
return new ObTaskMaterialRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
MaterialTypeId = material.Id,
MaterialCode = material.MaterialCode,
MaterialName = material.MaterialName,
AllowedExtensions = material.AllowedExtensions,
MaxFileCount = material.MaxFileCount,
MaxFileSizeMb = material.MaxFileSizeMb,
Required = config.Required,
SortCode = config.SortCode
};
}).ToList();
await _db.Insertable(materialSnapshots).ExecuteCommandAsync();
}
await _snapshotService.Build(taskId); await _snapshotService.Build(taskId);
await ApplyInitialContractVariables(input.ContractVariables, contracts);
await _db.Ado.CommitTranAsync(); await _db.Ado.CommitTranAsync();
} }
catch catch
@@ -130,4 +183,115 @@ public class OnboardingTaskService : IOnboardingTaskService
ExpiresAt = expiresAt ExpiresAt = expiresAt
}; };
} }
/// <summary>
/// 校验并保存任务创建时填写的人事合同变量。
/// </summary>
private async Task ApplyInitialContractVariables(
List<OnboardingInitialContractVariableInput> inputs,
List<ObTaskContract> contracts)
{
inputs ??= new List<OnboardingInitialContractVariableInput>();
var versionIds = contracts.Select(item => item.TemplateVersionId).Distinct().ToList();
var definitions = await _db.Queryable<ObTemplateVariable>()
.Where(item => versionIds.Contains(item.TemplateVersionId)
&& item.HrEditable
&& (item.VariableType == "hr"
|| item.VariableType == "contract"
|| item.VariableType == "company"
|| item.VariableType == ContractVariableType.Contract
|| item.VariableType == ContractVariableType.Company
|| item.VariableCode.StartsWith("hr.")))
.ToListAsync();
var duplicateInput = inputs
.GroupBy(item => new { item.ContractTemplateVersionId, item.VariableCode })
.FirstOrDefault(group => group.Count() > 1);
if (duplicateInput != null)
throw Oops.Bah($"合同变量“{duplicateInput.Key.VariableCode}”重复提交");
foreach (var input in inputs)
{
var definitionExists = definitions.Any(item =>
item.TemplateVersionId == input.ContractTemplateVersionId
&& item.VariableCode == input.VariableCode);
if (!definitionExists)
throw Oops.Bah($"合同变量“{input.VariableCode}”不存在或不允许人事填写");
}
var missingRequired = definitions.FirstOrDefault(definition =>
{
if (!definition.Required)
return false;
var input = inputs.FirstOrDefault(item =>
item.ContractTemplateVersionId == definition.TemplateVersionId
&& item.VariableCode == definition.VariableCode);
return string.IsNullOrWhiteSpace(input?.VariableValue ?? definition.DefaultValue);
});
if (missingRequired != null)
throw Oops.Bah($"请填写合同变量“{missingRequired.VariableName}”");
if (inputs.Count == 0)
return;
var contractByVersion = contracts.ToDictionary(item => item.TemplateVersionId);
var contractIds = contracts.Select(item => item.Id).ToList();
var rows = await _db.Queryable<ObTaskContractVariable>()
.Where(item => contractIds.Contains(item.TaskContractId))
.ToListAsync();
foreach (var input in inputs)
{
var contract = contractByVersion[input.ContractTemplateVersionId];
var row = rows.FirstOrDefault(item =>
item.TaskContractId == contract.Id && item.VariableCode == input.VariableCode)
?? throw Oops.Bah($"合同变量快照“{input.VariableCode}”不存在");
row.VariableValue = input.VariableValue;
row.SourceType = ContractVariableSourceType.HrInput;
row.IsManualOverride = true;
}
var changedRows = rows.Where(item => item.IsManualOverride).ToList();
if (changedRows.Count > 0)
await _db.Updateable(changedRows).ExecuteCommandAsync();
}
/// <summary>
/// 校验人事创建任务时填写的合同日期先后关系。
/// </summary>
private static void ValidateInitialContractDateRanges(
List<OnboardingInitialContractVariableInput> inputs)
{
inputs ??= new List<OnboardingInitialContractVariableInput>();
foreach (var group in inputs.GroupBy(item => item.ContractTemplateVersionId))
{
ValidateDateRange(group, "hr.ContractStartDate", "hr.ContractEndDate", "合同");
ValidateDateRange(group, "hr.ProbationStartDate", "hr.ProbationEndDate", "试用期");
}
}
/// <summary>
/// 校验同一合同版本内一组开始、结束日期。
/// </summary>
private static void ValidateDateRange(
IEnumerable<OnboardingInitialContractVariableInput> inputs,
string startCode,
string endCode,
string rangeName)
{
var startValue = inputs.FirstOrDefault(item =>
string.Equals(item.VariableCode, startCode, StringComparison.OrdinalIgnoreCase))?.VariableValue;
var endValue = inputs.FirstOrDefault(item =>
string.Equals(item.VariableCode, endCode, StringComparison.OrdinalIgnoreCase))?.VariableValue;
if (string.IsNullOrWhiteSpace(startValue) || string.IsNullOrWhiteSpace(endValue))
return;
if (!DateTime.TryParse(startValue, out var startDate))
throw Oops.Bah($"{rangeName}开始日期格式不正确");
if (!DateTime.TryParse(endValue, out var endDate))
throw Oops.Bah($"{rangeName}结束日期格式不正确");
if (endDate.Date < startDate.Date)
throw Oops.Bah($"{rangeName}结束日期不能早于开始日期");
}
} }
@@ -141,6 +141,15 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
{ {
await _db.Updateable(rows) await _db.Updateable(rows)
.ExecuteCommandAsync(); .ExecuteCommandAsync();
// 人事参数变化后,原合同文件和员工确认必须失效,避免员工确认旧内容。
contract.GeneratedDocxFileId = null;
contract.GeneratedPdfFileId = null;
contract.GeneratedAt = null;
contract.ReadAt = null;
contract.ConfirmedAt = null;
contract.ContractStatus = TaskContractStatus.PendingGenerate;
await _db.Updateable(contract).ExecuteCommandAsync();
} }
} }
} }
@@ -44,12 +44,7 @@ public class OnboardingTaskDetailOutput
/// <summary> /// <summary>
/// 候选人已上传材料列表。 /// 候选人已上传材料列表。
/// </summary> /// </summary>
public List<ObTaskMaterial> Materials { get; set; } public List<OnboardingTaskMaterialDetailOutput> Materials { get; set; }
/// <summary>
/// 候选人动态字段值列表。
/// </summary>
public List<ObTaskFormValue> FormValues { get; set; }
/// <summary> /// <summary>
/// 任务当前有效链接信息。 /// 任务当前有效链接信息。
@@ -61,3 +56,19 @@ public class OnboardingTaskDetailOutput
/// </summary> /// </summary>
public List<ObTaskAudit> Audits { get; set; } public List<ObTaskAudit> Audits { get; set; }
} }
/// <summary>
/// 后台入职任务材料详情。
/// </summary>
public class OnboardingTaskMaterialDetailOutput : ObTaskMaterial
{
/// <summary>
/// 材料原始文件名,用于下载时保留文件名称。
/// </summary>
public string FileName { get; set; }
/// <summary>
/// 材料文件后缀,用于判断是否支持图片预览。
/// </summary>
public string FileSuffix { get; set; }
}
@@ -33,28 +33,50 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(input.Id) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(input.Id)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == task.Id);
var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
var materials = await _db.Queryable<ObTaskMaterial>()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
var materialFileIds = materials
.Select(item => item.FileId)
.Distinct()
.ToList();
var materialFiles = await _db.Queryable<SysFile>()
.Where(item => materialFileIds.Contains(item.Id))
.ToListAsync();
var materialFileMap = materialFiles.ToDictionary(item => item.Id);
var materialOutputs = materials.Select(material =>
{
var output = material.Adapt<OnboardingTaskMaterialDetailOutput>();
if (materialFileMap.TryGetValue(material.FileId, out var file))
{
output.FileName = file.Name;
output.FileSuffix = file.Suffix;
}
return output;
}).ToList();
var link = await _db.Queryable<ObTaskLink>()
.Where(item => item.TaskId == task.Id && item.LinkStatus == "active")
.OrderByDescending(item => item.CreateTime)
.FirstAsync();
var audits = await _db.Queryable<ObTaskAudit>()
.Where(item => item.TaskId == task.Id)
.OrderByDescending(item => item.AuditedAt)
.ToListAsync();
return new OnboardingTaskDetailOutput return new OnboardingTaskDetailOutput
{ {
Task = task, Task = task,
EmployeeInfo = await _db.Queryable<ObTaskEmployeeInfo>() EmployeeInfo = employeeInfo,
.FirstAsync(item => item.TaskId == task.Id), Contracts = contracts,
Contracts = await _db.Queryable<ObTaskContract>() Materials = materialOutputs,
.Where(item => item.TaskId == task.Id) Link = link,
.ToListAsync(), Audits = audits
Materials = await _db.Queryable<ObTaskMaterial>()
.Where(item => item.TaskId == task.Id)
.ToListAsync(),
FormValues = await _db.Queryable<ObTaskFormValue>()
.Where(item => item.TaskId == task.Id)
.ToListAsync(),
Link = await _db.Queryable<ObTaskLink>()
.Where(item => item.TaskId == task.Id && item.LinkStatus == "active")
.OrderByDescending(item => item.CreateTime)
.FirstAsync(),
Audits = await _db.Queryable<ObTaskAudit>()
.Where(item => item.TaskId == task.Id)
.OrderByDescending(item => item.AuditedAt)
.ToListAsync()
}; };
} }
} }
@@ -0,0 +1,10 @@
namespace SimpleAdmin.Application;
public interface IOnboardingVariableDefinitionService : ITransient
{
Task<SqlSugarPagedList<ObVariableDefinition>> Page(OnboardingVariableDefinitionPageInput input);
Task<List<ObVariableDefinition>> ListAll();
Task Add(OnboardingVariableDefinitionAddInput input);
Task Edit(OnboardingVariableDefinitionEditInput input);
Task Delete(BaseIdListInput input);
}
@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace SimpleAdmin.Application;
public class OnboardingVariableDefinitionPageInput : BasePageInput
{
public string Keyword { get; set; }
public string VariableCategory { get; set; }
}
public class OnboardingVariableDefinitionAddInput
{
[Required(ErrorMessage = "变量编码不能为空")]
public string VariableCode { get; set; }
[Required(ErrorMessage = "变量名称不能为空")]
public string VariableName { get; set; }
[Required(ErrorMessage = "变量分类不能为空")]
public string VariableCategory { get; set; }
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; }
}
@@ -0,0 +1,78 @@
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<SqlSugarPagedList<ObVariableDefinition>> Page(OnboardingVariableDefinitionPageInput input)
{
return await _db.Queryable<ObVariableDefinition>()
.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)
.OrderBy(it => it.SortCode)
.OrderBy(it => it.Id)
.ToPagedListAsync(input.PageNum, input.PageSize);
}
public async Task<List<ObVariableDefinition>> ListAll()
{
return await _db.Queryable<ObVariableDefinition>()
.OrderBy(it => it.SortCode)
.OrderBy(it => it.Id)
.ToListAsync();
}
public async Task Add(OnboardingVariableDefinitionAddInput input)
{
var code = input.VariableCode.Trim();
var exists = await _db.Queryable<ObVariableDefinition>().AnyAsync(it => it.VariableCode == code);
if (exists)
throw Oops.Bah($"变量编码“{code}”已存在");
var entity = input.Adapt<ObVariableDefinition>();
entity.VariableCode = code;
entity.IsBuiltIn = false;
await _db.Insertable(entity).ExecuteCommandAsync();
}
public async Task Edit(OnboardingVariableDefinitionEditInput input)
{
var entity = await _db.Queryable<ObVariableDefinition>().InSingleAsync(input.Id)
?? throw Oops.Bah("变量不存在");
entity.VariableName = input.VariableName;
entity.DataType = input.DataType ?? "string";
entity.DefaultValue = input.DefaultValue;
entity.Required = input.Required;
entity.SortCode = input.SortCode;
entity.Remark = input.Remark;
if (!entity.IsBuiltIn)
{
entity.VariableCategory = input.VariableCategory;
}
await _db.Updateable(entity).ExecuteCommandAsync();
}
public async Task Delete(BaseIdListInput input)
{
if (input.Ids.Count == 0) return;
var hasBuiltIn = await _db.Queryable<ObVariableDefinition>()
.Where(it => input.Ids.Contains(it.Id) && it.IsBuiltIn)
.AnyAsync();
if (hasBuiltIn)
throw Oops.Bah("系统内置的标准入职登记表变量不能删除");
await _db.Deleteable<ObVariableDefinition>()
.Where(it => input.Ids.Contains(it.Id))
.ExecuteCommandAsync();
}
}
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛 // Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
// //
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款: // SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。 // 1.请不要删除和修改根目录下的LICENSE文件。
@@ -68,6 +68,11 @@ public class SysDictConst
/// </summary> /// </summary>
public const string FILE_ENGINE_MINIO = "MINIO"; public const string FILE_ENGINE_MINIO = "MINIO";
/// <summary>
/// 阿里云 OSS,当前仅供入职业务文件使用。
/// </summary>
public const string FILE_ENGINE_ALIYUN_OSS = "ALIYUN_OSS";
#endregion 上传文件引擎 #endregion 上传文件引擎
#region 系统字典分类 #region 系统字典分类
@@ -0,0 +1,62 @@
namespace SimpleAdmin.System;
/// <summary>
/// 阿里云 OSS 及文档预览服务配置。
/// </summary>
public class OssSettingOptions
{
/// <summary>
/// OSS 访问密钥 Id。
/// </summary>
public string AccessKeyID { get; set; }
/// <summary>
/// OSS 访问密钥。
/// </summary>
public string AccessKeySecret { get; set; }
/// <summary>
/// 用于换取临时凭证的 RAM 角色 ARN。
/// </summary>
public string RoleArn { get; set; }
/// <summary>
/// STS 角色会话名称。
/// </summary>
public string RoleSessionName { get; set; }
/// <summary>
/// STS 服务端点。
/// </summary>
public string StsEndpoint { get; set; }
/// <summary>
/// OSS Bucket 名称。
/// </summary>
public string Bucket { get; set; }
/// <summary>
/// OSS 服务端点。
/// </summary>
public string EndPoint { get; set; }
/// <summary>
/// IMM 服务端点。
/// </summary>
public string IMMEndpoint { get; set; }
/// <summary>
/// IMM 项目名称。
/// </summary>
public string IMMProject { get; set; }
/// <summary>
/// OSS 自定义访问域名。
/// </summary>
public string OSSUrl { get; set; }
/// <summary>
/// 阿里云地域 Id。
/// </summary>
public string RegionId { get; set; }
}
@@ -8,6 +8,8 @@
// 5.请不得将本软件应用于危害国家安全、荣誉和利益的行为,不能以任何形式用于非法为目的的行为。 // 5.请不得将本软件应用于危害国家安全、荣誉和利益的行为,不能以任何形式用于非法为目的的行为。
// 6.任何基于本软件而产生的一切法律纠纷和责任,均于我司无关。 // 6.任何基于本软件而产生的一切法律纠纷和责任,均于我司无关。
using Aliyun.OSS;
namespace SimpleAdmin.System; namespace SimpleAdmin.System;
/// <summary> /// <summary>
@@ -95,6 +97,8 @@ public class FileService : DbRepository<SysFile>, IFileService
return GetFileStreamResult(devFile.StoragePath, devFile.Name); return GetFileStreamResult(devFile.StoragePath, devFile.Name);
if (devFile.Engine == SysDictConst.FILE_ENGINE_MINIO) if (devFile.Engine == SysDictConst.FILE_ENGINE_MINIO)
return await GetFileStreamResultFromMinio(devFile.ObjName, devFile.Name); return await GetFileStreamResultFromMinio(devFile.ObjName, devFile.Name);
if (devFile.Engine == SysDictConst.FILE_ENGINE_ALIYUN_OSS)
return await GetFileStreamResultFromAliyunOss(devFile);
return null; return null;
} }
return null; return null;
@@ -142,6 +146,9 @@ public class FileService : DbRepository<SysFile>, IFileService
} }
break; break;
case SysDictConst.FILE_ENGINE_ALIYUN_OSS:
(bucketName, objName, storageUrl) = await StorageAliyunOss(objectId, file);
break;
default: default:
throw Oops.Bah("不支持的文件引擎"); throw Oops.Bah("不支持的文件引擎");
@@ -154,13 +161,13 @@ public class FileService : DbRepository<SysFile>, IFileService
Engine = engine, Engine = engine,
Bucket = bucketName, Bucket = bucketName,
Name = file.FileName, Name = file.FileName,
Suffix = fileSuffix.Split(".")[1], Suffix = fileSuffix.TrimStart('.'),
ObjName = objName, ObjName = objName,
SizeKb = fileSizeKb, SizeKb = fileSizeKb,
SizeInfo = GetSizeInfo(fileSizeKb), SizeInfo = GetSizeInfo(fileSizeKb),
StoragePath = storageUrl StoragePath = storageUrl
}; };
if (engine != CateGoryConst.CONFIG_FILE_LOCAL)//如果不是本地,设置下载地址 if (engine != SysDictConst.FILE_ENGINE_LOCAL)//如果不是本地,设置下载地址
{ {
devFile.DownloadPath = storageUrl; devFile.DownloadPath = storageUrl;
} }
@@ -236,6 +243,111 @@ public class FileService : DbRepository<SysFile>, IFileService
return (fileObjectName, downloadUrl); return (fileObjectName, downloadUrl);
} }
/// <summary>
/// 将入职业务文件存储到阿里云 OSS。
/// </summary>
private async Task<(string bucketName, string objName, string downloadUrl)> StorageAliyunOss(
long fileId,
IFormFile file)
{
var setting = GetOssSetting();
var suffix = Path.GetExtension(file.FileName).ToLowerInvariant();
var objectName = $"onboarding/{DateTime.Now:yyyy/MM/dd}/{fileId}{suffix}";
var metadata = new ObjectMetadata();
if (!string.IsNullOrWhiteSpace(file.ContentType))
metadata.ContentType = file.ContentType;
await using var stream = file.OpenReadStream();
var client = CreateOssClient(setting);
client.PutObject(setting.Bucket, objectName, stream, metadata);
var downloadUrl = string.IsNullOrWhiteSpace(setting.OSSUrl)
? $"https://{setting.Bucket}.{setting.EndPoint.Trim().TrimEnd('/')}/{objectName}"
: $"{setting.OSSUrl.Trim().TrimEnd('/')}/{objectName}";
return (setting.Bucket, objectName, downloadUrl);
}
/// <summary>
/// 从阿里云 OSS 读取业务文件并通过统一下载接口返回。
/// </summary>
private async Task<FileStreamResult> GetFileStreamResultFromAliyunOss(SysFile file)
{
var setting = GetOssSetting();
var client = CreateOssClient(setting);
var ossObject = client.GetObject(file.Bucket, file.ObjName);
var memory = new MemoryStream();
await using (ossObject.Content)
{
await ossObject.Content.CopyToAsync(memory);
}
memory.Position = 0;
var fileName = HttpUtility.UrlEncode(file.Name, Encoding.GetEncoding("UTF-8"));
return new FileStreamResult(memory, "application/octet-stream")
{
FileDownloadName = fileName
};
}
/// <summary>
/// 读取并校验阿里云 OSS 配置。
/// </summary>
private static OssSettingOptions GetOssSetting()
{
var setting = App.GetConfig<OssSettingOptions>("OssSetting", true);
var configurationIncomplete = setting == null
|| string.IsNullOrWhiteSpace(setting.AccessKeyID)
|| string.IsNullOrWhiteSpace(setting.AccessKeySecret)
|| string.IsNullOrWhiteSpace(setting.Bucket)
|| string.IsNullOrWhiteSpace(setting.EndPoint);
if (configurationIncomplete)
throw Oops.Bah("阿里云 OSS 配置不完整");
return setting;
}
/// <summary>
/// 通过 STS 临时凭证创建阿里云 OSS 客户端。
/// </summary>
private static OssClient CreateOssClient(OssSettingOptions setting)
{
var endpoint = setting.EndPoint.Trim();
if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
&& !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
endpoint = "https://" + endpoint;
}
if (string.IsNullOrWhiteSpace(setting.RoleArn))
return new OssClient(endpoint, setting.AccessKeyID, setting.AccessKeySecret);
var stsConfig = new AlibabaCloud.OpenApiClient.Models.Config
{
AccessKeyId = setting.AccessKeyID,
AccessKeySecret = setting.AccessKeySecret,
Endpoint = setting.StsEndpoint
};
var stsClient = new AlibabaCloud.SDK.Sts20150401.Client(stsConfig);
var request = new AlibabaCloud.SDK.Sts20150401.Models.AssumeRoleRequest
{
RoleArn = setting.RoleArn,
RoleSessionName = string.IsNullOrWhiteSpace(setting.RoleSessionName)
? "onboardx-server"
: setting.RoleSessionName,
DurationSeconds = 3600
};
var credentials = stsClient.AssumeRole(request).Body.Credentials;
if (credentials == null)
throw Oops.Bah("获取阿里云 OSS 临时凭证失败");
return new OssClient(
endpoint,
credentials.AccessKeyId,
credentials.AccessKeySecret,
credentials.SecurityToken);
}
/// <summary> /// <summary>
/// 获取文件大小 /// 获取文件大小
/// </summary> /// </summary>
+3 -1
View File
@@ -1,7 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<PackageReference Include="AlibabaCloud.SDK.Sts20150401" Version="1.2.0"/>
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.0"/>
<PackageReference Include="IP2Region.Net" Version="2.0.2"/> <PackageReference Include="IP2Region.Net" Version="2.0.2"/>
<PackageReference Include="UAParser" Version="3.1.47"/> <PackageReference Include="UAParser" Version="3.1.47"/>
<PackageReference Include="Magicodes.IE.Excel" Version="2.7.5.1"/> <PackageReference Include="Magicodes.IE.Excel" Version="2.7.5.1"/>
+85
View File
@@ -506,6 +506,11 @@
MINIO MINIO
</summary> </summary>
</member> </member>
<member name="F:SimpleAdmin.System.SysDictConst.FILE_ENGINE_ALIYUN_OSS">
<summary>
阿里云 OSS,当前仅供入职业务文件使用。
</summary>
</member>
<member name="F:SimpleAdmin.System.SysDictConst.DICT_CATEGORY_FRM"> <member name="F:SimpleAdmin.System.SysDictConst.DICT_CATEGORY_FRM">
<summary> <summary>
系统 系统
@@ -2670,6 +2675,66 @@
对象映射 对象映射
</summary> </summary>
</member> </member>
<member name="T:SimpleAdmin.System.OssSettingOptions">
<summary>
阿里云 OSS 及文档预览服务配置。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.AccessKeyID">
<summary>
OSS 访问密钥 Id。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.AccessKeySecret">
<summary>
OSS 访问密钥。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.RoleArn">
<summary>
用于换取临时凭证的 RAM 角色 ARN。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.RoleSessionName">
<summary>
STS 角色会话名称。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.StsEndpoint">
<summary>
STS 服务端点。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.Bucket">
<summary>
OSS Bucket 名称。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.EndPoint">
<summary>
OSS 服务端点。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.IMMEndpoint">
<summary>
IMM 服务端点。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.IMMProject">
<summary>
IMM 项目名称。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.OSSUrl">
<summary>
OSS 自定义访问域名。
</summary>
</member>
<member name="P:SimpleAdmin.System.OssSettingOptions.RegionId">
<summary>
阿里云地域 Id。
</summary>
</member>
<member name="T:SimpleAdmin.System.SystemSettingsOptions"> <member name="T:SimpleAdmin.System.SystemSettingsOptions">
<summary> <summary>
系统配置 系统配置
@@ -3588,6 +3653,26 @@
<param name="file"></param> <param name="file"></param>
<returns></returns> <returns></returns>
</member> </member>
<member name="M:SimpleAdmin.System.FileService.StorageAliyunOss(System.Int64,Microsoft.AspNetCore.Http.IFormFile)">
<summary>
将入职业务文件存储到阿里云 OSS。
</summary>
</member>
<member name="M:SimpleAdmin.System.FileService.GetFileStreamResultFromAliyunOss(SimpleAdmin.System.SysFile)">
<summary>
从阿里云 OSS 读取业务文件并通过统一下载接口返回。
</summary>
</member>
<member name="M:SimpleAdmin.System.FileService.GetOssSetting">
<summary>
读取并校验阿里云 OSS 配置。
</summary>
</member>
<member name="M:SimpleAdmin.System.FileService.CreateOssClient(SimpleAdmin.System.OssSettingOptions)">
<summary>
通过 STS 临时凭证创建阿里云 OSS 客户端。
</summary>
</member>
<member name="M:SimpleAdmin.System.FileService.GetSizeInfo(System.Int64)"> <member name="M:SimpleAdmin.System.FileService.GetSizeInfo(System.Int64)">
<summary> <summary>
获取文件大小 获取文件大小
+14 -1
View File
@@ -24,5 +24,18 @@
"InitSeedData": false, //是否初始化种子数据 "InitSeedData": false, //是否初始化种子数据
"SuperAdminViewAllData": true, //是否超级管理员可以查看所有数据 "SuperAdminViewAllData": true, //是否超级管理员可以查看所有数据
"UseMessageCenter": false //是否启用通知中心 "UseMessageCenter": false //是否启用通知中心
} },
"OssSetting": {
"AccessKeyID": "LTAI5t9VQvLERXtMHC9BK31f",
"AccessKeySecret": "YomhludvaOmLZo1K0vUDoeBhWRjxWL",
"RoleArn": "acs:ram::1436008104961462:role/onboardx-sts",
"RoleSessionName": "onboardx-server",
"StsEndpoint": "sts.cn-beijing.aliyuncs.com",
"Bucket": "onboardx",
"EndPoint": "oss-cn-beijing.aliyuncs.com",
"IMMEndpoint": "",
"IMMProject": "",
"OSSUrl": "https://onboardx.oss-cn-beijing.aliyuncs.com",
"RegionId": "oss-cn-beijing"
}
} }
+14 -1
View File
@@ -24,5 +24,18 @@
"InitSeedData": false, //是否初始化种子数据 "InitSeedData": false, //是否初始化种子数据
"SuperAdminViewAllData": true, //是否超级管理员可以查看所有数据 "SuperAdminViewAllData": true, //是否超级管理员可以查看所有数据
"UseMessageCenter": false //是否启用通知中心 "UseMessageCenter": false //是否启用通知中心
} },
"OssSetting": {
"AccessKeyID": "LTAI5t9VQvLERXtMHC9BK31f",
"AccessKeySecret": "YomhludvaOmLZo1K0vUDoeBhWRjxWL",
"RoleArn": "acs:ram::1436008104961462:role/onboardx-sts",
"RoleSessionName": "onboardx-server",
"StsEndpoint": "sts.cn-beijing.aliyuncs.com",
"Bucket": "onboardx",
"EndPoint": "oss-cn-beijing.aliyuncs.com",
"IMMEndpoint": "",
"IMMProject": "",
"OSSUrl": "https://onboardx.oss-cn-beijing.aliyuncs.com",
"RegionId": "oss-cn-beijing"
}
} }
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Web.Core; namespace SimpleAdmin.Web.Core;
/// <summary> /// <summary>
/// 提供合同模板、版本、变量和适用范围相关 HTTP 接口。 /// 提供合同模板、DOCX 版本和自动识别变量配置接口。
/// </summary> /// </summary>
[ApiDescriptionSettings("Application", Tag = "入职合同模板")] [ApiDescriptionSettings("Application", Tag = "入职合同模板")]
[Route("biz/onboarding/contract-template")] [Route("biz/onboarding/contract-template")]
@@ -9,10 +9,32 @@ namespace SimpleAdmin.Web.Core;
public class OnboardingContractTemplateController public class OnboardingContractTemplateController
{ {
private readonly IOnboardingContractTemplateService _service; private readonly IOnboardingContractTemplateService _service;
private readonly IFileService _fileService;
public OnboardingContractTemplateController(IOnboardingContractTemplateService service) public OnboardingContractTemplateController(
IOnboardingContractTemplateService service,
IFileService fileService)
{ {
_service = service; _service = service;
_fileService = fileService;
}
/// <summary>
/// 将入职合同 DOCX 模板上传到阿里云 OSS。
/// </summary>
[HttpPost("file/upload-docx")]
[Consumes("multipart/form-data")]
[RequestSizeLimit(20 * 1024 * 1024)]
public async Task<long> UploadDocx([FromForm] IFormFile file)
{
if (file == null || file.Length == 0)
throw Oops.Bah("请选择需要上传的 DOCX 文件");
var suffix = Path.GetExtension(file.FileName);
if (!string.Equals(suffix, ".docx", StringComparison.OrdinalIgnoreCase))
throw Oops.Bah("合同模板仅支持 DOCX 文件");
return await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
} }
/// <summary> /// <summary>
@@ -87,6 +109,15 @@ public class OnboardingContractTemplateController
await _service.SetVersionStatus(input); await _service.SetVersionStatus(input);
} }
/// <summary>
/// 删除未被入职模板或任务使用的合同模板版本。
/// </summary>
[HttpPost("version/delete")]
public async Task DeleteVersion([FromBody] BaseIdInput input)
{
await _service.DeleteVersion(input);
}
/// <summary> /// <summary>
/// 分页查询模板变量。 /// 分页查询模板变量。
/// </summary> /// </summary>
@@ -96,15 +127,6 @@ public class OnboardingContractTemplateController
return await _service.VariablePage(input); return await _service.VariablePage(input);
} }
/// <summary>
/// 新增模板变量。
/// </summary>
[HttpPost("variable/add")]
public async Task AddVariable([FromBody] OnboardingTemplateVariableAddInput input)
{
await _service.AddVariable(input);
}
/// <summary> /// <summary>
/// 编辑模板变量。 /// 编辑模板变量。
/// </summary> /// </summary>
@@ -113,49 +135,4 @@ public class OnboardingContractTemplateController
{ {
await _service.EditVariable(input); await _service.EditVariable(input);
} }
}
/// <summary>
/// 删除模板变量。
/// </summary>
[HttpPost("variable/delete")]
public async Task DeleteVariable([FromBody] BaseIdListInput input)
{
await _service.DeleteVariable(input);
}
/// <summary>
/// 分页查询合同适用范围。
/// </summary>
[HttpGet("scope/page")]
public async Task<dynamic> ScopePage([FromQuery] OnboardingContractScopePageInput input)
{
return await _service.ScopePage(input);
}
/// <summary>
/// 新增合同适用范围。
/// </summary>
[HttpPost("scope/add")]
public async Task AddScope([FromBody] OnboardingContractScopeAddInput input)
{
await _service.AddScope(input);
}
/// <summary>
/// 编辑合同适用范围。
/// </summary>
[HttpPost("scope/edit")]
public async Task EditScope([FromBody] OnboardingContractScopeEditInput input)
{
await _service.EditScope(input);
}
/// <summary>
/// 删除合同适用范围。
/// </summary>
[HttpPost("scope/delete")]
public async Task DeleteScope([FromBody] BaseIdListInput input)
{
await _service.DeleteScope(input);
}
}
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Web.Core; namespace SimpleAdmin.Web.Core;
/// <summary> /// <summary>
/// 提供入职字段与材料要求配置相关 HTTP 接口。 /// 提供员工动态补充字段与上传材料类型配置接口。
/// </summary> /// </summary>
[ApiDescriptionSettings("Application", Tag = "入职填写与材料配置")] [ApiDescriptionSettings("Application", Tag = "入职填写与材料配置")]
[Route("biz/onboarding/entry-config")] [Route("biz/onboarding/entry-config")]
@@ -68,58 +68,4 @@ public class OnboardingEntryConfigController
{ {
await _service.DeleteMaterialTypes(input); await _service.DeleteMaterialTypes(input);
} }
}
/// <summary>
/// 查询公司岗位动态字段要求。
/// </summary>
[HttpGet("form-requirement/list")]
public async Task<dynamic> ListFormRequirements([FromQuery] long companyId)
{
return await _service.ListFormRequirements(companyId);
}
/// <summary>
/// 保存公司岗位动态字段要求。
/// </summary>
[HttpPost("form-requirement/save")]
public async Task SaveFormRequirement([FromBody] OnboardingFormRequirementSaveInput input)
{
await _service.SaveFormRequirement(input);
}
/// <summary>
/// 删除动态字段要求。
/// </summary>
[HttpPost("form-requirement/delete")]
public async Task DeleteFormRequirements([FromBody] BaseIdListInput input)
{
await _service.DeleteFormRequirements(input);
}
/// <summary>
/// 查询公司岗位材料要求。
/// </summary>
[HttpGet("material-requirement/list")]
public async Task<dynamic> ListMaterialRequirements([FromQuery] long companyId)
{
return await _service.ListMaterialRequirements(companyId);
}
/// <summary>
/// 保存公司岗位材料要求。
/// </summary>
[HttpPost("material-requirement/save")]
public async Task SaveMaterialRequirement([FromBody] OnboardingMaterialRequirementSaveInput input)
{
await _service.SaveMaterialRequirement(input);
}
/// <summary>
/// 删除材料要求。
/// </summary>
[HttpPost("material-requirement/delete")]
public async Task DeleteMaterialRequirements([FromBody] BaseIdListInput input)
{
await _service.DeleteMaterialRequirements(input);
}
}
@@ -35,6 +35,16 @@ public class OnboardingPublicSubmissionController
await _service.SaveMaterial(input); await _service.SaveMaterial(input);
} }
/// <summary>
/// 删除当前入职任务中的一条已上传材料。
/// </summary>
[HttpPost("delete-material")]
public async Task DeleteMaterial(
[FromBody] OnboardingDeleteMaterialInput input)
{
await _service.DeleteMaterial(input);
}
/// <summary> /// <summary>
/// 确认已阅读指定合同。 /// 确认已阅读指定合同。
/// </summary> /// </summary>
@@ -59,11 +69,14 @@ public class OnboardingPublicSubmissionController
[HttpPost("upload-material")] [HttpPost("upload-material")]
[Consumes("multipart/form-data")] [Consumes("multipart/form-data")]
[RequestSizeLimit(20 * 1024 * 1024)] [RequestSizeLimit(20 * 1024 * 1024)]
public async Task UploadMaterial([FromForm] string token, [FromForm] long materialTypeId, IFormFile file) public async Task UploadMaterial(
[FromQuery] string token,
[FromQuery] long materialTypeId,
[FromForm] IFormFile file)
{ {
if (file == null || file.Length == 0) if (file == null || file.Length == 0)
{ {
throw new ArgumentException("请选择需要上传的文件"); throw Oops.Bah("请选择需要上传的文件");
} }
await _service.ValidateMaterialUpload(new OnboardingMaterialUploadValidationInput await _service.ValidateMaterialUpload(new OnboardingMaterialUploadValidationInput
@@ -74,7 +87,7 @@ public class OnboardingPublicSubmissionController
FileLength = file.Length FileLength = file.Length
}); });
var fileId = await _fileService.UploadFile(SysDictConst.FILE_ENGINE_LOCAL, file); var fileId = await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
await _service.SaveMaterial(new OnboardingSaveMaterialInput await _service.SaveMaterial(new OnboardingSaveMaterialInput
{ {
Token = token, Token = token,
@@ -36,7 +36,7 @@ public class OnboardingPublicTaskController
} }
/// <summary> /// <summary>
/// 下载当前任务锁定的合同模板源文件。 /// 下载根据当前任务资料动态生成并已替换变量的合同文件。
/// </summary> /// </summary>
[HttpGet("contract-source")] [HttpGet("contract-source")]
public async Task<FileStreamResult> ContractSource([FromQuery] OnboardingPublicContractSourceInput input) public async Task<FileStreamResult> ContractSource([FromQuery] OnboardingPublicContractSourceInput input)
@@ -32,4 +32,13 @@ public class OnboardingTaskLifecycleController
{ {
await _service.Revoke(input); await _service.Revoke(input);
} }
/// <summary>
/// 删除已撤销的入职任务。
/// </summary>
[HttpPost("delete")]
public async Task Delete([FromBody] BaseIdInput input)
{
await _service.Delete(input);
}
} }
@@ -0,0 +1,62 @@
namespace SimpleAdmin.Web.Core;
/// <summary>
/// 提供入职模板聚合配置接口。
/// </summary>
[ApiDescriptionSettings("Application", Tag = "入职模板")]
[Route("biz/onboarding/template")]
[RolePermission]
public class OnboardingTemplateController
{
private readonly IOnboardingTemplateService _service;
public OnboardingTemplateController(IOnboardingTemplateService service)
{
_service = service;
}
/// <summary>
/// 分页查询入职模板。
/// </summary>
[HttpGet("page")]
public async Task<dynamic> Page([FromQuery] OnboardingTemplatePageInput input)
{
return await _service.Page(input);
}
/// <summary>
/// 查询入职模板详情及全部关联配置。
/// </summary>
[HttpGet("detail")]
public async Task<dynamic> Detail([FromQuery] BaseIdInput input)
{
return await _service.Detail(input);
}
/// <summary>
/// 新增或编辑入职模板。
/// </summary>
[HttpPost("save")]
public async Task<long> Save([FromBody] OnboardingTemplateSaveInput input)
{
return await _service.Save(input);
}
/// <summary>
/// 删除未被任务使用的入职模板。
/// </summary>
[HttpPost("delete")]
public async Task Delete([FromBody] BaseIdListInput input)
{
await _service.Delete(input);
}
/// <summary>
/// 查询指定公司岗位可使用的入职模板。
/// </summary>
[HttpGet("list-enabled")]
public async Task<dynamic> ListEnabled([FromQuery] long companyId, [FromQuery] long positionId)
{
return await _service.ListEnabled(companyId, positionId);
}
}
@@ -0,0 +1,47 @@
namespace SimpleAdmin.Web.Core;
/// <summary>
/// 合同变量字典定义管理。
/// </summary>
[ApiDescriptionSettings("Application", Tag = "合同变量字典")]
[Route("biz/onboarding/variable-definition")]
[RolePermission]
public class OnboardingVariableDefinitionController
{
private readonly IOnboardingVariableDefinitionService _service;
public OnboardingVariableDefinitionController(IOnboardingVariableDefinitionService service)
{
_service = service;
}
[HttpGet("page")]
public async Task<dynamic> Page([FromQuery] OnboardingVariableDefinitionPageInput input)
{
return await _service.Page(input);
}
[HttpGet("list")]
public async Task<dynamic> List()
{
return await _service.ListAll();
}
[HttpPost("add")]
public async Task Add([FromBody] OnboardingVariableDefinitionAddInput input)
{
await _service.Add(input);
}
[HttpPost("edit")]
public async Task Edit([FromBody] OnboardingVariableDefinitionEditInput input)
{
await _service.Edit(input);
}
[HttpPost("delete")]
public async Task Delete([FromBody] BaseIdListInput input)
{
await _service.Delete(input);
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛 // Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
// //
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款: // SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。 // 1.请不要删除和修改根目录下的LICENSE文件。
@@ -10,6 +10,7 @@
global using MoYu; global using MoYu;
global using MoYu.DynamicApiController; global using MoYu.DynamicApiController;
global using MoYu.FriendlyException;
global using MoYu.Logging; global using MoYu.Logging;
global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Builder; global using Microsoft.AspNetCore.Builder;
+58 -63
View File
@@ -88,7 +88,12 @@
</member> </member>
<member name="T:SimpleAdmin.Web.Core.OnboardingContractTemplateController"> <member name="T:SimpleAdmin.Web.Core.OnboardingContractTemplateController">
<summary> <summary>
提供合同模板、版本、变量和适用范围相关 HTTP 接口。 提供合同模板、DOCX 版本和自动识别变量配置接口。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.UploadDocx(Microsoft.AspNetCore.Http.IFormFile)">
<summary>
将入职合同 DOCX 模板上传到阿里云 OSS。
</summary> </summary>
</member> </member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.Page(SimpleAdmin.Application.OnboardingContractTemplatePageInput)"> <member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.Page(SimpleAdmin.Application.OnboardingContractTemplatePageInput)">
@@ -131,46 +136,21 @@
设置合同模板版本状态。 设置合同模板版本状态。
</summary> </summary>
</member> </member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.DeleteVersion(SimpleAdmin.Core.BaseIdInput)">
<summary>
删除未被入职模板或任务使用的合同模板版本。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.VariablePage(SimpleAdmin.Application.OnboardingTemplateVariablePageInput)"> <member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.VariablePage(SimpleAdmin.Application.OnboardingTemplateVariablePageInput)">
<summary> <summary>
分页查询模板变量。 分页查询模板变量。
</summary> </summary>
</member> </member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.AddVariable(SimpleAdmin.Application.OnboardingTemplateVariableAddInput)">
<summary>
新增模板变量。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.EditVariable(SimpleAdmin.Application.OnboardingTemplateVariableEditInput)"> <member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.EditVariable(SimpleAdmin.Application.OnboardingTemplateVariableEditInput)">
<summary> <summary>
编辑模板变量。 编辑模板变量。
</summary> </summary>
</member> </member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.DeleteVariable(SimpleAdmin.Core.BaseIdListInput)">
<summary>
删除模板变量。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.ScopePage(SimpleAdmin.Application.OnboardingContractScopePageInput)">
<summary>
分页查询合同适用范围。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.AddScope(SimpleAdmin.Application.OnboardingContractScopeAddInput)">
<summary>
新增合同适用范围。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.EditScope(SimpleAdmin.Application.OnboardingContractScopeEditInput)">
<summary>
编辑合同适用范围。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingContractTemplateController.DeleteScope(SimpleAdmin.Core.BaseIdListInput)">
<summary>
删除合同适用范围。
</summary>
</member>
<member name="T:SimpleAdmin.Web.Core.OnboardingEmployeeController"> <member name="T:SimpleAdmin.Web.Core.OnboardingEmployeeController">
<summary> <summary>
提供正式员工档案相关 HTTP 接口。 提供正式员工档案相关 HTTP 接口。
@@ -188,7 +168,7 @@
</member> </member>
<member name="T:SimpleAdmin.Web.Core.OnboardingEntryConfigController"> <member name="T:SimpleAdmin.Web.Core.OnboardingEntryConfigController">
<summary> <summary>
提供入职字段与材料要求配置相关 HTTP 接口。 提供员工动态补充字段与上传材料类型配置接口。
</summary> </summary>
</member> </member>
<member name="M:SimpleAdmin.Web.Core.OnboardingEntryConfigController.ListFormFields"> <member name="M:SimpleAdmin.Web.Core.OnboardingEntryConfigController.ListFormFields">
@@ -221,36 +201,6 @@
删除入职材料类型。 删除入职材料类型。
</summary> </summary>
</member> </member>
<member name="M:SimpleAdmin.Web.Core.OnboardingEntryConfigController.ListFormRequirements(System.Int64)">
<summary>
查询公司岗位动态字段要求。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingEntryConfigController.SaveFormRequirement(SimpleAdmin.Application.OnboardingFormRequirementSaveInput)">
<summary>
保存公司岗位动态字段要求。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingEntryConfigController.DeleteFormRequirements(SimpleAdmin.Core.BaseIdListInput)">
<summary>
删除动态字段要求。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingEntryConfigController.ListMaterialRequirements(System.Int64)">
<summary>
查询公司岗位材料要求。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingEntryConfigController.SaveMaterialRequirement(SimpleAdmin.Application.OnboardingMaterialRequirementSaveInput)">
<summary>
保存公司岗位材料要求。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingEntryConfigController.DeleteMaterialRequirements(SimpleAdmin.Core.BaseIdListInput)">
<summary>
删除材料要求。
</summary>
</member>
<member name="T:SimpleAdmin.Web.Core.OnboardingMaterialReviewController"> <member name="T:SimpleAdmin.Web.Core.OnboardingMaterialReviewController">
<summary> <summary>
提供候选人材料审核相关 HTTP 接口。 提供候选人材料审核相关 HTTP 接口。
@@ -311,6 +261,11 @@
保存员工已上传材料记录。 保存员工已上传材料记录。
</summary> </summary>
</member> </member>
<member name="M:SimpleAdmin.Web.Core.OnboardingPublicSubmissionController.DeleteMaterial(SimpleAdmin.Application.OnboardingDeleteMaterialInput)">
<summary>
删除当前入职任务中的一条已上传材料。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingPublicSubmissionController.ConfirmContract(SimpleAdmin.Application.OnboardingConfirmContractInput)"> <member name="M:SimpleAdmin.Web.Core.OnboardingPublicSubmissionController.ConfirmContract(SimpleAdmin.Application.OnboardingConfirmContractInput)">
<summary> <summary>
确认已阅读指定合同。 确认已阅读指定合同。
@@ -343,7 +298,7 @@
</member> </member>
<member name="M:SimpleAdmin.Web.Core.OnboardingPublicTaskController.ContractSource(SimpleAdmin.Application.OnboardingPublicContractSourceInput)"> <member name="M:SimpleAdmin.Web.Core.OnboardingPublicTaskController.ContractSource(SimpleAdmin.Application.OnboardingPublicContractSourceInput)">
<summary> <summary>
下载当前任务锁定的合同模板源文件。 下载根据当前任务资料动态生成并已替换变量的合同文件。
</summary> </summary>
</member> </member>
<member name="T:SimpleAdmin.Web.Core.OnboardingTaskContractVariableController"> <member name="T:SimpleAdmin.Web.Core.OnboardingTaskContractVariableController">
@@ -386,6 +341,11 @@
撤销入职任务及员工填写链接。 撤销入职任务及员工填写链接。
</summary> </summary>
</member> </member>
<member name="M:SimpleAdmin.Web.Core.OnboardingTaskLifecycleController.Delete(SimpleAdmin.Core.BaseIdInput)">
<summary>
删除已撤销的入职任务。
</summary>
</member>
<member name="T:SimpleAdmin.Web.Core.OnboardingTaskQueryController"> <member name="T:SimpleAdmin.Web.Core.OnboardingTaskQueryController">
<summary> <summary>
提供入职任务查询相关 HTTP 接口。 提供入职任务查询相关 HTTP 接口。
@@ -401,6 +361,41 @@
查询入职任务完整详情。 查询入职任务完整详情。
</summary> </summary>
</member> </member>
<member name="T:SimpleAdmin.Web.Core.OnboardingTemplateController">
<summary>
提供入职模板聚合配置接口。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingTemplateController.Page(SimpleAdmin.Application.OnboardingTemplatePageInput)">
<summary>
分页查询入职模板。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingTemplateController.Detail(SimpleAdmin.Core.BaseIdInput)">
<summary>
查询入职模板详情及全部关联配置。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingTemplateController.Save(SimpleAdmin.Application.OnboardingTemplateSaveInput)">
<summary>
新增或编辑入职模板。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingTemplateController.Delete(SimpleAdmin.Core.BaseIdListInput)">
<summary>
删除未被任务使用的入职模板。
</summary>
</member>
<member name="M:SimpleAdmin.Web.Core.OnboardingTemplateController.ListEnabled(System.Int64,System.Int64)">
<summary>
查询指定公司岗位可使用的入职模板。
</summary>
</member>
<member name="T:SimpleAdmin.Web.Core.OnboardingVariableDefinitionController">
<summary>
合同变量字典定义管理。
</summary>
</member>
<member name="T:SimpleAdmin.Web.Core.BizOrgController"> <member name="T:SimpleAdmin.Web.Core.BizOrgController">
<summary> <summary>
机构管理控制器 机构管理控制器
@@ -0,0 +1,364 @@
-- OnboardX 入职模板聚合重构
-- 适用数据库:MySQL 8.x
-- 执行前请备份 OnboardX 数据库。
-- 新逻辑不再读取 ob_form_requirement、ob_material_requirement、ob_contract_scope,
-- 三张旧表暂时保留,仅用于迁移和回滚;对应写接口已从业务代码中移除。
-- 执行本脚本后请重启后端服务。
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
CREATE TABLE IF NOT EXISTS `ob_onboarding_template` (
`Id` BIGINT NOT NULL COMMENT '主键Id',
`TemplateCode` VARCHAR(100) NOT NULL COMMENT '入职模板编码',
`TemplateName` VARCHAR(200) NOT NULL COMMENT '入职模板名称',
`CompanyId` BIGINT NOT NULL COMMENT '所属公司Id',
`Description` VARCHAR(1000) NULL COMMENT '模板说明',
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '状态',
`CreateTime` DATETIME NULL COMMENT '创建时间',
`UpdateTime` DATETIME NULL COMMENT '更新时间',
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
`CreateUser` VARCHAR(100) NULL COMMENT '创建人',
`UpdateUser` VARCHAR(100) NULL COMMENT '更新人',
`IsDelete` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除',
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_ob_onboarding_template_code` (`TemplateCode`),
KEY `idx_ob_onboarding_template_company` (`CompanyId`, `Status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板';
CREATE TABLE IF NOT EXISTS `ob_onboarding_template_position` (
`Id` BIGINT NOT NULL,
`OnboardingTemplateId` BIGINT NOT NULL COMMENT '入职模板Id',
`PositionId` BIGINT NOT NULL COMMENT '岗位Id',
`Status` VARCHAR(20) NULL DEFAULT 'enable',
`CreateTime` DATETIME NULL,
`UpdateTime` DATETIME NULL,
`CreateUserId` BIGINT NULL,
`UpdateUserId` BIGINT NULL,
`CreateUser` VARCHAR(100) NULL,
`UpdateUser` VARCHAR(100) NULL,
`IsDelete` TINYINT(1) NOT NULL DEFAULT 0,
`ExtJson` LONGTEXT NULL,
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_ob_template_position` (`OnboardingTemplateId`, `PositionId`),
KEY `idx_ob_template_position_position` (`PositionId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板适用岗位';
CREATE TABLE IF NOT EXISTS `ob_onboarding_template_contract` (
`Id` BIGINT NOT NULL,
`OnboardingTemplateId` BIGINT NOT NULL COMMENT '入职模板Id',
`ContractTemplateId` BIGINT NOT NULL COMMENT '合同模板Id',
`ContractTemplateVersionId` BIGINT NOT NULL COMMENT '合同模板版本Id',
`Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必须确认',
`PrintCopies` INT NOT NULL DEFAULT 1 COMMENT '打印份数',
`SortCode` INT NULL COMMENT '排序码',
`Status` VARCHAR(20) NULL DEFAULT 'enable',
`CreateTime` DATETIME NULL,
`UpdateTime` DATETIME NULL,
`CreateUserId` BIGINT NULL,
`UpdateUserId` BIGINT NULL,
`CreateUser` VARCHAR(100) NULL,
`UpdateUser` VARCHAR(100) NULL,
`IsDelete` TINYINT(1) NOT NULL DEFAULT 0,
`ExtJson` LONGTEXT NULL,
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_ob_template_contract_version` (`OnboardingTemplateId`, `ContractTemplateVersionId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板合同配置';
CREATE TABLE IF NOT EXISTS `ob_onboarding_template_form_field` (
`Id` BIGINT NOT NULL,
`OnboardingTemplateId` BIGINT NOT NULL COMMENT '入职模板Id',
`FieldId` BIGINT NOT NULL COMMENT '动态字段Id',
`Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必填',
`SortCode` INT NULL COMMENT '排序码',
`Status` VARCHAR(20) NULL DEFAULT 'enable',
`CreateTime` DATETIME NULL,
`UpdateTime` DATETIME NULL,
`CreateUserId` BIGINT NULL,
`UpdateUserId` BIGINT NULL,
`CreateUser` VARCHAR(100) NULL,
`UpdateUser` VARCHAR(100) NULL,
`IsDelete` TINYINT(1) NOT NULL DEFAULT 0,
`ExtJson` LONGTEXT NULL,
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_ob_template_form_field` (`OnboardingTemplateId`, `FieldId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板补充字段';
CREATE TABLE IF NOT EXISTS `ob_onboarding_template_material` (
`Id` BIGINT NOT NULL,
`OnboardingTemplateId` BIGINT NOT NULL COMMENT '入职模板Id',
`MaterialTypeId` BIGINT NOT NULL COMMENT '材料类型Id',
`Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必传',
`SortCode` INT NULL COMMENT '排序码',
`Status` VARCHAR(20) NULL DEFAULT 'enable',
`CreateTime` DATETIME NULL,
`UpdateTime` DATETIME NULL,
`CreateUserId` BIGINT NULL,
`UpdateUserId` BIGINT NULL,
`CreateUser` VARCHAR(100) NULL,
`UpdateUser` VARCHAR(100) NULL,
`IsDelete` TINYINT(1) NOT NULL DEFAULT 0,
`ExtJson` LONGTEXT NULL,
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_ob_template_material` (`OnboardingTemplateId`, `MaterialTypeId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入职模板上传材料';
CREATE TABLE IF NOT EXISTS `ob_task_form_requirement` (
`Id` BIGINT NOT NULL,
`TaskId` BIGINT NOT NULL COMMENT '入职任务Id',
`FieldId` BIGINT NOT NULL COMMENT '原动态字段Id',
`FieldCode` VARCHAR(100) NOT NULL COMMENT '冻结字段编码',
`FieldName` VARCHAR(200) NOT NULL COMMENT '冻结字段名称',
`DataType` VARCHAR(50) NOT NULL COMMENT '冻结数据类型',
`OptionsJson` JSON NULL COMMENT '冻结选项JSON',
`Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必填',
`SortCode` INT NULL COMMENT '排序码',
`Status` VARCHAR(20) NULL DEFAULT 'enable',
`CreateTime` DATETIME NULL,
`UpdateTime` DATETIME NULL,
`CreateUserId` BIGINT NULL,
`UpdateUserId` BIGINT NULL,
`CreateUser` VARCHAR(100) NULL,
`UpdateUser` VARCHAR(100) NULL,
`IsDelete` TINYINT(1) NOT NULL DEFAULT 0,
`ExtJson` LONGTEXT NULL,
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_ob_task_form_requirement` (`TaskId`, `FieldId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务补充字段快照';
CREATE TABLE IF NOT EXISTS `ob_task_material_requirement` (
`Id` BIGINT NOT NULL,
`TaskId` BIGINT NOT NULL COMMENT '入职任务Id',
`MaterialTypeId` BIGINT NOT NULL COMMENT '原材料类型Id',
`MaterialCode` VARCHAR(100) NOT NULL COMMENT '冻结材料编码',
`MaterialName` VARCHAR(200) NOT NULL COMMENT '冻结材料名称',
`AllowedExtensions` VARCHAR(500) NULL COMMENT '冻结允许扩展名',
`MaxFileCount` INT NOT NULL DEFAULT 1 COMMENT '冻结最大文件数',
`MaxFileSizeMb` INT NULL COMMENT '冻结单文件大小MB',
`Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必传',
`SortCode` INT NULL COMMENT '排序码',
`Status` VARCHAR(20) NULL DEFAULT 'enable',
`CreateTime` DATETIME NULL,
`UpdateTime` DATETIME NULL,
`CreateUserId` BIGINT NULL,
`UpdateUserId` BIGINT NULL,
`CreateUser` VARCHAR(100) NULL,
`UpdateUser` VARCHAR(100) NULL,
`IsDelete` TINYINT(1) NOT NULL DEFAULT 0,
`ExtJson` LONGTEXT NULL,
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_ob_task_material_requirement` (`TaskId`, `MaterialTypeId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务上传材料快照';
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 ;
CALL `ob_add_column_if_missing`(
'ob_onboarding_task',
'OnboardingTemplateId',
'BIGINT NOT NULL DEFAULT 0 COMMENT ''创建任务时锁定的入职模板Id'' AFTER `PositionId`'
);
CALL `ob_add_column_if_missing`(
'ob_task_contract',
'PrintCopies',
'INT NOT NULL DEFAULT 1 COMMENT ''合同打印份数'' AFTER `Required`'
);
DROP PROCEDURE IF EXISTS `ob_add_column_if_missing`;
-- 将旧的公司/岗位合同范围迁移为“一公司一岗位一入职模板”。
-- 公司级合同、字段和材料要求会自动继承到该公司的每个岗位模板。
DROP TEMPORARY TABLE IF EXISTS `tmp_ob_template_map`;
CREATE TEMPORARY TABLE `tmp_ob_template_map` (
`CompanyId` BIGINT NOT NULL,
`PositionId` BIGINT NOT NULL,
`TemplateCode` VARCHAR(100) NOT NULL,
`OnboardingTemplateId` BIGINT NULL,
PRIMARY KEY (`CompanyId`, `PositionId`)
);
INSERT IGNORE INTO `tmp_ob_template_map` (`CompanyId`, `PositionId`, `TemplateCode`)
SELECT DISTINCT p.`CompanyId`, p.`Id`, CONCAT('MIGRATED_', p.`CompanyId`, '_', p.`Id`)
FROM `ob_position` p
INNER JOIN (
SELECT `CompanyId` FROM `ob_contract_scope` WHERE `IsDelete` = 0
UNION
SELECT `CompanyId` FROM `ob_form_requirement` WHERE `IsDelete` = 0
UNION
SELECT `CompanyId` FROM `ob_material_requirement` WHERE `IsDelete` = 0
) configured_company ON configured_company.`CompanyId` = p.`CompanyId`
WHERE p.`IsDelete` = 0;
INSERT IGNORE INTO `ob_onboarding_template` (
`Id`, `TemplateCode`, `TemplateName`, `CompanyId`, `Description`, `Status`, `CreateTime`, `IsDelete`
)
SELECT
UUID_SHORT(),
m.`TemplateCode`,
CONCAT(COALESCE(c.`CompanyName`, m.`CompanyId`), '-', COALESCE(p.`PositionName`, m.`PositionId`), '-入职模板'),
m.`CompanyId`,
'由旧公司岗位配置自动迁移,请管理员检查后使用',
'enable',
NOW(),
0
FROM `tmp_ob_template_map` m
LEFT JOIN `ob_company` c ON c.`Id` = m.`CompanyId`
LEFT JOIN `ob_position` p ON p.`Id` = m.`PositionId`;
UPDATE `tmp_ob_template_map` m
INNER JOIN `ob_onboarding_template` t ON t.`TemplateCode` = m.`TemplateCode`
SET m.`OnboardingTemplateId` = t.`Id`;
INSERT IGNORE INTO `ob_onboarding_template_position` (
`Id`, `OnboardingTemplateId`, `PositionId`, `Status`, `CreateTime`, `IsDelete`
)
SELECT UUID_SHORT(), m.`OnboardingTemplateId`, m.`PositionId`, 'enable', NOW(), 0
FROM `tmp_ob_template_map` m;
INSERT IGNORE INTO `ob_onboarding_template_contract` (
`Id`, `OnboardingTemplateId`, `ContractTemplateId`, `ContractTemplateVersionId`,
`Required`, `PrintCopies`, `SortCode`, `Status`, `CreateTime`, `IsDelete`
)
SELECT
UUID_SHORT(),
m.`OnboardingTemplateId`,
MAX(s.`TemplateId`),
s.`TemplateVersionId`,
COALESCE(
MAX(CASE WHEN s.`PositionId` = m.`PositionId` THEN s.`Required` END),
MAX(s.`Required`)
),
1,
COALESCE(
MAX(CASE WHEN s.`PositionId` = m.`PositionId` THEN s.`SortCode` END),
MAX(s.`SortCode`)
),
'enable',
NOW(),
0
FROM `tmp_ob_template_map` m
INNER JOIN `ob_contract_scope` s
ON s.`CompanyId` = m.`CompanyId`
AND (s.`PositionId` IS NULL OR s.`PositionId` = m.`PositionId`)
AND s.`IsDelete` = 0
GROUP BY m.`OnboardingTemplateId`, s.`TemplateVersionId`;
INSERT IGNORE INTO `ob_onboarding_template_form_field` (
`Id`, `OnboardingTemplateId`, `FieldId`, `Required`, `SortCode`, `Status`, `CreateTime`, `IsDelete`
)
SELECT
UUID_SHORT(),
m.`OnboardingTemplateId`,
r.`FieldId`,
COALESCE(
MAX(CASE WHEN r.`PositionId` = m.`PositionId` THEN r.`Required` END),
MAX(r.`Required`)
),
COALESCE(
MAX(CASE WHEN r.`PositionId` = m.`PositionId` THEN r.`SortCode` END),
MAX(r.`SortCode`)
),
'enable',
NOW(),
0
FROM `tmp_ob_template_map` m
INNER JOIN `ob_form_requirement` r
ON r.`CompanyId` = m.`CompanyId`
AND (r.`PositionId` IS NULL OR r.`PositionId` = m.`PositionId`)
AND r.`IsDelete` = 0
GROUP BY m.`OnboardingTemplateId`, r.`FieldId`;
INSERT IGNORE INTO `ob_onboarding_template_material` (
`Id`, `OnboardingTemplateId`, `MaterialTypeId`, `Required`, `SortCode`, `Status`, `CreateTime`, `IsDelete`
)
SELECT
UUID_SHORT(),
m.`OnboardingTemplateId`,
r.`MaterialTypeId`,
COALESCE(
MAX(CASE WHEN r.`PositionId` = m.`PositionId` THEN r.`Required` END),
MAX(r.`Required`)
),
COALESCE(
MAX(CASE WHEN r.`PositionId` = m.`PositionId` THEN r.`SortCode` END),
MAX(r.`SortCode`)
),
'enable',
NOW(),
0
FROM `tmp_ob_template_map` m
INNER JOIN `ob_material_requirement` r
ON r.`CompanyId` = m.`CompanyId`
AND (r.`PositionId` IS NULL OR r.`PositionId` = m.`PositionId`)
AND r.`IsDelete` = 0
GROUP BY m.`OnboardingTemplateId`, r.`MaterialTypeId`;
-- 为迁移前已创建的入职任务回填入职模板,并冻结字段/材料要求快照。
UPDATE `ob_onboarding_task` task
INNER JOIN `tmp_ob_template_map` m
ON m.`CompanyId` = task.`CompanyId`
AND m.`PositionId` = task.`PositionId`
SET task.`OnboardingTemplateId` = m.`OnboardingTemplateId`
WHERE task.`OnboardingTemplateId` = 0;
INSERT IGNORE INTO `ob_task_form_requirement` (
`Id`, `TaskId`, `FieldId`, `FieldCode`, `FieldName`, `DataType`, `OptionsJson`,
`Required`, `SortCode`, `Status`, `CreateTime`, `IsDelete`
)
SELECT
UUID_SHORT(), task.`Id`, config.`FieldId`, field.`FieldCode`, field.`FieldName`,
field.`DataType`, field.`OptionsJson`, config.`Required`, config.`SortCode`,
'enable', NOW(), 0
FROM `ob_onboarding_task` task
INNER JOIN `ob_onboarding_template_form_field` config
ON config.`OnboardingTemplateId` = task.`OnboardingTemplateId`
INNER JOIN `ob_form_field` field ON field.`Id` = config.`FieldId` AND field.`IsDelete` = 0
WHERE task.`OnboardingTemplateId` <> 0;
INSERT IGNORE INTO `ob_task_material_requirement` (
`Id`, `TaskId`, `MaterialTypeId`, `MaterialCode`, `MaterialName`,
`AllowedExtensions`, `MaxFileCount`, `MaxFileSizeMb`, `Required`, `SortCode`,
`Status`, `CreateTime`, `IsDelete`
)
SELECT
UUID_SHORT(), task.`Id`, config.`MaterialTypeId`, material.`MaterialCode`,
material.`MaterialName`, material.`AllowedExtensions`, material.`MaxFileCount`,
material.`MaxFileSizeMb`, config.`Required`, config.`SortCode`,
'enable', NOW(), 0
FROM `ob_onboarding_task` task
INNER JOIN `ob_onboarding_template_material` config
ON config.`OnboardingTemplateId` = task.`OnboardingTemplateId`
INNER JOIN `ob_material_type` material ON material.`Id` = config.`MaterialTypeId` AND material.`IsDelete` = 0
WHERE task.`OnboardingTemplateId` <> 0;
DROP TEMPORARY TABLE IF EXISTS `tmp_ob_template_map`;
SET FOREIGN_KEY_CHECKS = 1;
-- 执行后检查:
-- SELECT * FROM ob_onboarding_template;
-- SELECT * FROM ob_onboarding_template_position;
-- SELECT * FROM ob_onboarding_template_contract;
-- SELECT * FROM ob_onboarding_template_form_field;
-- SELECT * FROM ob_onboarding_template_material;
@@ -0,0 +1,144 @@
-- ====================================================================
-- OnboardX 数据库全量升级与重建脚本 (2026-08-25)
-- 包含标准化《入职登记表》全字段、合同变量字典体系、精简入职模板
-- ====================================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- 1. 变量字典定义表 (新核心表)
CREATE TABLE IF NOT EXISTS `ob_variable_definition` (
`Id` BIGINT NOT NULL COMMENT '主键Id',
`VariableCode` VARCHAR(128) NOT NULL COMMENT '变量全编码 (如 employee.EmployeeName, hr.BaseSalary)',
`VariableName` VARCHAR(128) NOT NULL COMMENT '变量中文显示名称',
`VariableCategory` VARCHAR(32) NOT NULL COMMENT '变量分类: employee, hr, company, system',
`DataType` VARCHAR(32) NOT NULL DEFAULT 'string' COMMENT '数据类型: string, number, date',
`DefaultValue` VARCHAR(512) NULL COMMENT '默认值/占位提示',
`IsBuiltIn` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为登记表内置字段 (1=是,0=否)',
`Required` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否必填',
`SortCode` INT NOT NULL DEFAULT 100 COMMENT '排序码',
`Remark` VARCHAR(512) NULL COMMENT '说明备注',
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '状态',
`CreateTime` DATETIME NULL COMMENT '创建时间',
`UpdateTime` DATETIME NULL COMMENT '更新时间',
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
`CreateUser` VARCHAR(64) NULL COMMENT '创建人',
`UpdateUser` VARCHAR(64) NULL COMMENT '更新人',
`IsDelete` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删除',
`ExtJson` LONGTEXT NULL,
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_variable_code` (`VariableCode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合同变量字典定义表';
-- 预置标准变量字典数据
TRUNCATE TABLE `ob_variable_definition`;
INSERT INTO `ob_variable_definition` (`Id`, `VariableCode`, `VariableName`, `VariableCategory`, `DataType`, `IsBuiltIn`, `Required`, `SortCode`, `Remark`, `CreateTime`, `IsDelete`) VALUES
-- 员工入职登记表内置字段 (employee.)
(841400000000001, 'employee.EmployeeName', '员工姓名', 'employee', 'string', 1, 1, 10, '入职登记表-姓名', NOW(), 0),
(841400000000002, 'employee.Gender', '性别', 'employee', 'string', 1, 1, 20, '入职登记表-性别', NOW(), 0),
(841400000000003, 'employee.Nation', '民族', 'employee', 'string', 1, 0, 30, '入职登记表-民族', NOW(), 0),
(841400000000004, 'employee.Birthday', '出生年月日', 'employee', 'date', 1, 0, 40, '入职登记表-出生日期', NOW(), 0),
(841400000000005, 'employee.RegisteredResidence', '户籍所在地', 'employee', 'string', 1, 0, 50, '入职登记表-户籍', NOW(), 0),
(841400000000006, 'employee.Height', '身高(cm)', 'employee', 'string', 1, 0, 60, '入职登记表-身高', NOW(), 0),
(841400000000007, 'employee.Weight', '体重(kg)', 'employee', 'string', 1, 0, 70, '入职登记表-体重', NOW(), 0),
(841400000000008, 'employee.HealthCondition', '健康状况', 'employee', 'string', 1, 0, 80, '入职登记表-健康状况', NOW(), 0),
(841400000000009, 'employee.MaritalStatus', '婚育情况', 'employee', 'string', 1, 0, 90, '入职登记表-婚育情况', NOW(), 0),
(841400000000010, 'employee.Education', '最高学历', 'employee', 'string', 1, 0, 100, '入职登记表-学历', NOW(), 0),
(841400000000011, 'employee.Major', '所学专业', 'employee', 'string', 1, 0, 110, '入职登记表-专业', NOW(), 0),
(841400000000012, 'employee.GraduatedSchool', '毕业院校', 'employee', 'string', 1, 0, 120, '入职登记表-毕业院校', NOW(), 0),
(841400000000013, 'employee.VocationalQualification', '职业资格', 'employee', 'string', 1, 0, 130, '入职登记表-职业资格', NOW(), 0),
(841400000000014, 'employee.QualificationLevel', '资格等级', 'employee', 'string', 1, 0, 140, '入职登记表-等级', NOW(), 0),
(841400000000015, 'employee.TechnicalSpecialty', '技术特长', 'employee', 'string', 1, 0, 150, '入职登记表-有何技术特长', NOW(), 0),
(841400000000016, 'employee.Phone', '联系电话', 'employee', 'string', 1, 1, 160, '入职登记表-联系电话', NOW(), 0),
(841400000000017, 'employee.Email', '电子邮箱', 'employee', 'string', 1, 0, 170, '入职登记表-邮箱', NOW(), 0),
(841400000000018, 'employee.IdCardNumber', '身份证号码', 'employee', 'string', 1, 1, 180, '入职登记表-身份证号码', NOW(), 0),
(841400000000019, 'employee.CurrentAddress', '现居住/暂住地址', 'employee', 'string', 1, 0, 190, '入职登记表-暂住地址', NOW(), 0),
(841400000000020, 'employee.LandlordContact', '房东联系方式', 'employee', 'string', 1, 0, 200, '入职登记表-房东联系方式', NOW(), 0),
(841400000000021, 'employee.BankCardNumber', '工资银行卡号', 'employee', 'string', 1, 0, 210, '入职登记表-银行卡号', NOW(), 0),
(841400000000022, 'employee.BankName', '开户银行', 'employee', 'string', 1, 0, 220, '入职登记表-开户银行', NOW(), 0),
(841400000000023, 'employee.BankAccountName', '开户人姓名', 'employee', 'string', 1, 0, 230, '入职登记表-开户名', NOW(), 0),
(841400000000024, 'employee.EmergencyContact', '紧急联系人姓名', 'employee', 'string', 1, 0, 240, '入职登记表-紧急联系人', NOW(), 0),
(841400000000025, 'employee.EmergencyPhone', '紧急联系人电话', 'employee', 'string', 1, 0, 250, '入职登记表-紧急联系人电话', NOW(), 0),
(841400000000026, 'employee.EmergencyAddress', '紧急联系人地址', 'employee', 'string', 1, 0, 260, '入职登记表-紧急联系人地址', NOW(), 0),
-- 人事合同参数 (hr.)
(841400000000101, 'hr.BaseSalary', '基本工资(元)', 'hr', 'number', 1, 1, 300, '合同约定基本工资', NOW(), 0),
(841400000000102, 'hr.PositionSalary', '岗位工资(元)', 'hr', 'number', 1, 0, 310, '合同约定岗位工资', NOW(), 0),
(841400000000103, 'hr.PerformanceAllowance', '绩效津贴(元)', 'hr', 'number', 1, 0, 320, '合同绩效工资/津贴', NOW(), 0),
(841400000000104, 'hr.AttendanceBonus', '全勤奖(元)', 'hr', 'number', 1, 0, 330, '全勤奖金', NOW(), 0),
(841400000000105, 'hr.RegularSalary', '转正后薪资(元)', 'hr', 'number', 1, 0, 340, '转正薪资', NOW(), 0),
(841400000000106, 'hr.WorkCity', '工作地点/城市', 'hr', 'string', 1, 0, 350, '合同履行工作地点', NOW(), 0),
(841400000000107, 'hr.PositionName', '聘用岗位名称', 'hr', 'string', 1, 0, 360, '入职岗位名称', NOW(), 0),
(841400000000108, 'hr.ContractTerm', '合同期限', 'hr', 'string', 1, 0, 370, '固定期限,如:3年', NOW(), 0),
(841400000000109, 'hr.ContractStartDate', '合同开始日期', 'hr', 'date', 1, 1, 380, '劳动合同起算日期', NOW(), 0),
(841400000000110, 'hr.ContractEndDate', '合同结束日期', 'hr', 'date', 1, 1, 390, '劳动合同终止日期', NOW(), 0),
(841400000000111, 'hr.ProbationTerm', '试用期期限', 'hr', 'string', 1, 0, 400, '试用期时长,如:3个月', NOW(), 0),
(841400000000112, 'hr.ProbationStartDate', '试用期开始日期', 'hr', 'date', 1, 0, 410, '试用期起算日期', NOW(), 0),
(841400000000113, 'hr.ProbationEndDate', '试用期结束日期', 'hr', 'date', 1, 0, 420, '试用期截至日期', NOW(), 0),
(841400000000114, 'hr.EmployeeNo', '员工工号', 'hr', 'string', 1, 0, 430, '企业内部员工编号', NOW(), 0),
-- 公司与系统参数 (company. / system.)
(841400000000201, 'company.CompanyName', '公司主体全称', 'company', 'string', 1, 0, 500, '签署方甲方公司全称', NOW(), 0),
(841400000000202, 'company.CompanyCode', '公司统一代码', 'company', 'string', 1, 0, 510, '统一社会信用代码', NOW(), 0),
(841400000000203, 'company.LegalRepresentative', '法定代表人', 'company', 'string', 1, 0, 520, '甲方企业法定代表人', NOW(), 0),
(841400000000204, 'company.Address', '公司注册地址', 'company', 'string', 1, 0, 530, '甲方注册地址', NOW(), 0),
(841400000000205, 'company.Phone', '公司联系电话', 'company', 'string', 1, 0, 540, '甲方联系电话', NOW(), 0),
(841400000000301, 'system.TaskNo', '入职任务编号', 'system', 'string', 1, 0, 600, '系统入职流水号', NOW(), 0),
(841400000000302, 'system.SignDate', '合同签署日期', 'system', 'date', 1, 0, 610, '实际签署生成日期', NOW(), 0);
-- 2. 入职登记表员工档案表升级重建
DROP TABLE IF EXISTS `ob_task_employee_info`;
CREATE TABLE `ob_task_employee_info` (
`Id` BIGINT NOT NULL COMMENT '主键Id',
`TaskId` BIGINT NOT NULL COMMENT '所属入职任务Id',
`EmployeeNo` VARCHAR(64) NULL COMMENT '员工工号',
`EmployeeName` VARCHAR(64) NULL COMMENT '员工姓名',
`Gender` VARCHAR(16) NULL COMMENT '性别',
`Nation` VARCHAR(32) NULL COMMENT '民族',
`Birthday` VARCHAR(32) NULL COMMENT '出生年月日',
`RegisteredResidence` VARCHAR(256) NULL COMMENT '户籍所在地',
`Height` VARCHAR(16) NULL COMMENT '身高',
`Weight` VARCHAR(16) NULL COMMENT '体重',
`HealthCondition` VARCHAR(64) NULL COMMENT '健康状况',
`MaritalStatus` VARCHAR(32) NULL COMMENT '婚育情况',
`Education` VARCHAR(32) NULL COMMENT '最高学历',
`Major` VARCHAR(64) NULL COMMENT '所学专业',
`GraduatedSchool` VARCHAR(128) NULL COMMENT '毕业院校',
`VocationalQualification` VARCHAR(128) NULL COMMENT '职业资格',
`QualificationLevel` VARCHAR(64) NULL COMMENT '资格等级',
`TechnicalSpecialty` VARCHAR(256) NULL COMMENT '有何技术特长',
`Phone` VARCHAR(32) NULL COMMENT '联系电话',
`Email` VARCHAR(64) NULL COMMENT '电子邮箱',
`IdCardNumber` VARCHAR(32) NULL COMMENT '身份证号码',
`CurrentAddress` VARCHAR(256) NULL COMMENT '暂住/现居住地址',
`LandlordContact` VARCHAR(64) NULL COMMENT '房东联系方式',
`BankCardNumber` VARCHAR(64) NULL COMMENT '工资银行卡号',
`BankName` VARCHAR(128) NULL COMMENT '开户银行名称',
`BankAccountName` VARCHAR(64) NULL COMMENT '银行卡开户名',
`EmergencyContact` VARCHAR(64) NULL COMMENT '紧急联系人姓名',
`EmergencyPhone` VARCHAR(32) NULL COMMENT '紧急联系人电话',
`EmergencyAddress` VARCHAR(256) NULL COMMENT '紧急联系人地址',
`EducationHistoryJson` LONGTEXT NULL COMMENT '教育学习经历 JSON',
`WorkHistoryJson` LONGTEXT NULL COMMENT '主要工作经历 JSON',
`FamilyMembersJson` LONGTEXT NULL COMMENT '家庭成员 JSON',
`OnboardingDepartment` VARCHAR(64) NULL COMMENT '入职部门',
`OnboardingDate` VARCHAR(32) NULL COMMENT '入职时间',
`OnboardingPosition` VARCHAR(64) NULL COMMENT '入职岗位',
`OtherRemarks` VARCHAR(512) NULL COMMENT '其他说明',
`CustomVariablesJson` LONGTEXT NULL COMMENT '特殊合同增量变量键值 JSON',
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '状态',
`CreateTime` DATETIME NULL,
`UpdateTime` DATETIME NULL,
`CreateUserId` BIGINT NULL,
`UpdateUserId` BIGINT NULL,
`CreateUser` VARCHAR(64) NULL,
`UpdateUser` VARCHAR(64) NULL,
`IsDelete` TINYINT(1) NOT NULL DEFAULT 0,
`ExtJson` LONGTEXT NULL,
PRIMARY KEY (`Id`),
KEY `idx_task_id` (`TaskId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='候选人填写的入职登记表';
SET FOREIGN_KEY_CHECKS = 1;
@@ -0,0 +1,68 @@
-- OnboardX 新版入职登记表与框架实体字段对齐
-- 仅补列,不删除或重建现有业务数据。
SET NAMES utf8mb4;
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 ;
CALL `ob_add_column_if_missing`(
'ob_variable_definition',
'Status',
'VARCHAR(20) NULL DEFAULT ''enable'' COMMENT ''状态'' AFTER `Remark`'
);
CALL `ob_add_column_if_missing`(
'ob_variable_definition',
'CreateUserId',
'BIGINT NULL COMMENT ''创建者Id'' AFTER `UpdateTime`'
);
CALL `ob_add_column_if_missing`(
'ob_variable_definition',
'UpdateUserId',
'BIGINT NULL COMMENT ''修改者Id'' AFTER `CreateUserId`'
);
CALL `ob_add_column_if_missing`(
'ob_task_employee_info',
'Status',
'VARCHAR(20) NULL DEFAULT ''enable'' COMMENT ''状态'' AFTER `CustomVariablesJson`'
);
CALL `ob_add_column_if_missing`(
'ob_task_employee_info',
'CreateUserId',
'BIGINT NULL COMMENT ''创建者Id'' AFTER `UpdateTime`'
);
CALL `ob_add_column_if_missing`(
'ob_task_employee_info',
'UpdateUserId',
'BIGINT NULL COMMENT ''修改者Id'' AFTER `CreateUserId`'
);
DROP PROCEDURE IF EXISTS `ob_add_column_if_missing`;
-- 检查结果:
-- SHOW COLUMNS FROM ob_variable_definition;
-- SHOW COLUMNS FROM ob_task_employee_info;