Compare commits
5
Commits
1d42c52d53
...
95b14b198a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95b14b198a | ||
|
|
c015bc356c | ||
|
|
da777327f5 | ||
|
|
384eaff444 | ||
|
|
6151872b6b |
@@ -6,8 +6,30 @@
|
||||
"Title": "业务模块" //配置分组标题,string 类型
|
||||
},
|
||||
//系统层设置
|
||||
"ApplicationSettings": {
|
||||
"InitTable": false, //是否初始化表结构
|
||||
"InitSeedData": false //是否初始化种子数据
|
||||
"ApplicationSettings": {
|
||||
"InitTable": false, //是否初始化表结构
|
||||
"InitSeedData": false //是否初始化种子数据
|
||||
},
|
||||
// 契约锁测试环境配置。AppToken、AppSecret 由部署人员填写,禁止提交真实密钥。
|
||||
"QiyuesuoSetting": {
|
||||
"Enabled": true,
|
||||
"ServerUrl": "https://openapi.qiyuesuo.cn",
|
||||
"AppToken": "rMKU99gkts",
|
||||
"AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O",
|
||||
"SignatureVersion": "",
|
||||
"CreatorName": "袁林",
|
||||
"CreatorMobile": "15123135384",
|
||||
"CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev/api/open/onboarding/qiyuesuo/callback",
|
||||
"SignPageCallbackUrl": "",
|
||||
"SignUrlExpireMinutes": 10,
|
||||
"EmployeeSignKeyword": "乙方(劳动者)签字:",
|
||||
"EmployeeSignKeywordIndex": 1,
|
||||
"CompanySealKeyword": "甲方(盖章):",
|
||||
"CompanySealKeywordIndex": 1,
|
||||
"CompanySealOffsetX": 0,
|
||||
"CompanySealOffsetY": -0.05,
|
||||
"EnableAcrossPageSeal": true,
|
||||
"AcrossPageSealType": "ALL_DOCUMENT",
|
||||
"TimeoutSeconds": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,28 @@
|
||||
//系统层设置
|
||||
"ApplicationSettings": {
|
||||
"InitTable": false, //是否初始化表结构
|
||||
"InitSeedData": false, //是否初始化种子数据0
|
||||
}
|
||||
}
|
||||
"InitSeedData": false //是否初始化种子数据
|
||||
},
|
||||
// 契约锁正式环境配置。上线前填写正式 AppToken/AppSecret,并把 ServerUrl 切换为 .com。
|
||||
"QiyuesuoSetting": {
|
||||
"Enabled": true,
|
||||
"ServerUrl": "https://openapi.qiyuesuo.com",
|
||||
"AppToken": "rMKU99gkts",
|
||||
"AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O",
|
||||
"SignatureVersion": "",
|
||||
"CreatorName": "袁林",
|
||||
"CreatorMobile": "15123135384",
|
||||
"CallbackUrl": "https://onboardx-api.cnzhyx.cn/api/open/onboarding/qiyuesuo/callback",
|
||||
"SignPageCallbackUrl": "",
|
||||
"SignUrlExpireMinutes": 10,
|
||||
"EmployeeSignKeyword": "乙方(劳动者)签字:",
|
||||
"EmployeeSignKeywordIndex": 1,
|
||||
"CompanySealKeyword": "甲方(盖章):",
|
||||
"CompanySealKeywordIndex": 1,
|
||||
"CompanySealOffsetX": 0,
|
||||
"CompanySealOffsetY": -0.12,
|
||||
"EnableAcrossPageSeal": true,
|
||||
"AcrossPageSealType": "ALL_DOCUMENT",
|
||||
"TimeoutSeconds": 90
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,16 @@ public class ObCompany : BaseEntity
|
||||
/// </summary>
|
||||
public string ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
|
||||
/// </summary>
|
||||
public string QiyuesuoCategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业印章 Id;每个签约公司独立配置。
|
||||
/// </summary>
|
||||
public string QiyuesuoSealId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 电子签流程中的合同文件映射,一条任务合同对应一个契约锁文档。
|
||||
/// </summary>
|
||||
[SugarTable("ob_electronic_sign_document", TableDescription = "入职电子签文档")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObElectronicSignDocument : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属电子签流程 Id。
|
||||
/// </summary>
|
||||
public long SignProcessId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对应的入职任务合同 Id。
|
||||
/// </summary>
|
||||
public long TaskContractId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁文档 Id。
|
||||
/// </summary>
|
||||
public string ProviderDocumentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发起签署时冻结的文档名称。
|
||||
/// </summary>
|
||||
public string DocumentName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文档在同一签署流程中的顺序。
|
||||
/// </summary>
|
||||
public int DocumentSort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传契约锁的原始 DOCX 系统文件 Id。
|
||||
/// </summary>
|
||||
public long OriginalDocxFileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签署完成后归档的 PDF 系统文件 Id。
|
||||
/// </summary>
|
||||
public long? SignedPdfFileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工签名关键字快照。
|
||||
/// </summary>
|
||||
public string EmployeeSignKeyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 企业盖章关键字快照。
|
||||
/// </summary>
|
||||
public string CompanySealKeyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁员工签署动作 Id。
|
||||
/// </summary>
|
||||
public string EmployeeActionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业盖章动作 Id。
|
||||
/// </summary>
|
||||
public string CompanyActionId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁回调与主动同步事件,用于幂等处理和完整审计。
|
||||
/// </summary>
|
||||
[SugarTable("ob_electronic_sign_event", TableDescription = "入职电子签事件")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObElectronicSignEvent : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属电子签流程 Id;无法识别流程的异常回调可为空。
|
||||
/// </summary>
|
||||
public long? SignProcessId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 回调内容 SHA-256,用于识别完全重复的通知。
|
||||
/// </summary>
|
||||
public string PayloadHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件来源,例如 callback 或 manual_sync。
|
||||
/// </summary>
|
||||
public string EventSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁回调事件类型。
|
||||
/// </summary>
|
||||
public string EventType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 回调携带或主动查询得到的契约锁状态。
|
||||
/// </summary>
|
||||
public string ProviderStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁原始请求内容,保留用于审计排查。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string PayloadJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次事件处理结果。
|
||||
/// </summary>
|
||||
public string ProcessResult { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收到事件的时间。
|
||||
/// </summary>
|
||||
public DateTime ReceivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件处理完成时间。
|
||||
/// </summary>
|
||||
public DateTime? ProcessedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务的一次电子签流程,一次流程可以包含多份合同文件。
|
||||
/// </summary>
|
||||
[SugarTable("ob_electronic_sign_process", TableDescription = "入职电子签流程")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObElectronicSignProcess : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属入职任务 Id。
|
||||
/// </summary>
|
||||
public long TaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过后生成的正式员工 Id。
|
||||
/// </summary>
|
||||
public long EmployeeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 电子签服务商编码。
|
||||
/// </summary>
|
||||
public string Provider { get; set; } = ElectronicSignProvider.Qiyuesuo;
|
||||
|
||||
/// <summary>
|
||||
/// 本系统生成的幂等业务编号。
|
||||
/// </summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为该入职任务当前使用的电子签流程。
|
||||
/// 历史流程保存为空,当前流程保存为 true,并通过数据库唯一索引防止重复发起。
|
||||
/// </summary>
|
||||
public bool? IsCurrent { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同流程 Id。
|
||||
/// </summary>
|
||||
public string ProviderContractId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁业务分类 Id 快照。
|
||||
/// </summary>
|
||||
public string CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业印章 Id 快照。
|
||||
/// </summary>
|
||||
public string SealId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 电子签流程标题。
|
||||
/// </summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工姓名快照。
|
||||
/// </summary>
|
||||
public string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工手机号码快照。
|
||||
/// </summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工身份证号码快照,仅用于锁定本次电子签署人的实名信息。
|
||||
/// </summary>
|
||||
public string EmployeeIdCardNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同创建人姓名快照。
|
||||
/// </summary>
|
||||
public string CreatorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同创建人手机号快照。
|
||||
/// </summary>
|
||||
public string CreatorMobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本系统电子签状态。
|
||||
/// </summary>
|
||||
public string SignStatus { get; set; } = ElectronicSignStatus.Creating;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁返回的原始合同状态。
|
||||
/// </summary>
|
||||
public string ProviderStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工签署页面地址。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string SignUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工签署页面地址到期时间。
|
||||
/// </summary>
|
||||
public DateTime? SignUrlExpireAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 管理员点击发起电子签的时间。
|
||||
/// </summary>
|
||||
public DateTime StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 合同流程正式发送的时间。
|
||||
/// </summary>
|
||||
public DateTime? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所有签署方完成签署的时间。
|
||||
/// </summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签署文件与签署日志保存到 OSS 的时间。
|
||||
/// </summary>
|
||||
public DateTime? ArchivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁完整归档 ZIP 在系统文件表中的 Id。
|
||||
/// </summary>
|
||||
public long? ArchiveFileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后一次主动查询契约锁状态的时间。
|
||||
/// </summary>
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后一次收到契约锁回调的时间。
|
||||
/// </summary>
|
||||
public DateTime? LastCallbackAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近一次失败原因,供管理员排查和重试。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string LastError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建或同步失败后的重试次数。
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 管理员重新发起本任务电子签时填写的原因。
|
||||
/// </summary>
|
||||
public string ReissueReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前流程被新流程替代的时间。
|
||||
/// </summary>
|
||||
public DateTime? ReissuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 替代当前流程的新电子签流程 Id。
|
||||
/// </summary>
|
||||
public long? ReissuedByProcessId { get; set; }
|
||||
}
|
||||
@@ -11,9 +11,9 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过后生成的正式员工档案,是入职流程的最终业务数据。
|
||||
/// 审核通过后生成的员工档案,是入职流程的最终业务数据。
|
||||
/// </summary>
|
||||
[SugarTable("ob_employee", TableDescription = "正式员工档案")]
|
||||
[SugarTable("ob_employee", TableDescription = "员工档案")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObEmployee : BaseEntity
|
||||
{
|
||||
@@ -59,6 +59,21 @@ public class ObEmployee : BaseEntity
|
||||
/// </summary>
|
||||
public long PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用工类型:full_time 正式用工,part_time 兼职用工。
|
||||
/// </summary>
|
||||
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
|
||||
|
||||
/// <summary>
|
||||
/// 转正状态:probation 试用期、regular 已转正、not_applicable 不适用。
|
||||
/// </summary>
|
||||
public string RegularizationStatus { get; set; } = EmployeeRegularizationStatus.Probation;
|
||||
|
||||
/// <summary>
|
||||
/// 社保状态:unknown 未维护、not_purchased 未购买、purchased 已购买、stopped 已停保、not_applicable 不适用。
|
||||
/// </summary>
|
||||
public string SocialInsuranceStatus { get; set; } = EmployeeSocialInsuranceStatus.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// 所属岗位名称,仅用于接口展示,不映射数据库字段。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 员工人事材料附件,可关联转正记录及后续扩展的人事业务。
|
||||
/// </summary>
|
||||
[SugarTable("ob_employee_attachment", TableDescription = "员工人事材料附件")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObEmployeeAttachment : BaseEntity
|
||||
{
|
||||
public long EmployeeId { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public long? ChangeRecordId { get; set; }
|
||||
|
||||
public string BusinessType { get; set; }
|
||||
|
||||
public long FileId { get; set; }
|
||||
|
||||
public string FileName { get; set; }
|
||||
|
||||
public DateTime UploadedAt { get; set; }
|
||||
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public string FileSuffix { get; set; }
|
||||
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public string FileSizeInfo { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 员工人事信息变更记录,保留转正及后续扩展业务的前后快照。
|
||||
/// </summary>
|
||||
[SugarTable("ob_employee_change_record", TableDescription = "员工人事信息变更记录")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObEmployeeChangeRecord : BaseEntity
|
||||
{
|
||||
public long EmployeeId { get; set; }
|
||||
|
||||
public string ChangeType { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public DateTime? EffectiveDate { get; set; }
|
||||
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string BeforeJson { get; set; }
|
||||
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string AfterJson { get; set; }
|
||||
|
||||
public string Remark { get; set; }
|
||||
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public List<ObEmployeeAttachment> Attachments { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 正式员工的一份合同在审核建档时冻结的业务快照。
|
||||
/// 常用日期和薪资使用独立列,全部标准及自定义变量同时保存在 VariablesJson 中。
|
||||
/// </summary>
|
||||
[SugarTable("ob_employee_contract_snapshot", TableDescription = "员工合同快照")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObEmployeeContractSnapshot : BaseEntity
|
||||
{
|
||||
/// <summary>正式员工 Id。</summary>
|
||||
public long EmployeeId { get; set; }
|
||||
|
||||
/// <summary>来源入职任务 Id。</summary>
|
||||
public long TaskId { get; set; }
|
||||
|
||||
/// <summary>来源任务合同 Id,同一任务合同只允许生成一份员工快照。</summary>
|
||||
public long TaskContractId { get; set; }
|
||||
|
||||
/// <summary>合同模板 Id。</summary>
|
||||
public long TemplateId { get; set; }
|
||||
|
||||
/// <summary>合同模板版本 Id。</summary>
|
||||
public long TemplateVersionId { get; set; }
|
||||
|
||||
/// <summary>合同名称快照。</summary>
|
||||
public string ContractName { get; set; }
|
||||
|
||||
/// <summary>合同类型快照。</summary>
|
||||
public string ContractType { get; set; }
|
||||
|
||||
/// <summary>合同开始日期。</summary>
|
||||
public DateTime? ContractStartDate { get; set; }
|
||||
|
||||
/// <summary>合同结束日期。</summary>
|
||||
public DateTime? ContractEndDate { get; set; }
|
||||
|
||||
/// <summary>合同期限原始文本。</summary>
|
||||
public string ContractTerm { get; set; }
|
||||
|
||||
/// <summary>试用期开始日期。</summary>
|
||||
public DateTime? ProbationStartDate { get; set; }
|
||||
|
||||
/// <summary>试用期结束日期。</summary>
|
||||
public DateTime? ProbationEndDate { get; set; }
|
||||
|
||||
/// <summary>试用期期限原始文本。</summary>
|
||||
public string ProbationTerm { get; set; }
|
||||
|
||||
/// <summary>明确使用 hr.ProbationSalary 保存的试用薪资;不从其他工资项目推算。</summary>
|
||||
public decimal? ProbationSalary { get; set; }
|
||||
|
||||
/// <summary>基本工资。</summary>
|
||||
public decimal? BaseSalary { get; set; }
|
||||
|
||||
/// <summary>岗位工资。</summary>
|
||||
public decimal? PositionSalary { get; set; }
|
||||
|
||||
/// <summary>绩效津贴。</summary>
|
||||
public decimal? PerformanceAllowance { get; set; }
|
||||
|
||||
/// <summary>全勤奖。</summary>
|
||||
public decimal? AttendanceBonus { get; set; }
|
||||
|
||||
/// <summary>转正后薪资。</summary>
|
||||
public decimal? RegularSalary { get; set; }
|
||||
|
||||
/// <summary>工作地点或城市。</summary>
|
||||
public string WorkCity { get; set; }
|
||||
|
||||
/// <summary>合同中的岗位名称。</summary>
|
||||
public string PositionName { get; set; }
|
||||
|
||||
/// <summary>合同中的员工工号。</summary>
|
||||
public string EmployeeNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 以 VariableCode 为键保存该合同当时的全部变量元数据和值。
|
||||
/// 导出时通过稳定编码判断变量是否存在,不依赖可修改的中文名称。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = "json", IsNullable = false)]
|
||||
public string VariablesJson { get; set; }
|
||||
|
||||
/// <summary>快照结构版本。</summary>
|
||||
public int SnapshotVersion { get; set; } = 1;
|
||||
|
||||
/// <summary>快照生成时间。</summary>
|
||||
public DateTime SnapshotAt { get; set; }
|
||||
}
|
||||
@@ -33,6 +33,11 @@ public class ObOnboardingTask : BaseEntity
|
||||
/// </summary>
|
||||
public long PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用工类型:full_time 正式用工,part_time 兼职用工。
|
||||
/// </summary>
|
||||
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
|
||||
|
||||
/// <summary>
|
||||
/// 创建任务时锁定的入职模板 Id。
|
||||
/// </summary>
|
||||
@@ -106,4 +111,4 @@ public class ObOnboardingTask : BaseEntity
|
||||
/// </summary>
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public string OnboardingTemplateName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public static class OnboardingTaskStatus
|
||||
public const string Rejected = "rejected";
|
||||
|
||||
/// <summary>
|
||||
/// 已通过,系统已经生成正式员工档案。
|
||||
/// 已通过,系统已经生成员工档案。
|
||||
/// </summary>
|
||||
public const string Approved = "approved";
|
||||
|
||||
@@ -128,12 +128,138 @@ public static class TaskContractStatus
|
||||
/// </summary>
|
||||
public const string OfflinePendingSign = "offline_pending_sign";
|
||||
|
||||
/// <summary>
|
||||
/// 已发起第三方电子签,等待员工签署或企业盖章。
|
||||
/// </summary>
|
||||
public const string ElectronicSigning = "electronic_signing";
|
||||
|
||||
/// <summary>
|
||||
/// 第三方电子签已完成,签署文件正在或已经归档。
|
||||
/// </summary>
|
||||
public const string ElectronicSigned = "electronic_signed";
|
||||
|
||||
/// <summary>
|
||||
/// 已归档,任务流程结束且历史数据只读。
|
||||
/// </summary>
|
||||
public const string Archived = "archived";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工用工类型。
|
||||
/// </summary>
|
||||
public static class EmployeeEmploymentType
|
||||
{
|
||||
public const string FullTime = "full_time";
|
||||
public const string PartTime = "part_time";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工转正状态,仅由后台人事操作改变,不根据合同日期自动推算。
|
||||
/// </summary>
|
||||
public static class EmployeeRegularizationStatus
|
||||
{
|
||||
public const string Probation = "probation";
|
||||
public const string Regular = "regular";
|
||||
public const string NotApplicable = "not_applicable";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工社保状态。
|
||||
/// </summary>
|
||||
public static class EmployeeSocialInsuranceStatus
|
||||
{
|
||||
public const string Unknown = "unknown";
|
||||
public const string NotPurchased = "not_purchased";
|
||||
public const string Purchased = "purchased";
|
||||
public const string Stopped = "stopped";
|
||||
public const string NotApplicable = "not_applicable";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工人事变更类型。
|
||||
/// </summary>
|
||||
public static class EmployeeChangeType
|
||||
{
|
||||
public const string HrInfoUpdate = "hr_info_update";
|
||||
public const string Regularization = "regularization";
|
||||
public const string RegularizationRevoke = "regularization_revoke";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工附件业务类型。
|
||||
/// </summary>
|
||||
public static class EmployeeAttachmentBusinessType
|
||||
{
|
||||
public const string Regularization = "regularization";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 电子签服务商。
|
||||
/// </summary>
|
||||
public static class ElectronicSignProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 契约锁开放平台。
|
||||
/// </summary>
|
||||
public const string Qiyuesuo = "qiyuesuo";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入职电子签流程状态。
|
||||
/// </summary>
|
||||
public static class ElectronicSignStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 正在调用第三方创建流程。
|
||||
/// </summary>
|
||||
public const string Creating = "creating";
|
||||
|
||||
/// <summary>
|
||||
/// 已创建草稿,尚未成功发送。
|
||||
/// </summary>
|
||||
public const string Draft = "draft";
|
||||
|
||||
/// <summary>
|
||||
/// 已发送,等待员工签署。
|
||||
/// </summary>
|
||||
public const string PendingEmployeeSign = "pending_employee_sign";
|
||||
|
||||
/// <summary>
|
||||
/// 员工已签,等待后台管理员核对文件并确认企业盖章。
|
||||
/// </summary>
|
||||
public const string PendingCompanySeal = "pending_company_seal";
|
||||
|
||||
/// <summary>
|
||||
/// 所有签署已完成,等待文件归档。
|
||||
/// </summary>
|
||||
public const string Completed = "completed";
|
||||
|
||||
/// <summary>
|
||||
/// 签署文件和签署日志已经归档。
|
||||
/// </summary>
|
||||
public const string Archived = "archived";
|
||||
|
||||
/// <summary>
|
||||
/// 流程被拒签。
|
||||
/// </summary>
|
||||
public const string Rejected = "rejected";
|
||||
|
||||
/// <summary>
|
||||
/// 流程已作废。
|
||||
/// </summary>
|
||||
public const string Invalid = "invalid";
|
||||
|
||||
/// <summary>
|
||||
/// 流程已过期。
|
||||
/// </summary>
|
||||
public const string Expired = "expired";
|
||||
|
||||
/// <summary>
|
||||
/// 创建、发送、同步或归档失败,可由管理员重试。
|
||||
/// </summary>
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 合同变量类型。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁开放平台连接及入职电子签业务配置。
|
||||
/// </summary>
|
||||
public class QiyuesuoSettingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用契约锁电子签功能。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁开放平台地址;测试环境为 https://openapi.qiyuesuo.cn。
|
||||
/// </summary>
|
||||
public string ServerUrl { get; set; } = "https://openapi.qiyuesuo.cn";
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁应用 App Token。
|
||||
/// </summary>
|
||||
public string AppToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁应用 App Secret。
|
||||
/// </summary>
|
||||
public string AppSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签名版本;留空使用兼容性最好的 MD5,设置 HMACSHA256 使用新版签名。
|
||||
/// </summary>
|
||||
public string SignatureVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业成员中的合同创建人姓名。
|
||||
/// </summary>
|
||||
public string CreatorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业成员中的合同创建人手机号。
|
||||
/// </summary>
|
||||
public string CreatorMobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同状态回调地址。
|
||||
/// </summary>
|
||||
public string CallbackUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工从契约锁签署页面返回时跳转的前端地址;可为空。
|
||||
/// </summary>
|
||||
public string SignPageCallbackUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工签署页链接有效分钟数。
|
||||
/// </summary>
|
||||
public int SignUrlExpireMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// DOCX 中定位员工签名位置的关键字。
|
||||
/// </summary>
|
||||
public string EmployeeSignKeyword { get; set; } = "乙方(劳动者)签字:";
|
||||
|
||||
/// <summary>
|
||||
/// 员工签名关键字在单份文档中重复时使用第几个,从 1 开始。
|
||||
/// </summary>
|
||||
public int EmployeeSignKeywordIndex { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// DOCX 中定位企业盖章位置的关键字。
|
||||
/// </summary>
|
||||
public string CompanySealKeyword { get; set; } = "甲方(盖章):";
|
||||
|
||||
/// <summary>
|
||||
/// 企业盖章关键字在单份文档中重复时使用第几个,从 1 开始。
|
||||
/// </summary>
|
||||
public int CompanySealKeywordIndex { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 企业盖章相对关键字的横向偏移量,范围 (-1, 1),正数向右、负数向左。
|
||||
/// </summary>
|
||||
public decimal CompanySealOffsetX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 企业盖章相对关键字的纵向偏移量,范围 (-1, 1),正数向上、负数向下。
|
||||
/// </summary>
|
||||
public decimal CompanySealOffsetY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否在后台确认企业盖章时同时加盖骑缝章。
|
||||
/// </summary>
|
||||
public bool EnableAcrossPageSeal { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 骑缝章文档范围:ALL_DOCUMENT(全部文档合并)或 SINGLE_DOCUMENT(每份文档单独)。
|
||||
/// </summary>
|
||||
public string AcrossPageSealType { get; set; } = "ALL_DOCUMENT";
|
||||
|
||||
/// <summary>
|
||||
/// 单次 HTTP 请求超时秒数。
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 90;
|
||||
}
|
||||
@@ -8,14 +8,17 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IConfigService _config;
|
||||
private readonly IOnboardingContractSnapshotService _snapshotService;
|
||||
private readonly IOnboardingEmployeeContractSnapshotService _employeeContractSnapshotService;
|
||||
|
||||
public OnboardingAuditService(
|
||||
IConfigService config,
|
||||
IOnboardingContractSnapshotService snapshotService)
|
||||
IOnboardingContractSnapshotService snapshotService,
|
||||
IOnboardingEmployeeContractSnapshotService employeeContractSnapshotService)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_config = config;
|
||||
_snapshotService = snapshotService;
|
||||
_employeeContractSnapshotService = employeeContractSnapshotService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -51,6 +54,13 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
employee.CompanyId = task.CompanyId;
|
||||
employee.DepartmentId = task.DepartmentId;
|
||||
employee.PositionId = task.PositionId;
|
||||
employee.EmploymentType = string.IsNullOrWhiteSpace(task.EmploymentType)
|
||||
? EmployeeEmploymentType.FullTime
|
||||
: task.EmploymentType;
|
||||
employee.RegularizationStatus = employee.EmploymentType == EmployeeEmploymentType.PartTime
|
||||
? EmployeeRegularizationStatus.NotApplicable
|
||||
: EmployeeRegularizationStatus.Probation;
|
||||
employee.SocialInsuranceStatus = EmployeeSocialInsuranceStatus.Unknown;
|
||||
if (task.DepartmentId.HasValue)
|
||||
{
|
||||
var dept = await _db.Queryable<ObDepartment>().InSingleAsync(task.DepartmentId.Value);
|
||||
@@ -96,6 +106,7 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
|
||||
await _snapshotService.Build(task.Id, employeeInfo);
|
||||
await _db.Insertable(employee).ExecuteCommandAsync();
|
||||
await _employeeContractSnapshotService.Build(employee.Id, task.Id);
|
||||
|
||||
task.EmployeeId = employee.Id;
|
||||
task.TaskStatus = OnboardingTaskStatus.Approved;
|
||||
@@ -197,7 +208,7 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (normalizedValues.Count == 0)
|
||||
throw Oops.Bah("签署材料参数“合同开始日期”尚未填写,无法生成正式员工档案");
|
||||
throw Oops.Bah("签署材料参数“合同开始日期”尚未填写,无法生成员工档案");
|
||||
|
||||
var parsedDates = new List<DateTime>();
|
||||
foreach (var value in normalizedValues)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 统一校验入职业务中的可编辑编码,避免非法字符或超长内容落到数据库后才报 500。
|
||||
/// </summary>
|
||||
internal static class OnboardingCodeValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// 规范化并校验业务编码。编码必须以字母开头,可包含字母、数字、点、下划线和短横线。
|
||||
/// </summary>
|
||||
public static string Normalize(string code, string fieldName, int maxLength)
|
||||
{
|
||||
var normalized = code?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
throw Oops.Bah($"{fieldName}不能为空");
|
||||
|
||||
if (normalized.Length > maxLength)
|
||||
throw Oops.Bah($"{fieldName}长度不能超过 {maxLength} 个字符");
|
||||
|
||||
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
normalized,
|
||||
@"^[A-Za-z][A-Za-z0-9._-]*$"))
|
||||
{
|
||||
throw Oops.Bah($"{fieldName}必须以字母开头,只能包含字母、数字、点、下划线和短横线");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
|
||||
/// </summary>
|
||||
public async Task Add(OnboardingCompanyAddInput input)
|
||||
{
|
||||
NormalizeAndValidateQiyuesuoConfig(input);
|
||||
await EnsureCodeUnique(input.CompanyCode, 0);
|
||||
await InsertAsync(input.Adapt<ObCompany>());
|
||||
}
|
||||
@@ -52,6 +53,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
|
||||
/// </summary>
|
||||
public async Task Edit(OnboardingCompanyEditInput input)
|
||||
{
|
||||
NormalizeAndValidateQiyuesuoConfig(input);
|
||||
await Detail(new BaseIdInput { Id = input.Id });
|
||||
await EnsureCodeUnique(input.CompanyCode, input.Id);
|
||||
await UpdateAsync(input.Adapt<ObCompany>());
|
||||
@@ -98,6 +100,30 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
|
||||
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
|
||||
}
|
||||
|
||||
private static void NormalizeAndValidateQiyuesuoConfig(ObCompany company)
|
||||
{
|
||||
company.CompanyCode = OnboardingCodeValidator.Normalize(company.CompanyCode, "公司编码", 50);
|
||||
company.CompanyName = company.CompanyName?.Trim();
|
||||
company.LegalName = company.LegalName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(company.CompanyName))
|
||||
throw Oops.Bah("公司名称不能为空");
|
||||
|
||||
company.QiyuesuoCategoryId = company.QiyuesuoCategoryId?.Trim();
|
||||
company.QiyuesuoSealId = company.QiyuesuoSealId?.Trim();
|
||||
|
||||
var hasCategoryId = !string.IsNullOrWhiteSpace(company.QiyuesuoCategoryId);
|
||||
var hasSealId = !string.IsNullOrWhiteSpace(company.QiyuesuoSealId);
|
||||
if (hasCategoryId != hasSealId)
|
||||
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写");
|
||||
if (!hasCategoryId)
|
||||
return;
|
||||
if (!long.TryParse(company.QiyuesuoCategoryId, out _)
|
||||
|| !long.TryParse(company.QiyuesuoSealId, out _))
|
||||
{
|
||||
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写数字");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureCodeUnique(string code, long id)
|
||||
{
|
||||
var exists = await Context.Queryable<ObCompany>()
|
||||
|
||||
+60
@@ -11,3 +11,63 @@ public class OnboardingGenerateContractInput
|
||||
[IdNotNull(ErrorMessage = "任务合同Id不能为空")]
|
||||
public long TaskContractId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 人事在创建入职任务前下载合同预览的参数。
|
||||
/// </summary>
|
||||
public class OnboardingContractPreviewInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 入职业务公司 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "公司Id不能为空")]
|
||||
public long CompanyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务部门 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "部门Id不能为空")]
|
||||
public long DepartmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入职岗位 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "岗位Id不能为空")]
|
||||
public long PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前选择的入职模板 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "入职模板Id不能为空")]
|
||||
public long OnboardingTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次需要预览的合同模板版本 Id;批量预览时无需传入。
|
||||
/// </summary>
|
||||
public long? ContractTemplateVersionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 人事当前在创建表单中填写的合同变量。
|
||||
/// </summary>
|
||||
public List<OnboardingInitialContractVariableInput> ContractVariables
|
||||
{
|
||||
get;
|
||||
set;
|
||||
} = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建任务前生成的临时 DOCX 预览文件。
|
||||
/// </summary>
|
||||
public class OnboardingContractPreviewOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// DOCX 文件内容,仅通过当前请求返回,不保存到文件表或 OSS。
|
||||
/// </summary>
|
||||
public byte[] FileBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 浏览器下载时使用的文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
}
|
||||
|
||||
+12
@@ -5,6 +5,18 @@ namespace SimpleAdmin.Application;
|
||||
/// </summary>
|
||||
public interface IOnboardingContractDocumentService : ITransient
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建入职任务前,根据人事当前填写的参数生成临时 DOCX 预览。
|
||||
/// </summary>
|
||||
Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
|
||||
OnboardingContractPreviewInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 创建入职任务前,按模板顺序生成全部合同预览并打包为 ZIP。
|
||||
/// </summary>
|
||||
Task<OnboardingContractPreviewOutput> PreviewAllBeforeCreate(
|
||||
OnboardingContractPreviewInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 根据已审核通过的任务合同生成并保存 DOCX 文件。
|
||||
/// </summary>
|
||||
|
||||
+318
-1
@@ -14,6 +14,281 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
|
||||
_fileService = fileService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在任务创建前生成指定合同的临时 DOCX,仅通过响应返回,不写文件表和 OSS。
|
||||
/// </summary>
|
||||
public async Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
|
||||
OnboardingContractPreviewInput input)
|
||||
{
|
||||
var context = await LoadPreviewContext(input);
|
||||
var contractConfig = input.ContractTemplateVersionId.HasValue
|
||||
? context.ContractConfigs.FirstOrDefault(item =>
|
||||
item.ContractTemplateVersionId == input.ContractTemplateVersionId.Value)
|
||||
: context.ContractConfigs.FirstOrDefault();
|
||||
if (contractConfig == null)
|
||||
throw Oops.Bah("所选合同不属于当前入职模板或已被移除");
|
||||
|
||||
return await RenderPreview(input, context, contractConfig, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在任务创建前生成全部合同预览,并按模板顺序打包为 ZIP。
|
||||
/// </summary>
|
||||
public async Task<OnboardingContractPreviewOutput> PreviewAllBeforeCreate(
|
||||
OnboardingContractPreviewInput input)
|
||||
{
|
||||
var context = await LoadPreviewContext(input);
|
||||
using var zipBytes = new MemoryStream();
|
||||
using (var archive = new global::System.IO.Compression.ZipArchive(
|
||||
zipBytes,
|
||||
global::System.IO.Compression.ZipArchiveMode.Create,
|
||||
true))
|
||||
{
|
||||
for (var index = 0; index < context.ContractConfigs.Count; index++)
|
||||
{
|
||||
var preview = await RenderPreview(
|
||||
input,
|
||||
context,
|
||||
context.ContractConfigs[index],
|
||||
index + 1);
|
||||
var entry = archive.CreateEntry(
|
||||
preview.FileName,
|
||||
global::System.IO.Compression.CompressionLevel.Fastest);
|
||||
await using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(preview.FileBytes);
|
||||
}
|
||||
}
|
||||
|
||||
return new OnboardingContractPreviewOutput
|
||||
{
|
||||
FileBytes = zipBytes.ToArray(),
|
||||
FileName = $"全部合同_人事预览_{DateTime.Now:yyyyMMddHHmmss}.zip"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验预览上下文,并读取入职模板锁定的全部合同配置。
|
||||
/// </summary>
|
||||
private async Task<ContractPreviewContext> LoadPreviewContext(
|
||||
OnboardingContractPreviewInput input)
|
||||
{
|
||||
var company = await _db.Queryable<ObCompany>()
|
||||
.FirstAsync(item => item.Id == input.CompanyId
|
||||
&& item.Status == CommonStatusConst.ENABLE)
|
||||
?? throw Oops.Bah("公司不存在或未启用");
|
||||
|
||||
var departmentExists = await _db.Queryable<ObDepartment>()
|
||||
.AnyAsync(item => item.Id == input.DepartmentId
|
||||
&& item.CompanyId == input.CompanyId
|
||||
&& item.Status == CommonStatusConst.ENABLE);
|
||||
if (!departmentExists)
|
||||
throw Oops.Bah("部门不存在、未启用或不属于该公司");
|
||||
|
||||
var positionExists = await _db.Queryable<ObPosition>()
|
||||
.AnyAsync(item => item.Id == input.PositionId
|
||||
&& item.CompanyId == input.CompanyId
|
||||
&& item.DepartmentId == input.DepartmentId
|
||||
&& item.Status == CommonStatusConst.ENABLE);
|
||||
if (!positionExists)
|
||||
throw Oops.Bah("岗位不存在、未启用或不属于该部门/公司");
|
||||
|
||||
var onboardingTemplate = await _db.Queryable<ObOnboardingTemplate>()
|
||||
.FirstAsync(item => item.Id == input.OnboardingTemplateId
|
||||
&& item.CompanyId == input.CompanyId
|
||||
&& item.DepartmentId == input.DepartmentId
|
||||
&& item.Status == CommonStatusConst.ENABLE)
|
||||
?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司、部门");
|
||||
|
||||
var supportsPosition = await _db.Queryable<ObOnboardingTemplatePosition>()
|
||||
.AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id
|
||||
&& item.PositionId == input.PositionId);
|
||||
if (!supportsPosition)
|
||||
throw Oops.Bah("所选入职模板不适用于该岗位");
|
||||
|
||||
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
|
||||
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync();
|
||||
if (contractConfigs.Count == 0)
|
||||
throw Oops.Bah("入职模板尚未配置合同文件");
|
||||
|
||||
var duplicateVersion = contractConfigs
|
||||
.GroupBy(item => item.ContractTemplateVersionId)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicateVersion != null)
|
||||
throw Oops.Bah("入职模板中存在重复的合同版本,请先调整模板配置");
|
||||
|
||||
var templateIds = contractConfigs
|
||||
.Select(item => item.ContractTemplateId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var contractTemplates = await _db.Queryable<ObContractTemplate>()
|
||||
.Where(item => templateIds.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (contractTemplates.Count != templateIds.Count
|
||||
|| contractTemplates.Any(item => item.CompanyId != input.CompanyId
|
||||
|| item.DepartmentId != input.DepartmentId
|
||||
|| !string.Equals(
|
||||
item.Status,
|
||||
CommonStatusConst.ENABLE,
|
||||
global::System.StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板");
|
||||
}
|
||||
|
||||
var supportedTemplateCount = await _db.Queryable<ObContractTemplatePosition>()
|
||||
.CountAsync(item => templateIds.Contains(item.ContractTemplateId)
|
||||
&& item.PositionId == input.PositionId);
|
||||
if (supportedTemplateCount != templateIds.Count)
|
||||
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
|
||||
|
||||
var versionIds = contractConfigs
|
||||
.Select(item => item.ContractTemplateVersionId)
|
||||
.ToList();
|
||||
var versions = await _db.Queryable<ObContractTemplateVersion>()
|
||||
.Where(item => versionIds.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
var versionsValid = versions.Count == versionIds.Count
|
||||
&& contractConfigs.All(config => versions.Any(version =>
|
||||
version.Id == config.ContractTemplateVersionId
|
||||
&& version.TemplateId == config.ContractTemplateId
|
||||
&& version.VersionStatus == ContractTemplateVersionStatus.Enabled));
|
||||
if (!versionsValid)
|
||||
throw Oops.Bah("入职模板包含不存在、未启用或归属错误的合同版本");
|
||||
|
||||
var inputs = input.ContractVariables
|
||||
?? new List<OnboardingInitialContractVariableInput>();
|
||||
OnboardingTaskService.ValidateInitialContractDateRanges(inputs);
|
||||
var duplicateInput = inputs
|
||||
.GroupBy(item => new
|
||||
{
|
||||
item.ContractTemplateVersionId,
|
||||
VariableCode = item.VariableCode?.ToUpperInvariant()
|
||||
})
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicateInput != null)
|
||||
throw Oops.Bah($"合同变量“{duplicateInput.Key.VariableCode}”重复提交");
|
||||
var unknownVersionInput = inputs.FirstOrDefault(item =>
|
||||
!versionIds.Contains(item.ContractTemplateVersionId));
|
||||
if (unknownVersionInput != null)
|
||||
throw Oops.Bah($"合同变量“{unknownVersionInput.VariableCode}”不属于当前入职模板");
|
||||
|
||||
return new ContractPreviewContext
|
||||
{
|
||||
Company = company,
|
||||
ContractConfigs = contractConfigs,
|
||||
ContractTemplates = contractTemplates,
|
||||
Versions = versions
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据一条合同配置和当前人事参数渲染一份 DOCX。
|
||||
/// </summary>
|
||||
private async Task<OnboardingContractPreviewOutput> RenderPreview(
|
||||
OnboardingContractPreviewInput input,
|
||||
ContractPreviewContext context,
|
||||
ObOnboardingTemplateContract contractConfig,
|
||||
int? fileOrder)
|
||||
{
|
||||
var contractTemplate = context.ContractTemplates.First(item =>
|
||||
item.Id == contractConfig.ContractTemplateId);
|
||||
var version = context.Versions.First(item =>
|
||||
item.Id == contractConfig.ContractTemplateVersionId);
|
||||
var definitions = await _db.Queryable<ObTemplateVariable>()
|
||||
.Where(item => item.TemplateVersionId == version.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.ToListAsync();
|
||||
var duplicateDefinition = definitions
|
||||
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicateDefinition != null)
|
||||
throw Oops.Bah($"合同变量“{duplicateDefinition.Key}”存在重复定义");
|
||||
|
||||
var inputs = (input.ContractVariables
|
||||
?? new List<OnboardingInitialContractVariableInput>())
|
||||
.Where(item => item.ContractTemplateVersionId == version.Id)
|
||||
.ToList();
|
||||
var editableDefinitions = definitions
|
||||
.Where(IsHrEditableDefinition)
|
||||
.ToList();
|
||||
foreach (var submitted in inputs)
|
||||
{
|
||||
var definitionExists = editableDefinitions.Any(definition =>
|
||||
string.Equals(
|
||||
definition.VariableCode,
|
||||
submitted.VariableCode,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (!definitionExists)
|
||||
throw Oops.Bah($"合同变量“{submitted.VariableCode}”不存在或不允许人事填写");
|
||||
}
|
||||
|
||||
var missingRequired = editableDefinitions.FirstOrDefault(definition =>
|
||||
{
|
||||
if (!definition.Required)
|
||||
return false;
|
||||
|
||||
var submitted = inputs.FirstOrDefault(item =>
|
||||
string.Equals(
|
||||
item.VariableCode,
|
||||
definition.VariableCode,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
return string.IsNullOrWhiteSpace(
|
||||
submitted?.VariableValue ?? definition.DefaultValue);
|
||||
});
|
||||
if (missingRequired != null)
|
||||
throw Oops.Bah($"请填写合同变量“{missingRequired.VariableName}”");
|
||||
|
||||
var previewTask = new ObOnboardingTask
|
||||
{
|
||||
TaskNo = "【发起后自动生成】",
|
||||
CompanyId = input.CompanyId,
|
||||
DepartmentId = input.DepartmentId,
|
||||
PositionId = input.PositionId,
|
||||
OnboardingTemplateId = input.OnboardingTemplateId
|
||||
};
|
||||
var emptyDynamicValues = new Dictionary<string, string>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var values = definitions.ToDictionary(
|
||||
definition => definition.VariableCode,
|
||||
definition =>
|
||||
{
|
||||
var submitted = inputs.FirstOrDefault(item =>
|
||||
string.Equals(
|
||||
item.VariableCode,
|
||||
definition.VariableCode,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (submitted != null)
|
||||
return submitted.VariableValue ?? string.Empty;
|
||||
|
||||
return OnboardingContractSnapshotService.ResolveValue(
|
||||
definition,
|
||||
null,
|
||||
emptyDynamicValues,
|
||||
context.Company,
|
||||
previewTask,
|
||||
true)
|
||||
?? string.Empty;
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var source = await _fileService.Download(
|
||||
new BaseIdInput { Id = version.SourceDocxFileId })
|
||||
?? throw Oops.Bah("无法读取合同源文件");
|
||||
await using var sourceStream = source.FileStream;
|
||||
using var sourceBytes = new MemoryStream();
|
||||
await sourceStream.CopyToAsync(sourceBytes);
|
||||
|
||||
return new OnboardingContractPreviewOutput
|
||||
{
|
||||
FileBytes = DocxTemplateProcessor.Render(
|
||||
sourceBytes.ToArray(),
|
||||
values),
|
||||
FileName = BuildPreviewFileName(contractTemplate.TemplateName, fileOrder)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
|
||||
/// </summary>
|
||||
@@ -26,7 +301,8 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
|
||||
var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Rejected
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Reviewing
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Approved;
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Approved
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Archived;
|
||||
if (!statusAllowsGeneration)
|
||||
throw Oops.Bah("当前任务状态不能生成合同");
|
||||
if (contract.GeneratedDocxFileId.HasValue)
|
||||
@@ -86,4 +362,45 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
|
||||
await _db.Updateable(contract).ExecuteCommandAsync();
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断变量是否允许人事在发起任务前填写。
|
||||
/// </summary>
|
||||
private static bool IsHrEditableDefinition(ObTemplateVariable definition)
|
||||
{
|
||||
return definition.HrEditable
|
||||
&& (definition.VariableType == "hr"
|
||||
|| definition.VariableType == ContractVariableType.Contract
|
||||
|| definition.VariableType == ContractVariableType.Company
|
||||
|| definition.VariableCode.StartsWith(
|
||||
"hr.",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成兼容 Windows 和 Linux 的合同预览下载文件名。
|
||||
/// </summary>
|
||||
private static string BuildPreviewFileName(string contractName, int? fileOrder = null)
|
||||
{
|
||||
var invalidChars = global::System.IO.Path.GetInvalidFileNameChars();
|
||||
var safeName = new string((contractName ?? "合同")
|
||||
.Select(character => invalidChars.Contains(character) ? '_' : character)
|
||||
.ToArray());
|
||||
var orderPrefix = fileOrder.HasValue ? $"{fileOrder.Value:D2}_" : string.Empty;
|
||||
return $"{orderPrefix}{safeName}_人事预览_{DateTime.Now:yyyyMMddHHmmss}.docx";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建任务前预览合同所需的已校验数据。
|
||||
/// </summary>
|
||||
private sealed class ContractPreviewContext
|
||||
{
|
||||
public ObCompany Company { get; init; }
|
||||
|
||||
public List<ObOnboardingTemplateContract> ContractConfigs { get; init; }
|
||||
|
||||
public List<ObContractTemplate> ContractTemplates { get; init; }
|
||||
|
||||
public List<ObContractTemplateVersion> Versions { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
+17
-5
@@ -110,12 +110,13 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveValue(
|
||||
internal static string ResolveValue(
|
||||
ObTemplateVariable definition,
|
||||
ObTaskEmployeeInfo employeeInfo,
|
||||
IReadOnlyDictionary<string, string> dynamicValues,
|
||||
ObCompany company,
|
||||
ObOnboardingTask task)
|
||||
ObOnboardingTask task,
|
||||
bool useEmployeePlaceholder = false)
|
||||
{
|
||||
var sourceField = GetSourceField(definition);
|
||||
if (definition.VariableType == ContractVariableType.Employee)
|
||||
@@ -133,7 +134,15 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
|
||||
if (doc.RootElement.TryGetProperty(sourceField, out var prop)
|
||||
|| doc.RootElement.TryGetProperty(definition.VariableCode, out prop))
|
||||
{
|
||||
var str = prop.GetString();
|
||||
var str = prop.ValueKind switch
|
||||
{
|
||||
global::System.Text.Json.JsonValueKind.String => prop.GetString(),
|
||||
global::System.Text.Json.JsonValueKind.Number => prop.ToString(),
|
||||
global::System.Text.Json.JsonValueKind.True => bool.TrueString,
|
||||
global::System.Text.Json.JsonValueKind.False => bool.FalseString,
|
||||
global::System.Text.Json.JsonValueKind.Null => null,
|
||||
_ => throw Oops.Bah($"员工补充变量“{definition.VariableName}”格式不正确")
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(str)) return str;
|
||||
}
|
||||
}
|
||||
@@ -143,8 +152,11 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
|
||||
}
|
||||
}
|
||||
|
||||
return dynamicValues.TryGetValue(sourceField, out var dynamicValue)
|
||||
? dynamicValue
|
||||
if (dynamicValues.TryGetValue(sourceField, out var dynamicValue))
|
||||
return dynamicValue;
|
||||
|
||||
return useEmployeePlaceholder
|
||||
? $"【待员工填写:{definition.VariableName ?? definition.VariableCode}】"
|
||||
: definition.DefaultValue;
|
||||
}
|
||||
|
||||
|
||||
+55
-18
@@ -17,25 +17,39 @@ public static class DocxTemplateProcessor
|
||||
/// </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)))
|
||||
ValidateDocxHeader(docx);
|
||||
try
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
using var input = new MemoryStream(docx);
|
||||
using var archive = new global::System.IO.Compression.ZipArchive(
|
||||
input,
|
||||
global::System.IO.Compression.ZipArchiveMode.Read);
|
||||
if (archive.GetEntry("word/document.xml") == null)
|
||||
throw Oops.Bah("上传文件不是有效的 DOCX 文档,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
|
||||
|
||||
return codes.OrderBy(code => code).ToList();
|
||||
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();
|
||||
}
|
||||
catch (global::System.IO.InvalidDataException)
|
||||
{
|
||||
throw Oops.Bah("DOCX 文件结构已经损坏或上传不完整,请重新另存为 DOCX 后上传");
|
||||
}
|
||||
catch (global::System.Xml.XmlException)
|
||||
{
|
||||
throw Oops.Bah("DOCX 文件内部内容损坏,Word 可以尝试修复该文件;请修复并重新另存为 DOCX 后上传");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,6 +57,7 @@ public static class DocxTemplateProcessor
|
||||
/// </summary>
|
||||
public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
ValidateDocxHeader(docx);
|
||||
using var input = new MemoryStream(docx);
|
||||
using var archive = new global::System.IO.Compression.ZipArchive(
|
||||
input,
|
||||
@@ -157,4 +172,26 @@ public static class DocxTemplateProcessor
|
||||
|| entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase))
|
||||
&& entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据文件头识别真实格式,避免只修改扩展名的旧版 DOC 在解压时变成系统异常。
|
||||
/// </summary>
|
||||
private static void ValidateDocxHeader(byte[] docx)
|
||||
{
|
||||
if (docx == null || docx.Length < 4)
|
||||
throw Oops.Bah("上传的 DOCX 文件为空或上传不完整,请重新选择文件上传");
|
||||
|
||||
// 旧版 Word 97-2003 二进制 DOC 使用 OLE Compound File 文件头:D0 CF 11 E0。
|
||||
if (docx[0] == 0xD0
|
||||
&& docx[1] == 0xCF
|
||||
&& docx[2] == 0x11
|
||||
&& docx[3] == 0xE0)
|
||||
{
|
||||
throw Oops.Bah("上传文件实际是旧版 DOC 格式,不能直接修改后缀名;请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
|
||||
}
|
||||
|
||||
// DOCX 是 Open XML ZIP 包,正常文件以 PK 开头。
|
||||
if (docx[0] != 0x50 || docx[1] != 0x4B)
|
||||
throw Oops.Bah("上传文件的真实格式不是 DOCX,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -37,6 +37,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
|
||||
var page = await query
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
|
||||
.OrderByDescending(it => it.CreateTime)
|
||||
.OrderBy(it => it.TemplateName)
|
||||
.ToPagedListAsync(input.PageNum, input.PageSize);
|
||||
await PopulateScopeDisplay(page.List.ToList());
|
||||
@@ -449,13 +450,12 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
/// </summary>
|
||||
private static void NormalizeInput(OnboardingContractTemplateAddInput input)
|
||||
{
|
||||
input.TemplateCode = input.TemplateCode?.Trim();
|
||||
input.TemplateCode = OnboardingCodeValidator.Normalize(input.TemplateCode, "签署材料模板编码", 50);
|
||||
input.TemplateName = input.TemplateName?.Trim();
|
||||
input.ContractType = input.ContractType?.Trim();
|
||||
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.TemplateCode)
|
||||
|| string.IsNullOrWhiteSpace(input.TemplateName)
|
||||
if (string.IsNullOrWhiteSpace(input.TemplateName)
|
||||
|| string.IsNullOrWhiteSpace(input.ContractType))
|
||||
{
|
||||
throw Oops.Bah("请填写模板编码、模板名称和合同类型");
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 校验任务合同变量的必填状态和员工动态变量数据类型。
|
||||
/// </summary>
|
||||
public interface IOnboardingContractVariableValidationService : ITransient
|
||||
{
|
||||
/// <summary>
|
||||
/// 校验指定任务的全部合同变量快照。
|
||||
/// </summary>
|
||||
Task Validate(long taskId);
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 统一校验任务合同变量,确保最终提交的数据可以安全用于合同生成。
|
||||
/// </summary>
|
||||
public class OnboardingContractVariableValidationService
|
||||
: IOnboardingContractVariableValidationService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
|
||||
public OnboardingContractVariableValidationService()
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Validate(long taskId)
|
||||
{
|
||||
var contracts = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == taskId)
|
||||
.ToListAsync();
|
||||
if (contracts.Count == 0)
|
||||
return;
|
||||
|
||||
var contractIds = contracts
|
||||
.Select(item => item.Id)
|
||||
.ToList();
|
||||
var templateVersionIds = contracts
|
||||
.Select(item => item.TemplateVersionId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var variables = await _db.Queryable<ObTaskContractVariable>()
|
||||
.Where(item => contractIds.Contains(item.TaskContractId))
|
||||
.ToListAsync();
|
||||
var definitions = await _db.Queryable<ObTemplateVariable>()
|
||||
.Where(item => templateVersionIds.Contains(item.TemplateVersionId))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var contract in contracts)
|
||||
{
|
||||
var contractDefinitions = definitions
|
||||
.Where(item => item.TemplateVersionId == contract.TemplateVersionId)
|
||||
.OrderBy(item => item.SortCode);
|
||||
|
||||
foreach (var definition in contractDefinitions)
|
||||
{
|
||||
var variable = variables.FirstOrDefault(item =>
|
||||
item.TaskContractId == contract.Id
|
||||
&& item.VariableCode == definition.VariableCode);
|
||||
var value = variable?.VariableValue?.Trim();
|
||||
|
||||
if (definition.Required && string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"合同“{contract.ContractName}”的必填变量“{definition.VariableName}”尚未填写");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value)
|
||||
|| definition.VariableType != ContractVariableType.Employee)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ValidateDataType(contract.ContractName, definition, value);
|
||||
ValidateOptions(contract.ContractName, definition, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDataType(
|
||||
string contractName,
|
||||
ObTemplateVariable definition,
|
||||
string value)
|
||||
{
|
||||
var dataType = definition.DataType?.Trim().ToLowerInvariant() ?? "string";
|
||||
switch (dataType)
|
||||
{
|
||||
case "string":
|
||||
return;
|
||||
case "number":
|
||||
var numberIsValid = decimal.TryParse(
|
||||
value,
|
||||
global::System.Globalization.NumberStyles.Number,
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
out _);
|
||||
if (!numberIsValid)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”必须填写数字");
|
||||
}
|
||||
|
||||
return;
|
||||
case "date":
|
||||
var dateIsValid = DateTime.TryParseExact(
|
||||
value,
|
||||
"yyyy-MM-dd",
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
global::System.Globalization.DateTimeStyles.None,
|
||||
out _);
|
||||
if (!dateIsValid)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”必须填写正确日期");
|
||||
}
|
||||
|
||||
return;
|
||||
default:
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”数据类型配置不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateOptions(
|
||||
string contractName,
|
||||
ObTemplateVariable definition,
|
||||
string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(definition.OptionsJson))
|
||||
return;
|
||||
|
||||
var options = ParseOptions(contractName, definition);
|
||||
if (options.Contains(value))
|
||||
return;
|
||||
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”必须从配置选项中选择");
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseOptions(
|
||||
string contractName,
|
||||
ObTemplateVariable definition)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = global::System.Text.Json.JsonDocument.Parse(
|
||||
definition.OptionsJson);
|
||||
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Array)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
var options = document.RootElement
|
||||
.EnumerateArray()
|
||||
.Select(GetOptionValue)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (options.Count == 0)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
return options;
|
||||
}
|
||||
catch (global::System.Text.Json.JsonException)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”可选项配置不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetOptionValue(
|
||||
global::System.Text.Json.JsonElement option)
|
||||
{
|
||||
if (option.ValueKind == global::System.Text.Json.JsonValueKind.String)
|
||||
return option.GetString()?.Trim();
|
||||
|
||||
if (option.ValueKind == global::System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
if (option.TryGetProperty("value", out var value))
|
||||
return GetJsonText(value);
|
||||
if (option.TryGetProperty("label", out var label))
|
||||
return GetJsonText(label);
|
||||
}
|
||||
|
||||
return GetJsonText(option);
|
||||
}
|
||||
|
||||
private static string GetJsonText(
|
||||
global::System.Text.Json.JsonElement value)
|
||||
{
|
||||
return value.ValueKind switch
|
||||
{
|
||||
global::System.Text.Json.JsonValueKind.String => value.GetString()?.Trim(),
|
||||
global::System.Text.Json.JsonValueKind.Number => value.ToString(),
|
||||
global::System.Text.Json.JsonValueKind.True => bool.TrueString,
|
||||
global::System.Text.Json.JsonValueKind.False => bool.FalseString,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
-4
@@ -133,12 +133,11 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
|
||||
/// </summary>
|
||||
private static void NormalizeInput(OnboardingDepartmentAddInput input)
|
||||
{
|
||||
input.DepartmentCode = input.DepartmentCode?.Trim();
|
||||
input.DepartmentCode = OnboardingCodeValidator.Normalize(input.DepartmentCode, "部门编码", 50);
|
||||
input.DepartmentName = input.DepartmentName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input.DepartmentCode)
|
||||
|| string.IsNullOrWhiteSpace(input.DepartmentName))
|
||||
if (string.IsNullOrWhiteSpace(input.DepartmentName))
|
||||
{
|
||||
throw Oops.Bah("请填写部门编码和部门名称");
|
||||
throw Oops.Bah("请填写部门名称");
|
||||
}
|
||||
|
||||
if (input.Status != "enable" && input.Status != "disable")
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 对指定入职任务执行电子签操作的参数。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignTaskInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 入职任务 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "入职任务Id不能为空")]
|
||||
public long TaskId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理员查看一份员工已签合同的参数。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignPreviewInput : OnboardingElectronicSignTaskInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 本地电子签文档 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "电子签文档Id不能为空")]
|
||||
public long SignDocumentId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 作废入职电子签流程的参数。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignInvalidInput : OnboardingElectronicSignTaskInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 作废原因。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "请填写作废原因")]
|
||||
public string Reason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重新发起入职电子签流程的参数。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignReinitiateInput : OnboardingElectronicSignTaskInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 管理员确认重新发起的原因,随旧流程保留用于审计。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "请填写重新发起原因")]
|
||||
public string Reason { get; set; }
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 管理端查看的入职电子签流程详情。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignDetailOutput
|
||||
{
|
||||
/// <summary>入职任务 Id。</summary>
|
||||
public long TaskId { get; set; }
|
||||
|
||||
/// <summary>入职任务编号。</summary>
|
||||
public string TaskNo { get; set; }
|
||||
|
||||
/// <summary>当前是否允许发起或重试电子签。</summary>
|
||||
public bool CanInitiate { get; set; }
|
||||
|
||||
/// <summary>不允许发起时的原因。</summary>
|
||||
public string CannotInitiateReason { get; set; }
|
||||
|
||||
/// <summary>当前流程是否允许作为一次新签约重新发起。</summary>
|
||||
public bool CanReinitiate { get; set; }
|
||||
|
||||
/// <summary>不允许重新发起时的原因。</summary>
|
||||
public string CannotReinitiateReason { get; set; }
|
||||
|
||||
/// <summary>是否已经存在电子签流程。</summary>
|
||||
public bool HasProcess { get; set; }
|
||||
|
||||
/// <summary>本地电子签流程 Id。</summary>
|
||||
public long? ProcessId { get; set; }
|
||||
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ProviderContractId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>电子签流程标题。</summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>本系统电子签状态。</summary>
|
||||
public string SignStatus { get; set; }
|
||||
|
||||
/// <summary>契约锁原始状态。</summary>
|
||||
public string ProviderStatus { get; set; }
|
||||
|
||||
/// <summary>员工姓名。</summary>
|
||||
public string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>员工手机号。</summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>员工签署页面地址。</summary>
|
||||
public string SignUrl { get; set; }
|
||||
|
||||
/// <summary>员工签署页面到期时间。</summary>
|
||||
public DateTime? SignUrlExpireAt { get; set; }
|
||||
|
||||
/// <summary>员工签署页面当前是否有效。</summary>
|
||||
public bool SignUrlValid { get; set; }
|
||||
|
||||
/// <summary>发起时间。</summary>
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
/// <summary>发送时间。</summary>
|
||||
public DateTime? SentAt { get; set; }
|
||||
|
||||
/// <summary>签署完成时间。</summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>归档完成时间。</summary>
|
||||
public DateTime? ArchivedAt { get; set; }
|
||||
|
||||
/// <summary>完整归档 ZIP 的系统文件 Id。</summary>
|
||||
public long? ArchiveFileId { get; set; }
|
||||
|
||||
|
||||
/// <summary>最近一次失败原因。</summary>
|
||||
public string LastError { get; set; }
|
||||
|
||||
/// <summary>签约流程包含的文档。</summary>
|
||||
public List<OnboardingElectronicSignDocumentOutput> Documents { get; set; } = new();
|
||||
|
||||
/// <summary>该入职任务已经结束的历史电子签流程。</summary>
|
||||
public List<OnboardingElectronicSignHistoryOutput> HistoryProcesses { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁员工已签文件只读预览结果。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignPreviewOutput
|
||||
{
|
||||
/// <summary>本地电子签文档 Id。</summary>
|
||||
public long SignDocumentId { get; set; }
|
||||
|
||||
/// <summary>契约锁生成的只读浏览页面地址。</summary>
|
||||
public string PreviewUrl { get; set; }
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 已被新流程替代的历史电子签流程摘要。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignHistoryOutput
|
||||
{
|
||||
/// <summary>本地电子签流程 Id。</summary>
|
||||
public long ProcessId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ProviderContractId { get; set; }
|
||||
|
||||
/// <summary>本系统电子签状态。</summary>
|
||||
public string SignStatus { get; set; }
|
||||
|
||||
/// <summary>契约锁原始状态。</summary>
|
||||
public string ProviderStatus { get; set; }
|
||||
|
||||
/// <summary>员工姓名快照。</summary>
|
||||
public string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>员工手机号快照。</summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>被新流程替代的时间。</summary>
|
||||
public DateTime? ReissuedAt { get; set; }
|
||||
|
||||
/// <summary>管理员填写的重新发起原因。</summary>
|
||||
public string ReissueReason { get; set; }
|
||||
|
||||
/// <summary>流程发起时间。</summary>
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
/// <summary>流程结束或失败原因。</summary>
|
||||
public string LastError { get; set; }
|
||||
}
|
||||
/// <summary>
|
||||
/// 电子签流程中的单份合同文档。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignDocumentOutput
|
||||
{
|
||||
/// <summary>本地电子签文档 Id。</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>任务合同 Id。</summary>
|
||||
public long TaskContractId { get; set; }
|
||||
|
||||
/// <summary>契约锁文档 Id。</summary>
|
||||
public string ProviderDocumentId { get; set; }
|
||||
|
||||
/// <summary>文档名称。</summary>
|
||||
public string DocumentName { get; set; }
|
||||
|
||||
/// <summary>文档顺序。</summary>
|
||||
public int DocumentSort { get; set; }
|
||||
|
||||
/// <summary>发起签约的原始 DOCX 文件 Id。</summary>
|
||||
public long OriginalDocxFileId { get; set; }
|
||||
|
||||
/// <summary>签署完成后的 PDF 文件 Id。</summary>
|
||||
public long? SignedPdfFileId { get; set; }
|
||||
|
||||
/// <summary>当前管理员流程中是否已生成过员工签后预览。</summary>
|
||||
public bool EmployeeSignedPreviewed { get; set; }
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 定义入职电子签流程管理能力。
|
||||
/// </summary>
|
||||
public interface IOnboardingElectronicSignService : ITransient
|
||||
{
|
||||
/// <summary>查询指定入职任务的电子签详情。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> Detail(BaseIdInput input);
|
||||
|
||||
/// <summary>创建或重试电子签流程。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> Initiate(
|
||||
OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>保留旧流程并创建一次全新的电子签流程。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> Reinitiate(
|
||||
OnboardingElectronicSignReinitiateInput input);
|
||||
|
||||
/// <summary>主动同步契约锁状态并在完成后归档。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> Sync(
|
||||
OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>获取或刷新员工签署地址。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> GetSignLink(
|
||||
OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>获取一份员工已签合同的只读浏览页面。</summary>
|
||||
Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
|
||||
OnboardingElectronicSignPreviewInput input);
|
||||
|
||||
/// <summary>确认全部文件已核对后,由后台执行企业盖章。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> CompanySeal(
|
||||
OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>催办当前待签员工。</summary>
|
||||
Task Notice(OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>作废尚未结束的签约流程。</summary>
|
||||
Task Invalid(OnboardingElectronicSignInvalidInput input);
|
||||
|
||||
/// <summary>处理契约锁服务器明文回调。</summary>
|
||||
Task HandleCallback(string payloadJson);
|
||||
}
|
||||
+1625
File diff suppressed because it is too large
Load Diff
+67
@@ -0,0 +1,67 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 定义契约锁开放平台所需的最小调用能力。
|
||||
/// </summary>
|
||||
public interface IQiyuesuoClient : ITransient
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建包含个人签署方和企业人工盖章动作的合同草稿。
|
||||
/// </summary>
|
||||
Task<QiyuesuoContractResult> CreateDraft(
|
||||
QiyuesuoCreateDraftInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 向合同草稿中上传一份 DOCX 文件。
|
||||
/// </summary>
|
||||
Task<QiyuesuoDocumentResult> AddDocument(
|
||||
QiyuesuoAddDocumentInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 发起合同并为每份文档指定员工签名与企业盖章位置。
|
||||
/// </summary>
|
||||
Task<QiyuesuoContractResult> Send(
|
||||
QiyuesuoSendContractInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 获取员工签署页面地址。
|
||||
/// </summary>
|
||||
Task<string> GetSignPageUrl(
|
||||
QiyuesuoSignPageInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前合同中指定文档的只读浏览页面地址。
|
||||
/// </summary>
|
||||
Task<string> GetViewPageUrl(
|
||||
QiyuesuoViewPageInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 查询契约锁合同详情。
|
||||
/// </summary>
|
||||
Task<QiyuesuoContractResult> GetContractDetail(string contractId);
|
||||
|
||||
/// <summary>
|
||||
/// 催办当前待签署人。
|
||||
/// </summary>
|
||||
Task Notice(string contractId);
|
||||
|
||||
/// <summary>
|
||||
/// 由后台管理员确认后执行企业盖章节点。
|
||||
/// </summary>
|
||||
Task CompanySign(QiyuesuoCompanySignInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 作废尚未完成的合同流程。
|
||||
/// </summary>
|
||||
Task Invalid(string contractId, string sealId, string reason);
|
||||
|
||||
/// <summary>
|
||||
/// 下载单份签署完成的 PDF 文档。
|
||||
/// </summary>
|
||||
Task<byte[]> DownloadDocument(string documentId);
|
||||
|
||||
/// <summary>
|
||||
/// 下载合同原文、签署文件与签署日志组成的完整归档 ZIP。
|
||||
/// </summary>
|
||||
Task<byte[]> DownloadArchive(string contractId);
|
||||
}
|
||||
+739
@@ -0,0 +1,739 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 直接调用契约锁开放平台,不依赖旧版 .NET Framework SDK。
|
||||
/// </summary>
|
||||
public class QiyuesuoClient : IQiyuesuoClient
|
||||
{
|
||||
private const string SignatureHmacSha256 = "HMACSHA256";
|
||||
private const string AccessTokenHeader = "x-qys-open-accesstoken";
|
||||
private const string TimestampHeader = "x-qys-open-timestamp";
|
||||
private const string NonceHeader = "x-qys-open-nonce";
|
||||
private const string SignatureHeader = "x-qys-open-signature";
|
||||
private const string SignatureVersionHeader = "x-qys-open-signature-version";
|
||||
private const string RequestBodySignatureKey = "x-qys-open-body";
|
||||
private static readonly HttpClient HttpClient = new()
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
|
||||
private readonly QiyuesuoSettingOptions _setting;
|
||||
|
||||
public QiyuesuoClient()
|
||||
{
|
||||
_setting = App.GetConfig<QiyuesuoSettingOptions>(
|
||||
"QiyuesuoSetting",
|
||||
true)
|
||||
?? new QiyuesuoSettingOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QiyuesuoContractResult> CreateDraft(
|
||||
QiyuesuoCreateDraftInput input)
|
||||
{
|
||||
if (!long.TryParse(input.SealId, out var sealId))
|
||||
throw Oops.Bah("契约锁 SealId 配置格式不正确");
|
||||
|
||||
var body = new
|
||||
{
|
||||
bizId = input.BizId,
|
||||
subject = input.Subject,
|
||||
tenantName = input.CompanyName,
|
||||
description = "OnboardX 入职电子签",
|
||||
send = false,
|
||||
callbackUrl = NullIfBlank(input.CallbackUrl),
|
||||
category = new
|
||||
{
|
||||
id = input.CategoryId
|
||||
},
|
||||
creator = new
|
||||
{
|
||||
name = input.CreatorName,
|
||||
contact = input.CreatorMobile,
|
||||
contactType = "MOBILE"
|
||||
},
|
||||
signatories = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantType = "PERSONAL",
|
||||
tenantName = input.EmployeeName,
|
||||
receiver = new
|
||||
{
|
||||
name = input.EmployeeName,
|
||||
contact = input.EmployeeMobile,
|
||||
contactType = "MOBILE"
|
||||
},
|
||||
serialNo = 1,
|
||||
signatoryNo = "employee",
|
||||
actions = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "PERSONAL",
|
||||
name = "员工本人签署",
|
||||
serialNo = 0,
|
||||
actionNo = "employee_sign"
|
||||
}
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
tenantType = "COMPANY",
|
||||
tenantName = input.CompanyName,
|
||||
receiver = new
|
||||
{
|
||||
name = input.CreatorName,
|
||||
contact = input.CreatorMobile,
|
||||
contactType = "MOBILE"
|
||||
},
|
||||
serialNo = 2,
|
||||
signatoryNo = "company",
|
||||
actions = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "COMPANY",
|
||||
name = "企业人工盖章",
|
||||
serialNo = 0,
|
||||
autoSign = false,
|
||||
sealId = input.SealId,
|
||||
corpSealIds = new[] { sealId },
|
||||
actionNo = "company_seal"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return await PostJson<QiyuesuoContractResult>(
|
||||
"/v2/contract/draft",
|
||||
body);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QiyuesuoDocumentResult> AddDocument(
|
||||
QiyuesuoAddDocumentInput input)
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["contractId"] = input.ContractId,
|
||||
["bizId"] = input.BizId,
|
||||
["title"] = input.Title,
|
||||
["fileSuffix"] = "docx",
|
||||
["documentSort"] = input.DocumentSort.ToString(
|
||||
CultureInfo.InvariantCulture)
|
||||
};
|
||||
return await PostFile<QiyuesuoDocumentResult>(
|
||||
"/v2/document/addbyfile",
|
||||
fields,
|
||||
input.FileBytes,
|
||||
input.FileName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<QiyuesuoContractResult> Send(
|
||||
QiyuesuoSendContractInput input)
|
||||
{
|
||||
return PostJson<QiyuesuoContractResult>(
|
||||
"/v2/contract/send",
|
||||
new
|
||||
{
|
||||
contractId = input.ContractId,
|
||||
bizId = input.BizId,
|
||||
tenantName = input.CompanyName,
|
||||
locateAllStamperKeywords = false,
|
||||
stampers = input.Stampers
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> GetSignPageUrl(
|
||||
QiyuesuoSignPageInput input)
|
||||
{
|
||||
var result = await PostJsonElement(
|
||||
"/v2/contract/pageurl",
|
||||
new
|
||||
{
|
||||
contractId = input.ContractId,
|
||||
user = new
|
||||
{
|
||||
contact = input.EmployeeMobile,
|
||||
contactType = "MOBILE"
|
||||
},
|
||||
callbackPage = NullIfBlank(input.CallbackPage)
|
||||
});
|
||||
|
||||
if (result.ValueKind == JsonValueKind.String)
|
||||
return result.GetString();
|
||||
if (TryGetString(result, "url", out var url)
|
||||
|| TryGetString(result, "pageUrl", out url)
|
||||
|| TryGetString(result, "signUrl", out url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
throw Oops.Bah("契约锁未返回员工签署页面地址");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> GetViewPageUrl(
|
||||
QiyuesuoViewPageInput input)
|
||||
{
|
||||
var result = await GetJsonElement(
|
||||
"/v2/contract/viewurl",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["contractId"] = input.ContractId,
|
||||
["documentId"] = input.DocumentId,
|
||||
["tenantName"] = input.CompanyName,
|
||||
["downloadButton"] = "false",
|
||||
["printButton"] = "false"
|
||||
});
|
||||
|
||||
if (result.ValueKind == JsonValueKind.String)
|
||||
return result.GetString();
|
||||
if (TryGetString(result, "url", out var url)
|
||||
|| TryGetString(result, "viewUrl", out url)
|
||||
|| TryGetString(result, "pageUrl", out url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
throw Oops.Bah("契约锁未返回合同浏览页面地址");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<QiyuesuoContractResult> GetContractDetail(string contractId)
|
||||
{
|
||||
return GetJson<QiyuesuoContractResult>(
|
||||
"/v2/contract/detail",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["contractId"] = contractId,
|
||||
["queryActionOperate"] = "true",
|
||||
["queryActionOperator"] = "true",
|
||||
["queryLocation"] = "true"
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Notice(string contractId)
|
||||
{
|
||||
await PostJsonElement(
|
||||
"/v2/contract/notice",
|
||||
new
|
||||
{
|
||||
contractId,
|
||||
onlySigning = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CompanySign(QiyuesuoCompanySignInput input)
|
||||
{
|
||||
await PostJsonElement(
|
||||
"/v2/contract/companysign",
|
||||
new
|
||||
{
|
||||
contractId = input.ContractId,
|
||||
tenantName = input.CompanyName,
|
||||
sealId = input.SealId
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Invalid(string contractId, string sealId, string reason)
|
||||
{
|
||||
await PostJsonElement(
|
||||
"/v2/contract/invalid",
|
||||
new
|
||||
{
|
||||
contractId,
|
||||
sealId,
|
||||
reason,
|
||||
deleteDoc = false,
|
||||
autoSign = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> DownloadDocument(string documentId)
|
||||
{
|
||||
return Download(
|
||||
"/v2/document/download",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["documentId"] = documentId
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> DownloadArchive(string contractId)
|
||||
{
|
||||
return Download(
|
||||
"/v2/contract/download",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["contractId"] = contractId,
|
||||
["downloadItems"] = "CONTRACT,SIGNLOG",
|
||||
["needCompressForOneFile"] = "true"
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<T> PostJson<T>(string path, object body)
|
||||
{
|
||||
var result = await PostJsonElement(path, body);
|
||||
return result.Deserialize<T>(JsonOptions)
|
||||
?? throw Oops.Bah("契约锁返回数据为空");
|
||||
}
|
||||
|
||||
private async Task<JsonElement> PostJsonElement(
|
||||
string path,
|
||||
object body)
|
||||
{
|
||||
ValidateSettings();
|
||||
var json = JsonSerializer.Serialize(body, JsonOptions);
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
BuildUrl(path));
|
||||
request.Content = new StringContent(
|
||||
json,
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
AddAuthorizationHeaders(
|
||||
request,
|
||||
path,
|
||||
HttpMethod.Post,
|
||||
jsonBody: json);
|
||||
return await SendForJsonResult(request);
|
||||
}
|
||||
|
||||
private async Task<T> PostFile<T>(
|
||||
string path,
|
||||
Dictionary<string, string> fields,
|
||||
byte[] fileBytes,
|
||||
string fileName)
|
||||
{
|
||||
ValidateSettings();
|
||||
using var content = new MultipartFormDataContent();
|
||||
foreach (var field in fields)
|
||||
{
|
||||
content.Add(
|
||||
new StringContent(field.Value ?? string.Empty, Encoding.UTF8),
|
||||
field.Key);
|
||||
}
|
||||
|
||||
var fileContent = new ByteArrayContent(fileBytes);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
content.Add(fileContent, "file", fileName);
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
BuildUrl(path))
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
AddAuthorizationHeaders(
|
||||
request,
|
||||
path,
|
||||
HttpMethod.Post,
|
||||
fields,
|
||||
fileBytes);
|
||||
var result = await SendForJsonResult(request);
|
||||
return result.Deserialize<T>(JsonOptions)
|
||||
?? throw Oops.Bah("契约锁返回数据为空");
|
||||
}
|
||||
|
||||
private async Task<T> GetJson<T>(
|
||||
string path,
|
||||
Dictionary<string, string> query)
|
||||
{
|
||||
var result = await GetJsonElement(path, query);
|
||||
return result.Deserialize<T>(JsonOptions)
|
||||
?? throw Oops.Bah("契约锁返回数据为空");
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetJsonElement(
|
||||
string path,
|
||||
Dictionary<string, string> query)
|
||||
{
|
||||
ValidateSettings();
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
BuildUrl(path, query));
|
||||
AddAuthorizationHeaders(
|
||||
request,
|
||||
path,
|
||||
HttpMethod.Get,
|
||||
query);
|
||||
return await SendForJsonResult(request);
|
||||
}
|
||||
|
||||
private async Task<byte[]> Download(
|
||||
string path,
|
||||
Dictionary<string, string> query)
|
||||
{
|
||||
ValidateSettings();
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
BuildUrl(path, query));
|
||||
AddAuthorizationHeaders(
|
||||
request,
|
||||
path,
|
||||
HttpMethod.Get,
|
||||
query);
|
||||
using var response = await Send(request);
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
if (!response.IsSuccessStatusCode || LooksLikeJson(response, bytes))
|
||||
{
|
||||
ThrowProviderError(bytes, response.StatusCode);
|
||||
}
|
||||
|
||||
if (bytes.Length == 0)
|
||||
throw Oops.Bah("契约锁下载文件内容为空");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> SendForJsonResult(
|
||||
HttpRequestMessage request)
|
||||
{
|
||||
using var response = await Send(request);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
if (string.IsNullOrWhiteSpace(responseText))
|
||||
throw Oops.Bah("契约锁返回内容为空");
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
var hasCode = root.TryGetProperty("code", out var codeElement);
|
||||
var code = hasCode ? ReadCode(codeElement) : 0;
|
||||
var hasResponseCode = TryGetString(
|
||||
root,
|
||||
"responseCode",
|
||||
out var responseCode);
|
||||
var responseCodeSucceeded = !hasResponseCode
|
||||
|| responseCode == "0"
|
||||
|| responseCode == "00000000";
|
||||
if (!response.IsSuccessStatusCode
|
||||
|| (hasCode && code != 0)
|
||||
|| !responseCodeSucceeded)
|
||||
{
|
||||
var hasMessage = TryGetString(
|
||||
root,
|
||||
"message",
|
||||
out var providerMessage)
|
||||
|| TryGetString(
|
||||
root,
|
||||
"responseMessage",
|
||||
out providerMessage);
|
||||
var message = hasMessage
|
||||
? providerMessage
|
||||
: $"HTTP {(int)response.StatusCode},响应码 {responseCode ?? code.ToString()}";
|
||||
var providerCode = hasResponseCode
|
||||
? responseCode
|
||||
: hasCode
|
||||
? code.ToString(CultureInfo.InvariantCulture)
|
||||
: ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
throw Oops.Bah($"契约锁请求失败:{message}(响应码:{providerCode})");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("result", out var result))
|
||||
return root.Clone();
|
||||
return result.Clone();
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> Send(
|
||||
HttpRequestMessage request)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(
|
||||
TimeSpan.FromSeconds(Math.Max(10, _setting.TimeoutSeconds)));
|
||||
try
|
||||
{
|
||||
return await HttpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw Oops.Bah("契约锁请求超时,请稍后重试");
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw Oops.Bah($"无法连接契约锁:{exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAuthorizationHeaders(
|
||||
HttpRequestMessage request,
|
||||
string path,
|
||||
HttpMethod method,
|
||||
Dictionary<string, string> parameters = null,
|
||||
byte[] fileBytes = null,
|
||||
string jsonBody = null)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow
|
||||
.ToUnixTimeMilliseconds()
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
var nonce = Guid.NewGuid().ToString();
|
||||
var signature = string.Equals(
|
||||
_setting.SignatureVersion,
|
||||
SignatureHmacSha256,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? BuildHmacSha256Signature(
|
||||
path,
|
||||
method,
|
||||
timestamp,
|
||||
nonce,
|
||||
parameters,
|
||||
fileBytes,
|
||||
jsonBody)
|
||||
: BuildMd5Signature(timestamp, nonce);
|
||||
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
AccessTokenHeader,
|
||||
_setting.AppToken.Trim());
|
||||
request.Headers.TryAddWithoutValidation(TimestampHeader, timestamp);
|
||||
request.Headers.TryAddWithoutValidation(NonceHeader, nonce);
|
||||
request.Headers.TryAddWithoutValidation(SignatureHeader, signature);
|
||||
request.Headers.TryAddWithoutValidation("version", "OnboardX-1.0");
|
||||
if (!string.IsNullOrWhiteSpace(_setting.SignatureVersion))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
SignatureVersionHeader,
|
||||
_setting.SignatureVersion.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildMd5Signature(string timestamp, string nonce)
|
||||
{
|
||||
var source = _setting.AppToken.Trim()
|
||||
+ _setting.AppSecret.Trim()
|
||||
+ timestamp
|
||||
+ nonce;
|
||||
return Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(source)))
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string BuildHmacSha256Signature(
|
||||
string path,
|
||||
HttpMethod method,
|
||||
string timestamp,
|
||||
string nonce,
|
||||
Dictionary<string, string> parameters,
|
||||
byte[] fileBytes,
|
||||
string jsonBody)
|
||||
{
|
||||
var values = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new(AccessTokenHeader, _setting.AppToken.Trim()),
|
||||
new(TimestampHeader, timestamp),
|
||||
new(NonceHeader, nonce),
|
||||
new(SignatureVersionHeader, SignatureHmacSha256)
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(jsonBody))
|
||||
values.Add(new KeyValuePair<string, string>(
|
||||
RequestBodySignatureKey,
|
||||
jsonBody));
|
||||
if (parameters != null)
|
||||
{
|
||||
values.AddRange(parameters.Where(item =>
|
||||
!string.IsNullOrWhiteSpace(item.Key)
|
||||
&& !string.IsNullOrWhiteSpace(item.Value)));
|
||||
}
|
||||
if (fileBytes != null)
|
||||
{
|
||||
values.Add(new KeyValuePair<string, string>(
|
||||
"file",
|
||||
Convert.ToHexString(MD5.HashData(fileBytes))
|
||||
.ToLowerInvariant()));
|
||||
}
|
||||
|
||||
var builder = new StringBuilder()
|
||||
.Append("requestMethod=")
|
||||
.Append(method.Method)
|
||||
.Append("&requestUrl=")
|
||||
.Append(path);
|
||||
foreach (var value in values
|
||||
.OrderBy(item => item.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append('&')
|
||||
.Append(value.Key.Trim())
|
||||
.Append('=')
|
||||
.Append(value.Value.Trim());
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(
|
||||
Encoding.UTF8.GetBytes(_setting.AppSecret.Trim()));
|
||||
return Convert.ToHexString(
|
||||
hmac.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString())))
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string BuildUrl(
|
||||
string path,
|
||||
Dictionary<string, string> query = null)
|
||||
{
|
||||
var url = $"{_setting.ServerUrl.Trim().TrimEnd('/')}/{path.TrimStart('/')}";
|
||||
if (query == null || query.Count == 0)
|
||||
return url;
|
||||
var queryString = string.Join("&", query.Select(item =>
|
||||
Uri.EscapeDataString(item.Key)
|
||||
+ "="
|
||||
+ Uri.EscapeDataString(item.Value ?? string.Empty)));
|
||||
return url + "?" + queryString;
|
||||
}
|
||||
|
||||
private void ValidateSettings()
|
||||
{
|
||||
if (!_setting.Enabled)
|
||||
throw Oops.Bah("契约锁电子签功能尚未启用");
|
||||
if (string.IsNullOrWhiteSpace(_setting.ServerUrl))
|
||||
throw Oops.Bah("请配置契约锁 ServerUrl");
|
||||
if (string.IsNullOrWhiteSpace(_setting.AppToken))
|
||||
throw Oops.Bah("请配置契约锁 AppToken");
|
||||
if (string.IsNullOrWhiteSpace(_setting.AppSecret))
|
||||
throw Oops.Bah("请配置契约锁 AppSecret");
|
||||
}
|
||||
|
||||
private static int ReadCode(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Number
|
||||
&& element.TryGetInt32(out var numericCode))
|
||||
{
|
||||
return numericCode;
|
||||
}
|
||||
return int.TryParse(element.ToString(), out var textCode)
|
||||
? textCode
|
||||
: -1;
|
||||
}
|
||||
|
||||
private static bool TryGetString(
|
||||
JsonElement element,
|
||||
string propertyName,
|
||||
out string value)
|
||||
{
|
||||
value = null;
|
||||
if (element.ValueKind != JsonValueKind.Object
|
||||
|| !element.TryGetProperty(propertyName, out var property)
|
||||
|| property.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: property.ToString();
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
private static bool LooksLikeJson(
|
||||
HttpResponseMessage response,
|
||||
byte[] bytes)
|
||||
{
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (!string.IsNullOrWhiteSpace(contentType)
|
||||
&& contentType.Contains("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var first = bytes.FirstOrDefault(item =>
|
||||
!char.IsWhiteSpace((char)item));
|
||||
return first == '{' || first == '[';
|
||||
}
|
||||
|
||||
private static void ThrowProviderError(
|
||||
byte[] bytes,
|
||||
global::System.Net.HttpStatusCode statusCode)
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(bytes);
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(text);
|
||||
var root = document.RootElement;
|
||||
var hasMessage = TryGetString(
|
||||
root,
|
||||
"message",
|
||||
out var providerMessage)
|
||||
|| TryGetString(
|
||||
root,
|
||||
"responseMessage",
|
||||
out providerMessage);
|
||||
var message = hasMessage ? providerMessage : text;
|
||||
throw Oops.Bah($"契约锁下载失败:{message}");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"契约锁下载失败:HTTP {(int)statusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NullIfBlank(string value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造兼容契约锁长整型 Id 和字符串 Id 两种返回形式的 JSON 配置。
|
||||
/// </summary>
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
options.Converters.Add(new FlexibleStringJsonConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将第三方响应中的字符串或数字统一读取为字符串,避免长整型 Id 精度丢失。
|
||||
/// </summary>
|
||||
private sealed class FlexibleStringJsonConverter : JsonConverter<string>
|
||||
{
|
||||
public override string Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
return reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
JsonTokenType.Number => GetRawNumber(ref reader),
|
||||
JsonTokenType.True => bool.TrueString,
|
||||
JsonTokenType.False => bool.FalseString,
|
||||
_ => reader.GetString()
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
string value,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value);
|
||||
}
|
||||
|
||||
private static string GetRawNumber(ref Utf8JsonReader reader)
|
||||
{
|
||||
if (reader.TryGetInt64(out var integer))
|
||||
return integer.ToString(CultureInfo.InvariantCulture);
|
||||
return reader.GetDecimal().ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 创建契约锁合同草稿所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoCreateDraftInput
|
||||
{
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>合同流程标题。</summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>签署企业法定名称。</summary>
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>员工姓名。</summary>
|
||||
public string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>员工手机号。</summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>契约锁业务分类 Id。</summary>
|
||||
public string CategoryId { get; set; }
|
||||
|
||||
/// <summary>契约锁企业印章 Id。</summary>
|
||||
public string SealId { get; set; }
|
||||
|
||||
/// <summary>契约锁企业成员姓名。</summary>
|
||||
public string CreatorName { get; set; }
|
||||
|
||||
/// <summary>契约锁企业成员手机号。</summary>
|
||||
public string CreatorMobile { get; set; }
|
||||
|
||||
/// <summary>契约锁服务器回调地址。</summary>
|
||||
public string CallbackUrl { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传契约锁合同文档所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoAddDocumentInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>文档标题。</summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>文档排序。</summary>
|
||||
public int DocumentSort { get; set; }
|
||||
|
||||
/// <summary>上传文件名。</summary>
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>DOCX 文件内容。</summary>
|
||||
public byte[] FileBytes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁签章位置。
|
||||
/// </summary>
|
||||
public class QiyuesuoStamperInput
|
||||
{
|
||||
/// <summary>企业签署动作 Id。</summary>
|
||||
public string ActionId { get; set; }
|
||||
|
||||
/// <summary>个人签署方 Id。</summary>
|
||||
public string SignatoryId { get; set; }
|
||||
|
||||
/// <summary>签章类型。</summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>契约锁文档 Id。</summary>
|
||||
public string DocumentId { get; set; }
|
||||
|
||||
/// <summary>骑缝章覆盖的契约锁文档 Id,多个 Id 使用英文逗号分隔。</summary>
|
||||
public string DocumentIds { get; set; }
|
||||
|
||||
/// <summary>骑缝章位置指定方式。</summary>
|
||||
public string AcrossType { get; set; }
|
||||
|
||||
/// <summary>企业印章 Id。</summary>
|
||||
public string SealId { get; set; }
|
||||
|
||||
/// <summary>签署位置关键字。</summary>
|
||||
public string Keyword { get; set; }
|
||||
|
||||
/// <summary>同名关键字的序号,从 1 开始。</summary>
|
||||
public int? KeywordIndex { get; set; }
|
||||
|
||||
/// <summary>相对关键字的横向偏移量;契约锁要求必传,不偏移时为 0。</summary>
|
||||
public decimal OffsetX { get; set; }
|
||||
|
||||
/// <summary>相对关键字的纵向偏移量;契约锁要求必传,不偏移时为 0。</summary>
|
||||
public decimal OffsetY { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发起契约锁合同流程所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoSendContractInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>契约锁合同发起企业名称。</summary>
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>所有文档的签名和盖章位置。</summary>
|
||||
public List<QiyuesuoStamperInput> Stampers { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取契约锁员工签署页所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoSignPageInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>员工手机号。</summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>签署完成后的前端返回地址。</summary>
|
||||
public string CallbackPage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取契约锁合同文档只读浏览页所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoViewPageInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>契约锁合同文档 Id。</summary>
|
||||
public string DocumentId { get; set; }
|
||||
|
||||
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
|
||||
public string CompanyName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业人工盖章所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoCompanySignInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>当前流程使用的企业印章 Id 快照。</summary>
|
||||
public string SealId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同返回对象。
|
||||
/// </summary>
|
||||
public class QiyuesuoContractResult
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>兼容部分接口返回的合同流程 Id 字段名。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>流程标题。</summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>契约锁合同状态。</summary>
|
||||
public string Status { get; set; }
|
||||
|
||||
/// <summary>签署方列表。</summary>
|
||||
public List<QiyuesuoSignatoryResult> Signatories { get; set; } = new();
|
||||
|
||||
/// <summary>合同文档列表。</summary>
|
||||
public List<QiyuesuoDocumentResult> Documents { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁签署方返回对象。
|
||||
/// </summary>
|
||||
public class QiyuesuoSignatoryResult
|
||||
{
|
||||
/// <summary>签署方 Id。</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>兼容部分接口返回的签署方 Id 字段名。</summary>
|
||||
public string SignatoryId { get; set; }
|
||||
|
||||
/// <summary>签署方类型。</summary>
|
||||
public string TenantType { get; set; }
|
||||
|
||||
/// <summary>签署方名称。</summary>
|
||||
public string TenantName { get; set; }
|
||||
|
||||
/// <summary>签署方状态。</summary>
|
||||
public string Status { get; set; }
|
||||
|
||||
/// <summary>签署顺序。</summary>
|
||||
public int? SerialNo { get; set; }
|
||||
|
||||
/// <summary>企业签署动作。</summary>
|
||||
public List<QiyuesuoActionResult> Actions { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业签署动作返回对象。
|
||||
/// </summary>
|
||||
public class QiyuesuoActionResult
|
||||
{
|
||||
/// <summary>签署动作 Id。</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>兼容部分接口返回的签署动作 Id 字段名。</summary>
|
||||
public string ActionId { get; set; }
|
||||
|
||||
/// <summary>签署动作类型。</summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>签署动作状态。</summary>
|
||||
public string Status { get; set; }
|
||||
|
||||
/// <summary>动作执行顺序。</summary>
|
||||
public int? SerialNo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同文档返回对象。
|
||||
/// </summary>
|
||||
public class QiyuesuoDocumentResult
|
||||
{
|
||||
/// <summary>契约锁文档 Id。</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>兼容部分接口返回的文档 Id 字段名。</summary>
|
||||
public string DocumentId { get; set; }
|
||||
|
||||
/// <summary>文档标题。</summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>文档页数。</summary>
|
||||
public int? PageCount { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
@@ -29,6 +31,84 @@ public class OnboardingEmployeePageInput : BasePageInput
|
||||
/// 岗位 Id。
|
||||
/// </summary>
|
||||
public long? PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用工类型。
|
||||
/// </summary>
|
||||
public string EmploymentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转正状态。
|
||||
/// </summary>
|
||||
public string RegularizationStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 社保状态。
|
||||
/// </summary>
|
||||
public string SocialInsuranceStatus { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑员工人事信息。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeHrUpdateInput
|
||||
{
|
||||
[IdNotNull(ErrorMessage = "员工Id不能为空")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "请选择用工类型")]
|
||||
public string EmploymentType { get; set; }
|
||||
|
||||
public string ContractNature { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "请选择社保状态")]
|
||||
public string SocialInsuranceStatus { get; set; }
|
||||
|
||||
public string SocialInsuranceCompany { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工转正办理参数。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeRegularizeInput
|
||||
{
|
||||
[IdNotNull(ErrorMessage = "员工Id不能为空")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "请选择转正日期")]
|
||||
public DateTime? RegularizationDate { get; set; }
|
||||
|
||||
public string Remark { get; set; }
|
||||
|
||||
public List<long> FileIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销员工转正参数。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeRegularizationRevokeInput
|
||||
{
|
||||
[IdNotNull(ErrorMessage = "员工Id不能为空")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "请填写撤销原因")]
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 正式员工档案导出结果。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeExportFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Excel 文件字节。
|
||||
/// </summary>
|
||||
public byte[] FileBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下载文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,8 +136,31 @@ public class OnboardingEmployeeDetailOutput
|
||||
/// </summary>
|
||||
public List<ObTaskContract> Contracts { get; set; } = new List<ObTaskContract>();
|
||||
|
||||
/// <summary>
|
||||
/// 审核建档时冻结的员工合同业务快照,包含常用字段及全部自定义变量。
|
||||
/// </summary>
|
||||
public List<ObEmployeeContractSnapshot> ContractSnapshots { get; set; }
|
||||
= new List<ObEmployeeContractSnapshot>();
|
||||
|
||||
/// <summary>
|
||||
/// 员工入职时上传的身份证、学历证明、体检报告等资料。
|
||||
/// </summary>
|
||||
public List<OnboardingTaskMaterialDetailOutput> Materials { get; set; }
|
||||
= new List<OnboardingTaskMaterialDetailOutput>();
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁签署文件与签署日志组成的完整归档 ZIP 文件 Id。
|
||||
/// </summary>
|
||||
public long? ElectronicSignArchiveFileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交与审核记录列表。
|
||||
/// </summary>
|
||||
public List<ObTaskAudit> Audits { get; set; } = new List<ObTaskAudit>();
|
||||
|
||||
/// <summary>
|
||||
/// 员工转正及人事维护历史。
|
||||
/// </summary>
|
||||
public List<ObEmployeeChangeRecord> ChangeRecords { get; set; }
|
||||
= new List<ObEmployeeChangeRecord>();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 定义正式员工档案查询业务能力。
|
||||
/// 定义员工档案查询和人事维护业务能力。
|
||||
/// </summary>
|
||||
public interface IOnboardingEmployeeService : ITransient
|
||||
{
|
||||
@@ -10,6 +10,22 @@ public interface IOnboardingEmployeeService : ITransient
|
||||
/// </summary>
|
||||
Task<SqlSugarPagedList<ObEmployee>> Page(OnboardingEmployeePageInput input);
|
||||
/// <summary>
|
||||
/// 按当前列表筛选条件导出员工明细。
|
||||
/// </summary>
|
||||
Task<OnboardingEmployeeExportFile> Export(OnboardingEmployeePageInput input);
|
||||
/// <summary>
|
||||
/// 编辑员工用工、社保及合同性质等人事信息。
|
||||
/// </summary>
|
||||
Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input);
|
||||
/// <summary>
|
||||
/// 人工办理员工转正。
|
||||
/// </summary>
|
||||
Task Regularize(OnboardingEmployeeRegularizeInput input);
|
||||
/// <summary>
|
||||
/// 撤销一次员工转正,历史记录和附件继续保留。
|
||||
/// </summary>
|
||||
Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input);
|
||||
/// <summary>
|
||||
/// 查询正式员工及其来源任务、合同和审核记录。
|
||||
/// </summary>
|
||||
Task<OnboardingEmployeeDetailOutput> Detail(BaseIdInput input);
|
||||
|
||||
+1065
-13
File diff suppressed because it is too large
Load Diff
+10
@@ -0,0 +1,10 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 定义正式员工合同快照归档能力。
|
||||
/// </summary>
|
||||
public interface IOnboardingEmployeeContractSnapshotService : ITransient
|
||||
{
|
||||
/// <summary>冻结一名员工来源任务中的全部合同和变量。</summary>
|
||||
Task Build(long employeeId, long taskId);
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 在正式员工建档时,将每份任务合同及其全部变量冻结为长期业务快照。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeContractSnapshotService : IOnboardingEmployeeContractSnapshotService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
|
||||
public OnboardingEmployeeContractSnapshotService()
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Build(long employeeId, long taskId)
|
||||
{
|
||||
var contracts = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == taskId)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync();
|
||||
if (contracts.Count == 0)
|
||||
throw Oops.Bah("入职任务没有合同,无法生成员工合同快照");
|
||||
|
||||
var contractIds = contracts.Select(item => item.Id).ToList();
|
||||
var allVariables = await _db.Queryable<ObTaskContractVariable>()
|
||||
.Where(item => contractIds.Contains(item.TaskContractId))
|
||||
.OrderBy(item => item.SortCode)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync();
|
||||
var now = DateTime.Now;
|
||||
var snapshots = new List<ObEmployeeContractSnapshot>();
|
||||
|
||||
foreach (var contract in contracts)
|
||||
{
|
||||
var variables = allVariables
|
||||
.Where(item => item.TaskContractId == contract.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.ThenBy(item => item.Id)
|
||||
.ToList();
|
||||
var valueMap = variables
|
||||
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Last().VariableValue,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var variableSnapshot = variables
|
||||
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group =>
|
||||
{
|
||||
// 历史任务若意外存在重复变量,以排序后的最后一条最终值为准,避免建档时报 500。
|
||||
var item = group.Last();
|
||||
return (object)new
|
||||
{
|
||||
variableName = item.VariableName,
|
||||
variableType = item.VariableType,
|
||||
dataType = item.DataType,
|
||||
variableValue = item.VariableValue,
|
||||
sourceType = item.SourceType,
|
||||
isManualOverride = item.IsManualOverride,
|
||||
sortCode = item.SortCode
|
||||
};
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
snapshots.Add(new ObEmployeeContractSnapshot
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
EmployeeId = employeeId,
|
||||
TaskId = taskId,
|
||||
TaskContractId = contract.Id,
|
||||
TemplateId = contract.TemplateId,
|
||||
TemplateVersionId = contract.TemplateVersionId,
|
||||
ContractName = contract.ContractName,
|
||||
ContractType = contract.ContractType,
|
||||
ContractStartDate = ParseDate(GetValue(valueMap, "hr.ContractStartDate")),
|
||||
ContractEndDate = ParseDate(GetValue(valueMap, "hr.ContractEndDate")),
|
||||
ContractTerm = GetValue(valueMap, "hr.ContractTerm"),
|
||||
ProbationStartDate = ParseDate(GetValue(valueMap, "hr.ProbationStartDate")),
|
||||
ProbationEndDate = ParseDate(GetValue(valueMap, "hr.ProbationEndDate")),
|
||||
ProbationTerm = GetValue(valueMap, "hr.ProbationTerm"),
|
||||
ProbationSalary = ParseAmount(GetValue(valueMap, "hr.ProbationSalary")),
|
||||
BaseSalary = ParseAmount(GetValue(valueMap, "hr.BaseSalary")),
|
||||
PositionSalary = ParseAmount(GetValue(valueMap, "hr.PositionSalary")),
|
||||
PerformanceAllowance = ParseAmount(GetValue(valueMap, "hr.PerformanceAllowance")),
|
||||
AttendanceBonus = ParseAmount(GetValue(valueMap, "hr.AttendanceBonus")),
|
||||
RegularSalary = ParseAmount(GetValue(valueMap, "hr.RegularSalary")),
|
||||
WorkCity = GetValue(valueMap, "hr.WorkCity"),
|
||||
PositionName = GetValue(valueMap, "hr.PositionName"),
|
||||
EmployeeNo = GetValue(valueMap, "hr.EmployeeNo"),
|
||||
VariablesJson = global::System.Text.Json.JsonSerializer.Serialize(variableSnapshot),
|
||||
SnapshotVersion = 1,
|
||||
SnapshotAt = now,
|
||||
Status = CommonStatusConst.ENABLE,
|
||||
CreateTime = now,
|
||||
CreateUserId = UserManager.UserId,
|
||||
CreateUser = UserManager.Name,
|
||||
IsDelete = false
|
||||
});
|
||||
}
|
||||
|
||||
// Build 只在审核建档事务内执行;先删后建使异常重试保持幂等。
|
||||
await _db.Deleteable<ObEmployeeContractSnapshot>()
|
||||
.Where(item => item.TaskId == taskId)
|
||||
.ExecuteCommandAsync();
|
||||
await _db.Insertable(snapshots).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
private static string GetValue(
|
||||
IReadOnlyDictionary<string, string> valueMap,
|
||||
string variableCode)
|
||||
{
|
||||
return valueMap.TryGetValue(variableCode, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value.Trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static DateTime? ParseDate(string value)
|
||||
{
|
||||
return DateTime.TryParse(value, out var date) ? date.Date : null;
|
||||
}
|
||||
|
||||
private static decimal? ParseAmount(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var normalized = value
|
||||
.Replace("¥", string.Empty)
|
||||
.Replace("¥", string.Empty)
|
||||
.Replace("元", string.Empty)
|
||||
.Trim();
|
||||
if (decimal.TryParse(
|
||||
normalized,
|
||||
NumberStyles.Number | NumberStyles.AllowCurrencySymbol,
|
||||
CultureInfo.GetCultureInfo("zh-CN"),
|
||||
out var amount)
|
||||
|| decimal.TryParse(
|
||||
normalized,
|
||||
NumberStyles.Number,
|
||||
CultureInfo.InvariantCulture,
|
||||
out amount))
|
||||
{
|
||||
return amount;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -27,6 +27,11 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
/// </summary>
|
||||
public async Task SaveFormField(OnboardingFormFieldSaveInput input)
|
||||
{
|
||||
input.FieldCode = OnboardingCodeValidator.Normalize(input.FieldCode, "字段编码", 100);
|
||||
input.FieldName = input.FieldName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input.FieldName))
|
||||
throw Oops.Bah("字段名称不能为空");
|
||||
|
||||
var fieldCodeQuery = _db.Queryable<ObFormField>()
|
||||
.Where(x => x.FieldCode == input.FieldCode);
|
||||
if (input.Id.HasValue)
|
||||
@@ -115,6 +120,10 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
/// </summary>
|
||||
public async Task SaveMaterialType(OnboardingMaterialTypeSaveInput input)
|
||||
{
|
||||
input.MaterialCode = OnboardingCodeValidator.Normalize(input.MaterialCode, "资料编码", 100);
|
||||
input.MaterialName = input.MaterialName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input.MaterialName))
|
||||
throw Oops.Bah("资料名称不能为空");
|
||||
if (input.MaxFileCount <= 0)
|
||||
throw Oops.Bah("最大文件数量必须大于0");
|
||||
|
||||
@@ -169,4 +178,4 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
|
||||
await _db.Deleteable<ObMaterialType>().In(input.Ids).ExecuteCommandAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
-11
@@ -92,12 +92,22 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
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)
|
||||
var codeOwner = await _db.Queryable<ObOnboardingTemplate>()
|
||||
.ClearFilter()
|
||||
.FirstAsync(item => item.TemplateCode == input.TemplateCode
|
||||
&& (!input.Id.HasValue || item.Id != input.Id.Value));
|
||||
if (codeOwner != null && !codeOwner.IsDelete)
|
||||
throw Oops.Bah("入职模板编码已存在");
|
||||
|
||||
if (input.Id.HasValue && codeOwner != null && codeOwner.IsDelete)
|
||||
throw Oops.Bah("该入职模板编码属于一条已删除记录,请通过新增模板重新启用该编码");
|
||||
|
||||
// 新增时如果编码命中历史软删除记录,复用原 Id 并恢复,避免唯一索引直接抛 500。
|
||||
var isRestore = !input.Id.HasValue && codeOwner != null && codeOwner.IsDelete;
|
||||
var templateId = isRestore
|
||||
? codeOwner.Id
|
||||
: input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
|
||||
|
||||
var entity = new ObOnboardingTemplate
|
||||
{
|
||||
Id = templateId,
|
||||
@@ -106,15 +116,17 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
CompanyId = input.CompanyId,
|
||||
DepartmentId = input.DepartmentId,
|
||||
Description = input.Description,
|
||||
Status = input.Status
|
||||
Status = input.Status,
|
||||
IsDelete = false
|
||||
};
|
||||
|
||||
await _db.Ado.BeginTranAsync();
|
||||
try
|
||||
{
|
||||
if (input.Id.HasValue)
|
||||
if (input.Id.HasValue || isRestore)
|
||||
{
|
||||
var exists = await _db.Queryable<ObOnboardingTemplate>().AnyAsync(item => item.Id == templateId);
|
||||
var exists = isRestore || await _db.Queryable<ObOnboardingTemplate>()
|
||||
.AnyAsync(item => item.Id == templateId);
|
||||
if (!exists)
|
||||
throw Oops.Bah("入职模板不存在");
|
||||
await _db.Updateable(entity).IgnoreColumns(ignoreAllNullColumns: true).ExecuteCommandAsync();
|
||||
@@ -189,7 +201,10 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
|
||||
private static void NormalizeAndValidateInput(OnboardingTemplateSaveInput input)
|
||||
{
|
||||
input.TemplateCode = input.TemplateCode?.Trim();
|
||||
input.TemplateCode = OnboardingCodeValidator.Normalize(
|
||||
input.TemplateCode,
|
||||
"入职模板编码",
|
||||
100);
|
||||
input.TemplateName = input.TemplateName?.Trim();
|
||||
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>();
|
||||
input.Contracts ??= new List<OnboardingTemplateContractInput>();
|
||||
@@ -208,8 +223,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
.Select(group => group.First())
|
||||
.ToList() ?? new List<OnboardingTemplateMaterialInput>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.TemplateCode) || string.IsNullOrWhiteSpace(input.TemplateName))
|
||||
throw Oops.Bah("请填写入职模板编码和名称");
|
||||
if (string.IsNullOrWhiteSpace(input.TemplateName))
|
||||
throw Oops.Bah("请填写入职模板名称");
|
||||
if (input.CompanyId <= 0)
|
||||
throw Oops.Bah("请选择所属公司");
|
||||
if (input.DepartmentId <= 0)
|
||||
@@ -299,7 +314,10 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
|
||||
if (input.Status == "enable")
|
||||
{
|
||||
if (contractTemplates.Any(item => item.Status != "enable"))
|
||||
if (contractTemplates.Any(item => !string.Equals(
|
||||
item.Status,
|
||||
"enable",
|
||||
global::System.StringComparison.OrdinalIgnoreCase)))
|
||||
throw Oops.Bah("启用入职模板前,请先启用全部合同模板");
|
||||
if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled))
|
||||
throw Oops.Bah("启用入职模板前,请先启用全部合同版本");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 实现入职业务岗位业务能力。
|
||||
@@ -17,6 +17,7 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
|
||||
.OrderByDescending(it => it.CreateTime)
|
||||
.OrderBy(it => it.PositionName)
|
||||
.ToPagedListAsync(input.PageNum, input.PageSize);
|
||||
|
||||
@@ -167,12 +168,11 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
|
||||
/// </summary>
|
||||
private static void NormalizeInput(OnboardingPositionAddInput input)
|
||||
{
|
||||
input.PositionCode = input.PositionCode?.Trim();
|
||||
input.PositionCode = OnboardingCodeValidator.Normalize(input.PositionCode, "岗位编码", 50);
|
||||
input.PositionName = input.PositionName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input.PositionCode)
|
||||
|| string.IsNullOrWhiteSpace(input.PositionName))
|
||||
if (string.IsNullOrWhiteSpace(input.PositionName))
|
||||
{
|
||||
throw Oops.Bah("请填写岗位编码和岗位名称");
|
||||
throw Oops.Bah("请填写岗位名称");
|
||||
}
|
||||
|
||||
if (input.Status != "enable" && input.Status != "disable")
|
||||
|
||||
+11
-36
@@ -8,14 +8,17 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IOnboardingContractSnapshotService _snapshotService;
|
||||
private readonly IOnboardingIdentityValidationService _identityValidationService;
|
||||
private readonly IOnboardingContractVariableValidationService _contractVariableValidationService;
|
||||
|
||||
public OnboardingPublicSubmissionService(
|
||||
IOnboardingContractSnapshotService snapshotService,
|
||||
IOnboardingIdentityValidationService identityValidationService)
|
||||
IOnboardingIdentityValidationService identityValidationService,
|
||||
IOnboardingContractVariableValidationService contractVariableValidationService)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_snapshotService = snapshotService;
|
||||
_identityValidationService = identityValidationService;
|
||||
_contractVariableValidationService = contractVariableValidationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -119,15 +122,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
{
|
||||
var task = await GetEditableTask(input.Token);
|
||||
var info = await _db.Queryable<ObTaskEmployeeInfo>()
|
||||
.FirstAsync(item => item.TaskId == task.Id);
|
||||
var basicInfoIncomplete = info == null
|
||||
|| string.IsNullOrWhiteSpace(info.EmployeeName)
|
||||
|| string.IsNullOrWhiteSpace(info.Phone)
|
||||
|| string.IsNullOrWhiteSpace(info.IdCardNumber)
|
||||
|| string.IsNullOrWhiteSpace(info.BankCardNumber)
|
||||
|| string.IsNullOrWhiteSpace(info.BankName);
|
||||
if (basicInfoIncomplete)
|
||||
throw Oops.Bah("请先完整填写基本信息");
|
||||
.FirstAsync(item => item.TaskId == task.Id)
|
||||
?? throw Oops.Bah("请先完整填写基本信息");
|
||||
|
||||
// 最终提交必须重新执行全量校验,不能只依赖员工手机端的页面校验。
|
||||
OnboardingEmployeeInfoValidator.ValidateAndNormalize(info);
|
||||
await _db.Updateable(info).ExecuteCommandAsync();
|
||||
|
||||
await _identityValidationService.EnsureNoDuplicate(
|
||||
task.Id,
|
||||
@@ -148,7 +148,7 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
if (hasMissingRequiredMaterial)
|
||||
throw Oops.Bah("仍有必传材料未上传");
|
||||
|
||||
await EnsureRequiredContractVariables(task);
|
||||
await _contractVariableValidationService.Validate(task.Id);
|
||||
|
||||
var hasUnconfirmedRequiredContract = await _db.Queryable<ObTaskContract>()
|
||||
.AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null);
|
||||
@@ -214,31 +214,6 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
throw Oops.Bah("该材料已达到最大上传数量");
|
||||
}
|
||||
|
||||
private async Task EnsureRequiredContractVariables(ObOnboardingTask task)
|
||||
{
|
||||
var contracts = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == task.Id)
|
||||
.ToListAsync();
|
||||
var contractIds = contracts.Select(contract => contract.Id).ToList();
|
||||
var templateVersionIds = contracts.Select(contract => contract.TemplateVersionId).ToList();
|
||||
var variables = await _db.Queryable<ObTaskContractVariable>()
|
||||
.Where(item => contractIds.Contains(item.TaskContractId))
|
||||
.ToListAsync();
|
||||
var definitions = await _db.Queryable<ObTemplateVariable>()
|
||||
.Where(item => templateVersionIds.Contains(item.TemplateVersionId) && item.Required)
|
||||
.ToListAsync();
|
||||
var missing = contracts
|
||||
.SelectMany(contract => definitions
|
||||
.Where(definition => definition.TemplateVersionId == contract.TemplateVersionId)
|
||||
.Select(definition => new { contract, definition }))
|
||||
.FirstOrDefault(item => string.IsNullOrWhiteSpace(
|
||||
variables.FirstOrDefault(variable => variable.TaskContractId == item.contract.Id
|
||||
&& variable.VariableCode == item.definition.VariableCode)
|
||||
?.VariableValue));
|
||||
if (missing != null)
|
||||
throw Oops.Bah($"合同“{missing.contract.ContractName}”的必填变量“{missing.definition.VariableName}”尚未填写");
|
||||
}
|
||||
|
||||
private Task<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId)
|
||||
{
|
||||
return _db.Queryable<ObTaskMaterialRequirement>()
|
||||
|
||||
+295
@@ -0,0 +1,295 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 统一校验并规范化员工端固定入职信息。
|
||||
/// </summary>
|
||||
internal static class OnboardingEmployeeInfoValidator
|
||||
{
|
||||
private static readonly int[] IdCardWeights =
|
||||
{
|
||||
7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2
|
||||
};
|
||||
|
||||
private static readonly char[] IdCardCheckCodes =
|
||||
{
|
||||
'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 规范化文本,并校验固定登记表字段的格式。
|
||||
/// </summary>
|
||||
public static void ValidateAndNormalize(ObTaskEmployeeInfo input)
|
||||
{
|
||||
input.EmployeeName = input.EmployeeName?.Trim();
|
||||
input.Phone = input.Phone?.Trim();
|
||||
input.Email = input.Email?.Trim();
|
||||
input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant();
|
||||
input.BankCardNumber = global::System.Text.RegularExpressions.Regex.Replace(
|
||||
input.BankCardNumber ?? string.Empty,
|
||||
"[\\s-]",
|
||||
string.Empty);
|
||||
input.BankName = input.BankName?.Trim();
|
||||
input.BankAccountName = input.EmployeeName;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100)
|
||||
throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符");
|
||||
|
||||
if (input.Gender != "男" && input.Gender != "女")
|
||||
throw Oops.Bah("请选择正确的性别");
|
||||
|
||||
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
input.Phone ?? string.Empty,
|
||||
"^1[3-9]\\d{9}$"))
|
||||
{
|
||||
throw Oops.Bah("请输入正确的 11 位手机号码");
|
||||
}
|
||||
|
||||
if (!TryValidateIdCard(input.IdCardNumber, out var idCardBirthday, out var idCardError))
|
||||
throw Oops.Bah(idCardError);
|
||||
|
||||
ValidateBirthday(input.Birthday, idCardBirthday);
|
||||
ValidateOptionalEmail(input.Email);
|
||||
ValidateOptionalNumber(input.Height, "身高", 100M, 250M, "厘米");
|
||||
ValidateOptionalNumber(input.Weight, "体重", 30M, 300M, "公斤");
|
||||
ValidateBankCard(input.BankCardNumber);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.BankName) || input.BankName.Length > 100)
|
||||
throw Oops.Bah("开户银行名称不能为空且不能超过 100 个字符");
|
||||
|
||||
ValidateOptionalContactPhone(input.EmergencyPhone);
|
||||
ValidateEducationHistory(input.EducationHistoryJson);
|
||||
ValidateWorkHistory(input.WorkHistoryJson);
|
||||
ValidateFamilyMembers(input.FamilyMembersJson);
|
||||
ValidateJson(input.CustomVariablesJson, global::System.Text.Json.JsonValueKind.Object, "员工补充变量");
|
||||
}
|
||||
|
||||
private static bool TryValidateIdCard(
|
||||
string idCardNumber,
|
||||
out DateTime birthday,
|
||||
out string errorMessage)
|
||||
{
|
||||
birthday = default;
|
||||
errorMessage = null;
|
||||
|
||||
var structureIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
idCardNumber ?? string.Empty,
|
||||
"^\\d{17}[0-9X]$");
|
||||
if (!structureIsValid)
|
||||
{
|
||||
errorMessage = "请输入正确的 18 位身份证号码";
|
||||
return false;
|
||||
}
|
||||
|
||||
var birthdayText = idCardNumber.Substring(6, 8);
|
||||
var birthdayIsValid = DateTime.TryParseExact(
|
||||
birthdayText,
|
||||
"yyyyMMdd",
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
global::System.Globalization.DateTimeStyles.None,
|
||||
out birthday);
|
||||
if (!birthdayIsValid || birthday.Date > DateTime.Today)
|
||||
{
|
||||
errorMessage = "身份证号码中的出生日期不正确";
|
||||
return false;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
for (var index = 0; index < IdCardWeights.Length; index++)
|
||||
{
|
||||
total += (idCardNumber[index] - '0') * IdCardWeights[index];
|
||||
}
|
||||
|
||||
if (IdCardCheckCodes[total % 11] == idCardNumber[17])
|
||||
return true;
|
||||
|
||||
errorMessage = "身份证号码校验位不正确";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ValidateBirthday(string birthdayText, DateTime idCardBirthday)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(birthdayText))
|
||||
return;
|
||||
|
||||
var birthdayIsValid = DateTime.TryParseExact(
|
||||
birthdayText.Trim(),
|
||||
"yyyy-MM-dd",
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
global::System.Globalization.DateTimeStyles.None,
|
||||
out var birthday);
|
||||
if (!birthdayIsValid || birthday.Date > DateTime.Today)
|
||||
throw Oops.Bah("请输入正确的出生日期");
|
||||
|
||||
if (birthday.Date != idCardBirthday.Date)
|
||||
throw Oops.Bah("出生日期与身份证号码不一致");
|
||||
}
|
||||
|
||||
private static void ValidateOptionalEmail(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return;
|
||||
|
||||
var emailIsValid = email.Length <= 100
|
||||
&& global::System.Net.Mail.MailAddress.TryCreate(email, out _);
|
||||
if (!emailIsValid)
|
||||
throw Oops.Bah("请输入正确的电子邮箱");
|
||||
}
|
||||
|
||||
private static void ValidateOptionalNumber(
|
||||
string value,
|
||||
string fieldName,
|
||||
decimal min,
|
||||
decimal max,
|
||||
string unit)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
|
||||
var numberIsValid = decimal.TryParse(
|
||||
value.Trim(),
|
||||
global::System.Globalization.NumberStyles.Number,
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var number);
|
||||
if (!numberIsValid || number < min || number > max)
|
||||
{
|
||||
throw Oops.Bah($"{fieldName}应为 {min:0} 至 {max:0} {unit}之间的数字");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBankCard(string bankCardNumber)
|
||||
{
|
||||
var bankCardIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
bankCardNumber ?? string.Empty,
|
||||
"^\\d{12,19}$");
|
||||
if (!bankCardIsValid)
|
||||
throw Oops.Bah("请输入 12 至 19 位银行卡号");
|
||||
}
|
||||
|
||||
private static void ValidateOptionalContactPhone(string phone)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
return;
|
||||
|
||||
var contactPhoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
phone.Trim(),
|
||||
"^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$");
|
||||
if (!contactPhoneIsValid)
|
||||
throw Oops.Bah("请输入正确的紧急联系人手机号码或座机号码");
|
||||
}
|
||||
|
||||
private static void ValidateEducationHistory(string json)
|
||||
{
|
||||
ValidateHistoryRows(json, "教育经历", (row, rowNumber) =>
|
||||
{
|
||||
GetRequiredRowValue(row, "timeRange", rowNumber, "教育经历", "时间范围");
|
||||
GetRequiredRowValue(row, "location", rowNumber, "教育经历", "学校或机构");
|
||||
GetRequiredRowValue(row, "content", rowNumber, "教育经历", "专业或学习内容");
|
||||
GetRequiredRowValue(row, "result", rowNumber, "教育经历", "结果或学位");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ValidateWorkHistory(string json)
|
||||
{
|
||||
ValidateHistoryRows(json, "工作经历", (row, rowNumber) =>
|
||||
{
|
||||
GetRequiredRowValue(row, "timeRange", rowNumber, "工作经历", "起止时间");
|
||||
GetRequiredRowValue(row, "companyName", rowNumber, "工作经历", "工作单位");
|
||||
GetRequiredRowValue(row, "position", rowNumber, "工作经历", "担任职务");
|
||||
GetRequiredRowValue(row, "leaveReason", rowNumber, "工作经历", "离职原因");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ValidateFamilyMembers(string json)
|
||||
{
|
||||
ValidateHistoryRows(json, "家庭成员", (row, rowNumber) =>
|
||||
{
|
||||
GetRequiredRowValue(row, "name", rowNumber, "家庭成员", "姓名");
|
||||
GetRequiredRowValue(row, "relationship", rowNumber, "家庭成员", "关系");
|
||||
var ageText = GetRequiredRowValue(row, "age", rowNumber, "家庭成员", "年龄");
|
||||
var phone = GetRequiredRowValue(row, "phone", rowNumber, "家庭成员", "联系电话");
|
||||
|
||||
var ageIsValid = int.TryParse(ageText, out var age) && age >= 0 && age <= 120;
|
||||
if (!ageIsValid)
|
||||
throw Oops.Bah($"第 {rowNumber} 条家庭成员的年龄应为 0 至 120 之间的整数");
|
||||
|
||||
var phoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
phone,
|
||||
"^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$");
|
||||
if (!phoneIsValid)
|
||||
throw Oops.Bah($"第 {rowNumber} 条家庭成员的联系电话格式不正确");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ValidateHistoryRows(
|
||||
string json,
|
||||
string fieldName,
|
||||
Action<global::System.Text.Json.JsonElement, int> validateRow)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = global::System.Text.Json.JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Array)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
var rowNumber = 0;
|
||||
foreach (var row in document.RootElement.EnumerateArray())
|
||||
{
|
||||
rowNumber++;
|
||||
if (row.ValueKind != global::System.Text.Json.JsonValueKind.Object)
|
||||
throw Oops.Bah($"第 {rowNumber} 条{fieldName}格式不正确,请删除后重新添加");
|
||||
|
||||
validateRow(row, rowNumber);
|
||||
}
|
||||
}
|
||||
catch (global::System.Text.Json.JsonException)
|
||||
{
|
||||
throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRequiredRowValue(
|
||||
global::System.Text.Json.JsonElement row,
|
||||
string propertyName,
|
||||
int rowNumber,
|
||||
string fieldName,
|
||||
string displayName)
|
||||
{
|
||||
if (!row.TryGetProperty(propertyName, out var property)
|
||||
|| property.ValueKind is global::System.Text.Json.JsonValueKind.Null
|
||||
or global::System.Text.Json.JsonValueKind.Undefined)
|
||||
{
|
||||
throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}");
|
||||
}
|
||||
|
||||
var value = property.ValueKind == global::System.Text.Json.JsonValueKind.String
|
||||
? property.GetString()
|
||||
: property.ToString();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}");
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static void ValidateJson(
|
||||
string json,
|
||||
global::System.Text.Json.JsonValueKind expectedKind,
|
||||
string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = global::System.Text.Json.JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != expectedKind)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
}
|
||||
catch (global::System.Text.Json.JsonException)
|
||||
{
|
||||
throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写");
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-41
@@ -30,6 +30,9 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
|
||||
?? throw Oops.Bah("入职任务不存在");
|
||||
|
||||
// 员工端必须展示当前 Token 的实际到期时间,不能依赖任务表中的冗余时间。
|
||||
task.LinkExpireAt = link.ExpiresAt;
|
||||
|
||||
link.AccessCount++;
|
||||
link.LastAccessedAt = DateTime.Now;
|
||||
await _db.Updateable(link).ExecuteCommandAsync();
|
||||
@@ -131,7 +134,7 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
throw Oops.Bah("当前任务不可编辑");
|
||||
}
|
||||
|
||||
ValidateEmployeeInfo(input);
|
||||
OnboardingEmployeeInfoValidator.ValidateAndNormalize(input);
|
||||
await _identityValidationService.EnsureNoDuplicate(
|
||||
task.Id,
|
||||
input.EmployeeName,
|
||||
@@ -170,46 +173,6 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
await _snapshotService.Build(task.Id, entity, invalidateGeneratedPreview: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验候选人核心身份信息,避免非法格式进入合同和正式员工档案。
|
||||
/// </summary>
|
||||
private static void ValidateEmployeeInfo(OnboardingTaskPublicSaveInput input)
|
||||
{
|
||||
input.EmployeeName = input.EmployeeName?.Trim();
|
||||
input.Phone = input.Phone?.Trim();
|
||||
input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100)
|
||||
throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符");
|
||||
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
input.Phone ?? string.Empty,
|
||||
"^1[3-9]\\d{9}$"))
|
||||
{
|
||||
throw Oops.Bah("请输入正确的 11 位手机号码");
|
||||
}
|
||||
|
||||
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
input.IdCardNumber ?? string.Empty,
|
||||
"^\\d{17}[0-9X]$"))
|
||||
{
|
||||
throw Oops.Bah("请输入正确的 18 位身份证号码");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.CustomVariablesJson))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = global::System.Text.Json.JsonDocument.Parse(input.CustomVariablesJson);
|
||||
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Object)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
}
|
||||
catch (global::System.Text.Json.JsonException)
|
||||
{
|
||||
throw Oops.Bah("员工补充变量格式不正确,请刷新页面后重新填写");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据员工当前已填写信息校验变量并提前生成合同 DOCX。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,6 +25,11 @@ public class OnboardingTaskCreateInput
|
||||
[IdNotNull(ErrorMessage = "岗位Id不能为空")]
|
||||
public long PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用工类型:full_time 正式用工,part_time 兼职用工。
|
||||
/// </summary>
|
||||
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
|
||||
|
||||
/// <summary>
|
||||
/// 本次任务使用的入职模板 Id。
|
||||
/// </summary>
|
||||
@@ -47,6 +54,41 @@ public class OnboardingTaskCreateInput
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "人事备注不能为空")]
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量复制入职任务参数。
|
||||
/// </summary>
|
||||
public class OnboardingTaskCopyInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 被复制的原入职任务 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "原入职任务Id不能为空")]
|
||||
public long SourceTaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 在原任务之外需要新建的任务数量。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每条新任务分别填写的人事备注。
|
||||
/// </summary>
|
||||
public List<OnboardingTaskCopyItemInput> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单条复制任务的个性化参数。
|
||||
/// </summary>
|
||||
public class OnboardingTaskCopyItemInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 新任务的人事备注,用于区分候选人和专属链接。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "人事备注不能为空")]
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
|
||||
@@ -96,4 +138,14 @@ public class OnboardingTaskCreatedOutput
|
||||
/// 员工填写链接过期时间。
|
||||
/// </summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 批量复制入职任务结果。
|
||||
/// </summary>
|
||||
public class OnboardingTaskCopyOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 本次复制生成的全部新任务及独立链接信息。
|
||||
/// </summary>
|
||||
public List<OnboardingTaskCreatedOutput> Tasks { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -79,24 +79,6 @@ public class OnboardingTaskPublicSaveInput : ObTaskEmployeeInfo
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public string Token { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工姓名。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public new string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public new string Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 身份证号码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public new string IdCardNumber { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,4 +9,9 @@ public interface IOnboardingTaskService : ITransient
|
||||
/// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。
|
||||
/// </summary>
|
||||
Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 复制原任务的业务配置和人事参数,批量创建独立的新入职任务。
|
||||
/// </summary>
|
||||
Task<OnboardingTaskCopyOutput> Copy(OnboardingTaskCopyInput input);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
/// </summary>
|
||||
public async Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Remark))
|
||||
throw Oops.Bah("请填写人事备注");
|
||||
|
||||
ValidateEmploymentType(input.EmploymentType);
|
||||
|
||||
var companyEnabled = await _db.Queryable<ObCompany>()
|
||||
.AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable");
|
||||
if (!companyEnabled)
|
||||
@@ -72,7 +77,10 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
if (contractTemplates.Count != templateIds.Count
|
||||
|| contractTemplates.Any(item => item.CompanyId != input.CompanyId
|
||||
|| item.DepartmentId != input.DepartmentId
|
||||
|| item.Status != "enable"))
|
||||
|| !string.Equals(
|
||||
item.Status,
|
||||
"enable",
|
||||
global::System.StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板");
|
||||
}
|
||||
@@ -117,11 +125,12 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
CompanyId = input.CompanyId,
|
||||
DepartmentId = input.DepartmentId,
|
||||
PositionId = input.PositionId,
|
||||
EmploymentType = input.EmploymentType,
|
||||
OnboardingTemplateId = onboardingTemplate.Id,
|
||||
AssignedReviewerUserId = input.AssignedReviewerUserId,
|
||||
LinkExpireAt = expiresAt,
|
||||
TaskStatus = OnboardingTaskStatus.PendingFill,
|
||||
Remark = input.Remark
|
||||
Remark = input.Remark.Trim()
|
||||
};
|
||||
|
||||
ValidateInitialContractDateRanges(input.ContractVariables);
|
||||
@@ -207,6 +216,233 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制原任务锁定的业务配置与人事参数,并原子化创建多条独立任务。
|
||||
/// </summary>
|
||||
public async Task<OnboardingTaskCopyOutput> Copy(OnboardingTaskCopyInput input)
|
||||
{
|
||||
const int maxCopyCount = 50;
|
||||
if (input.Count < 1 || input.Count > maxCopyCount)
|
||||
throw Oops.Bah($"复制数量必须在1到{maxCopyCount}之间");
|
||||
|
||||
input.Items ??= new List<OnboardingTaskCopyItemInput>();
|
||||
if (input.Items.Count != input.Count)
|
||||
throw Oops.Bah("复制数量与人事备注数量不一致");
|
||||
|
||||
var missingRemarkIndex = input.Items.FindIndex(item =>
|
||||
string.IsNullOrWhiteSpace(item.Remark));
|
||||
if (missingRemarkIndex >= 0)
|
||||
throw Oops.Bah($"请填写第{missingRemarkIndex + 1}份任务的人事备注");
|
||||
|
||||
var sourceTask = await _db.Queryable<ObOnboardingTask>()
|
||||
.InSingleAsync(input.SourceTaskId)
|
||||
?? throw Oops.Bah("原入职任务不存在");
|
||||
if (!sourceTask.DepartmentId.HasValue)
|
||||
throw Oops.Bah("原入职任务未设置部门,不能复制");
|
||||
|
||||
var companyEnabled = await _db.Queryable<ObCompany>()
|
||||
.AnyAsync(item => item.Id == sourceTask.CompanyId && item.Status == "enable");
|
||||
if (!companyEnabled)
|
||||
throw Oops.Bah("原任务所属公司不存在或未启用");
|
||||
|
||||
var departmentEnabled = await _db.Queryable<ObDepartment>()
|
||||
.AnyAsync(item => item.Id == sourceTask.DepartmentId.Value
|
||||
&& item.CompanyId == sourceTask.CompanyId
|
||||
&& item.Status == "enable");
|
||||
if (!departmentEnabled)
|
||||
throw Oops.Bah("原任务所属部门不存在或未启用");
|
||||
|
||||
var positionEnabled = await _db.Queryable<ObPosition>()
|
||||
.AnyAsync(item => item.Id == sourceTask.PositionId
|
||||
&& item.CompanyId == sourceTask.CompanyId
|
||||
&& item.DepartmentId == sourceTask.DepartmentId.Value
|
||||
&& item.Status == "enable");
|
||||
if (!positionEnabled)
|
||||
throw Oops.Bah("原任务所属岗位不存在或未启用");
|
||||
|
||||
var onboardingTemplateEnabled = await _db.Queryable<ObOnboardingTemplate>()
|
||||
.AnyAsync(item => item.Id == sourceTask.OnboardingTemplateId
|
||||
&& item.CompanyId == sourceTask.CompanyId
|
||||
&& item.DepartmentId == sourceTask.DepartmentId.Value
|
||||
&& item.Status == "enable");
|
||||
if (!onboardingTemplateEnabled)
|
||||
throw Oops.Bah("原任务使用的入职模板不存在或未启用");
|
||||
|
||||
var sourceContracts = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == sourceTask.Id)
|
||||
.ToListAsync();
|
||||
if (sourceContracts.Count == 0)
|
||||
throw Oops.Bah("原入职任务没有合同快照,不能复制");
|
||||
|
||||
var versionIds = sourceContracts
|
||||
.Select(item => item.TemplateVersionId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var enabledVersionCount = await _db.Queryable<ObContractTemplateVersion>()
|
||||
.CountAsync(item => versionIds.Contains(item.Id)
|
||||
&& item.VersionStatus == ContractTemplateVersionStatus.Enabled);
|
||||
if (enabledVersionCount != versionIds.Count)
|
||||
throw Oops.Bah("原任务包含已停用的合同版本,不能继续复制");
|
||||
|
||||
var sourceMaterialRequirements = await _db.Queryable<ObTaskMaterialRequirement>()
|
||||
.Where(item => item.TaskId == sourceTask.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.ToListAsync();
|
||||
var sourceContractIds = sourceContracts.Select(item => item.Id).ToList();
|
||||
var sourceManualVariables = await _db.Queryable<ObTaskContractVariable>()
|
||||
.Where(item => sourceContractIds.Contains(item.TaskContractId)
|
||||
&& item.SourceType == ContractVariableSourceType.HrInput
|
||||
&& item.IsManualOverride)
|
||||
.ToListAsync();
|
||||
|
||||
var initialContractVariables = (
|
||||
from contract in sourceContracts
|
||||
join variable in sourceManualVariables
|
||||
on contract.Id equals variable.TaskContractId
|
||||
select new OnboardingInitialContractVariableInput
|
||||
{
|
||||
ContractTemplateVersionId = contract.TemplateVersionId,
|
||||
VariableCode = variable.VariableCode,
|
||||
VariableValue = variable.VariableValue
|
||||
}).ToList();
|
||||
ValidateInitialContractDateRanges(initialContractVariables);
|
||||
|
||||
var expiryConfig = await _configService.GetByConfigKey(
|
||||
CateGoryConst.CONFIG_BIZ_DEFINE,
|
||||
"onboard.link.expireHours");
|
||||
if (expiryConfig == null
|
||||
|| !int.TryParse(expiryConfig.ConfigValue, out var expiryHours)
|
||||
|| expiryHours <= 0)
|
||||
{
|
||||
throw Oops.Bah("请在系统配置中设置 onboard.link.expireHours(正整数小时)");
|
||||
}
|
||||
|
||||
var outputs = new List<OnboardingTaskCreatedOutput>();
|
||||
await _db.Ado.BeginTranAsync();
|
||||
try
|
||||
{
|
||||
foreach (var item in input.Items)
|
||||
{
|
||||
var output = await CreateCopiedTask(
|
||||
sourceTask,
|
||||
sourceContracts,
|
||||
sourceMaterialRequirements,
|
||||
initialContractVariables,
|
||||
expiryHours,
|
||||
item.Remark.Trim());
|
||||
outputs.Add(output);
|
||||
}
|
||||
|
||||
await _db.Ado.CommitTranAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _db.Ado.RollbackTranAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new OnboardingTaskCopyOutput
|
||||
{
|
||||
Tasks = outputs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据原任务快照创建一条全新的待填写任务,不复制任何候选人业务数据。
|
||||
/// </summary>
|
||||
private async Task<OnboardingTaskCreatedOutput> CreateCopiedTask(
|
||||
ObOnboardingTask sourceTask,
|
||||
List<ObTaskContract> sourceContracts,
|
||||
List<ObTaskMaterialRequirement> sourceMaterialRequirements,
|
||||
List<OnboardingInitialContractVariableInput> initialContractVariables,
|
||||
int expiryHours,
|
||||
string remark)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
|
||||
var expiresAt = now.AddHours(expiryHours);
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var task = new ObOnboardingTask
|
||||
{
|
||||
Id = taskId,
|
||||
TaskNo = $"OB{now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}",
|
||||
CompanyId = sourceTask.CompanyId,
|
||||
DepartmentId = sourceTask.DepartmentId,
|
||||
PositionId = sourceTask.PositionId,
|
||||
EmploymentType = string.IsNullOrWhiteSpace(sourceTask.EmploymentType)
|
||||
? EmployeeEmploymentType.FullTime
|
||||
: sourceTask.EmploymentType,
|
||||
OnboardingTemplateId = sourceTask.OnboardingTemplateId,
|
||||
AssignedReviewerUserId = sourceTask.AssignedReviewerUserId,
|
||||
LinkExpireAt = expiresAt,
|
||||
TaskStatus = OnboardingTaskStatus.PendingFill,
|
||||
Remark = remark
|
||||
};
|
||||
|
||||
await _db.Insertable(task).ExecuteCommandAsync();
|
||||
await _db.Insertable(new ObTaskLink
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
PlainToken = token,
|
||||
TokenHash = Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(
|
||||
global::System.Text.Encoding.UTF8.GetBytes(token))),
|
||||
ExpiresAt = expiresAt
|
||||
}).ExecuteCommandAsync();
|
||||
|
||||
await _db.Insertable(new ObTaskEmployeeInfo
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
Status = CommonStatusConst.ENABLE,
|
||||
CreateTime = now
|
||||
}).ExecuteCommandAsync();
|
||||
|
||||
var copiedContracts = sourceContracts.Select(source => new ObTaskContract
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
TemplateId = source.TemplateId,
|
||||
TemplateVersionId = source.TemplateVersionId,
|
||||
ContractName = source.ContractName,
|
||||
ContractType = source.ContractType,
|
||||
Required = source.Required,
|
||||
PrintCopies = source.PrintCopies
|
||||
}).ToList();
|
||||
await _db.Insertable(copiedContracts).ExecuteCommandAsync();
|
||||
|
||||
if (sourceMaterialRequirements.Count > 0)
|
||||
{
|
||||
var copiedRequirements = sourceMaterialRequirements.Select(source =>
|
||||
new ObTaskMaterialRequirement
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
MaterialTypeId = source.MaterialTypeId,
|
||||
MaterialCode = source.MaterialCode,
|
||||
MaterialName = source.MaterialName,
|
||||
AllowedExtensions = source.AllowedExtensions,
|
||||
MaxFileCount = source.MaxFileCount,
|
||||
MaxFileSizeMb = source.MaxFileSizeMb,
|
||||
Required = source.Required,
|
||||
SortCode = source.SortCode
|
||||
}).ToList();
|
||||
await _db.Insertable(copiedRequirements).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
// 重新生成公司、系统和空员工变量,再覆盖原任务的人事参数。
|
||||
await _snapshotService.Build(taskId);
|
||||
await ApplyInitialContractVariables(initialContractVariables, copiedContracts);
|
||||
|
||||
return new OnboardingTaskCreatedOutput
|
||||
{
|
||||
TaskId = taskId,
|
||||
TaskNo = task.TaskNo,
|
||||
LinkToken = token,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验并保存任务创建时填写的人事合同变量。
|
||||
/// </summary>
|
||||
@@ -289,7 +525,7 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
/// <summary>
|
||||
/// 校验人事创建任务时填写的合同日期先后关系。
|
||||
/// </summary>
|
||||
private static void ValidateInitialContractDateRanges(
|
||||
internal static void ValidateInitialContractDateRanges(
|
||||
List<OnboardingInitialContractVariableInput> inputs)
|
||||
{
|
||||
inputs ??= new List<OnboardingInitialContractVariableInput>();
|
||||
@@ -323,4 +559,13 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
if (endDate.Date < startDate.Date)
|
||||
throw Oops.Bah($"{rangeName}结束日期不能早于开始日期");
|
||||
}
|
||||
|
||||
private static void ValidateEmploymentType(string employmentType)
|
||||
{
|
||||
if (employmentType != EmployeeEmploymentType.FullTime
|
||||
&& employmentType != EmployeeEmploymentType.PartTime)
|
||||
{
|
||||
throw Oops.Bah("用工类型不正确,请选择正式员工或兼职员工");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-1
@@ -1,4 +1,4 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务分页查询参数。
|
||||
@@ -24,6 +24,64 @@ public class OnboardingTaskPageInput : BasePageInput
|
||||
/// 入职业务部门 Id。
|
||||
/// </summary>
|
||||
public long? DepartmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务岗位 Id。
|
||||
/// </summary>
|
||||
public long? PositionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务分页数据及当前查询条件下的状态统计。
|
||||
/// </summary>
|
||||
public class OnboardingTaskPageOutput : SqlSugarPagedList<ObOnboardingTask>
|
||||
{
|
||||
/// <summary>
|
||||
/// 不受状态筛选影响的任务状态统计。
|
||||
/// </summary>
|
||||
public OnboardingTaskStatisticsOutput Statistics { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务状态统计。
|
||||
/// </summary>
|
||||
public class OnboardingTaskStatisticsOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 待候选人填写数量。
|
||||
/// </summary>
|
||||
public int PendingFill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待人事审核数量。
|
||||
/// </summary>
|
||||
public int Reviewing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已退回待修改数量。
|
||||
/// </summary>
|
||||
public int Rejected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过或已归档数量。
|
||||
/// </summary>
|
||||
public int Approved { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据库按任务状态分组后的内部统计行。
|
||||
/// </summary>
|
||||
public class OnboardingTaskStatusCountOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务状态。
|
||||
/// </summary>
|
||||
public string TaskStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 该状态的任务数量。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -76,4 +134,9 @@ public class OnboardingTaskMaterialDetailOutput : ObTaskMaterial
|
||||
/// 材料文件后缀,用于判断是否支持图片预览。
|
||||
/// </summary>
|
||||
public string FileSuffix { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 格式化后的文件大小,例如 1.5MB。
|
||||
/// </summary>
|
||||
public string FileSizeInfo { get; set; }
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ public interface IOnboardingTaskQueryService : ITransient
|
||||
/// <summary>
|
||||
/// 分页查询后台入职任务。
|
||||
/// </summary>
|
||||
Task<SqlSugarPagedList<ObOnboardingTask>> Page(OnboardingTaskPageInput input);
|
||||
Task<OnboardingTaskPageOutput> Page(OnboardingTaskPageInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 查询任务基础信息、人员资料、合同、材料、表单值、链接和审核记录。
|
||||
|
||||
+75
-8
@@ -17,22 +17,78 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
|
||||
/// <summary>
|
||||
/// 分页查询后台入职任务。
|
||||
/// </summary>
|
||||
public async Task<SqlSugarPagedList<ObOnboardingTask>> Page(OnboardingTaskPageInput input)
|
||||
public async Task<OnboardingTaskPageOutput> Page(OnboardingTaskPageInput input)
|
||||
{
|
||||
const string approvedGroupStatus = "approved_or_archived";
|
||||
var filterApprovedGroup = string.Equals(
|
||||
input.TaskStatus,
|
||||
approvedGroupStatus,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var list = await _db.Queryable<ObOnboardingTask>()
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo), item => item.TaskNo.Contains(input.TaskNo))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskStatus), item => item.TaskStatus == input.TaskStatus)
|
||||
.WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value)
|
||||
.WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value)
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo),
|
||||
item => item.TaskNo.Contains(input.TaskNo))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskStatus) && !filterApprovedGroup,
|
||||
item => item.TaskStatus == input.TaskStatus)
|
||||
.WhereIF(filterApprovedGroup,
|
||||
item => item.TaskStatus == OnboardingTaskStatus.Approved
|
||||
|| item.TaskStatus == OnboardingTaskStatus.Archived)
|
||||
.WhereIF(input.CompanyId.HasValue,
|
||||
item => item.CompanyId == input.CompanyId.Value)
|
||||
.WhereIF(input.DepartmentId.HasValue,
|
||||
item => item.DepartmentId == input.DepartmentId.Value)
|
||||
.WhereIF(input.PositionId.HasValue,
|
||||
item => item.PositionId == input.PositionId.Value)
|
||||
.OrderByDescending(item => item.CreateTime)
|
||||
.ToPagedListAsync(input.PageNum, input.PageSize);
|
||||
|
||||
if (list.List != null && list.List.Any())
|
||||
{
|
||||
await FillTaskMetadata(list.List);
|
||||
}
|
||||
|
||||
return list;
|
||||
// 统计继续应用组织、岗位和任务编号条件,但不受当前状态筛选影响。
|
||||
var statusRows = await _db.Queryable<ObOnboardingTask>()
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo),
|
||||
item => item.TaskNo.Contains(input.TaskNo))
|
||||
.WhereIF(input.CompanyId.HasValue,
|
||||
item => item.CompanyId == input.CompanyId.Value)
|
||||
.WhereIF(input.DepartmentId.HasValue,
|
||||
item => item.DepartmentId == input.DepartmentId.Value)
|
||||
.WhereIF(input.PositionId.HasValue,
|
||||
item => item.PositionId == input.PositionId.Value)
|
||||
.GroupBy(item => item.TaskStatus)
|
||||
.Select(item => new OnboardingTaskStatusCountOutput
|
||||
{
|
||||
TaskStatus = item.TaskStatus,
|
||||
Count = SqlFunc.AggregateCount(item.Id)
|
||||
})
|
||||
.ToListAsync();
|
||||
var statusCountMap = statusRows.ToDictionary(
|
||||
item => item.TaskStatus,
|
||||
item => item.Count);
|
||||
|
||||
return new OnboardingTaskPageOutput
|
||||
{
|
||||
PageNum = list.PageNum,
|
||||
PageSize = list.PageSize,
|
||||
Total = list.Total,
|
||||
Pages = list.Pages,
|
||||
List = list.List,
|
||||
HasPrevPages = list.HasPrevPages,
|
||||
HasNextPages = list.HasNextPages,
|
||||
Statistics = new OnboardingTaskStatisticsOutput
|
||||
{
|
||||
PendingFill = statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.PendingFill),
|
||||
Reviewing = statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.Reviewing),
|
||||
Rejected = statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.Rejected),
|
||||
Approved = statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.Approved)
|
||||
+ statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.Archived)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,6 +124,7 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
|
||||
{
|
||||
output.FileName = file.Name;
|
||||
output.FileSuffix = file.Suffix;
|
||||
output.FileSizeInfo = file.SizeInfo;
|
||||
}
|
||||
|
||||
return output;
|
||||
@@ -116,6 +173,16 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
|
||||
|
||||
if (activeLink != null)
|
||||
{
|
||||
// 修复历史数据或旧页面造成的冗余到期时间不一致。
|
||||
var activeExpiresAt = activeLink.ExpiresAt;
|
||||
if (task.LinkExpireAt != activeExpiresAt)
|
||||
{
|
||||
await _db.Updateable<ObOnboardingTask>()
|
||||
.SetColumns(item => item.LinkExpireAt == activeExpiresAt)
|
||||
.Where(item => item.Id == task.Id)
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
return new OnboardingTaskCreatedOutput
|
||||
{
|
||||
TaskId = task.Id,
|
||||
|
||||
@@ -115,10 +115,26 @@ public class FileService : DbRepository<SysFile>, IFileService
|
||||
IFormFile file = new FormFile(stream, 0, content.Length, "file", fileName)
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
ContentType = ResolveGeneratedContentType(fileName)
|
||||
};
|
||||
return await StorageFile(engine, file);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据归档文件扩展名设置 OSS 元数据,避免 PDF、ZIP 被错误标记为 DOCX。
|
||||
/// </summary>
|
||||
private static string ResolveGeneratedContentType(string fileName)
|
||||
{
|
||||
return Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||
{
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".pdf" => "application/pdf",
|
||||
".zip" => "application/zip",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
#region 方法
|
||||
|
||||
/// <summary>
|
||||
|
||||
+32
@@ -18,6 +18,38 @@ public class OnboardingContractDocumentController
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建入职任务前,按人事当前填写的参数下载指定 DOCX 合同预览。
|
||||
/// </summary>
|
||||
[HttpPost("preview-before-create")]
|
||||
[DisplayName("创建入职任务前预览合同")]
|
||||
public async Task<FileContentResult> PreviewBeforeCreate(
|
||||
[FromBody] OnboardingContractPreviewInput input)
|
||||
{
|
||||
var preview = await _service.PreviewBeforeCreate(input);
|
||||
return new FileContentResult(
|
||||
preview.FileBytes,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||||
{
|
||||
FileDownloadName = preview.FileName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建入职任务前,将全部 DOCX 合同预览打包下载。
|
||||
/// </summary>
|
||||
[HttpPost("preview-all-before-create")]
|
||||
[DisplayName("创建入职任务前预览全部合同")]
|
||||
public async Task<FileContentResult> PreviewAllBeforeCreate(
|
||||
[FromBody] OnboardingContractPreviewInput input)
|
||||
{
|
||||
var preview = await _service.PreviewAllBeforeCreate(input);
|
||||
return new FileContentResult(preview.FileBytes, "application/zip")
|
||||
{
|
||||
FileDownloadName = preview.FileName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成入职合同文件
|
||||
/// </summary>
|
||||
|
||||
+5
@@ -64,6 +64,7 @@ public class OnboardingContractTemplateController
|
||||
/// 查询入职模板当前公司、部门及岗位可选择的合同模板。
|
||||
/// </summary>
|
||||
[HttpPost("list-available")]
|
||||
[DisplayName("查询可选合同模板")]
|
||||
public async Task<dynamic> ListAvailable([FromBody] OnboardingContractTemplateAvailableInput input)
|
||||
{
|
||||
return await _service.ListAvailable(input);
|
||||
@@ -113,6 +114,7 @@ public class OnboardingContractTemplateController
|
||||
/// 新增合同模板版本。
|
||||
/// </summary>
|
||||
[HttpPost("version/add")]
|
||||
[DisplayName("新增合同模板版本")]
|
||||
public async Task AddVersion([FromBody] OnboardingContractVersionAddInput input)
|
||||
{
|
||||
await _service.AddVersion(input);
|
||||
@@ -122,6 +124,7 @@ public class OnboardingContractTemplateController
|
||||
/// 设置合同模板版本状态。
|
||||
/// </summary>
|
||||
[HttpPost("version/set-status")]
|
||||
[DisplayName("设置合同模板版本状态")]
|
||||
public async Task SetVersionStatus([FromBody] OnboardingContractVersionStatusInput input)
|
||||
{
|
||||
await _service.SetVersionStatus(input);
|
||||
@@ -131,6 +134,7 @@ public class OnboardingContractTemplateController
|
||||
/// 删除未被入职模板或任务使用的合同模板版本。
|
||||
/// </summary>
|
||||
[HttpPost("version/delete")]
|
||||
[DisplayName("删除合同模板版本")]
|
||||
public async Task DeleteVersion([FromBody] BaseIdInput input)
|
||||
{
|
||||
await _service.DeleteVersion(input);
|
||||
@@ -150,6 +154,7 @@ public class OnboardingContractTemplateController
|
||||
/// 编辑模板变量。
|
||||
/// </summary>
|
||||
[HttpPost("variable/edit")]
|
||||
[DisplayName("编辑合同模板变量")]
|
||||
public async Task EditVariable([FromBody] OnboardingTemplateVariableEditInput input)
|
||||
{
|
||||
await _service.EditVariable(input);
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using System.Text;
|
||||
|
||||
namespace SimpleAdmin.Web.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 接收契约锁服务器状态通知;第一版按平台要求使用明文回调。
|
||||
/// </summary>
|
||||
[ApiDescriptionSettings("Application", Tag = "入职电子签回调")]
|
||||
[Route("open/onboarding/qiyuesuo")]
|
||||
[AllowAnonymous]
|
||||
public class OnboardingElectronicSignCallbackController
|
||||
{
|
||||
private readonly IOnboardingElectronicSignService _service;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public OnboardingElectronicSignCallbackController(
|
||||
IOnboardingElectronicSignService service,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_service = service;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 接收契约锁回调并返回平台可识别的非统一响应。
|
||||
/// </summary>
|
||||
[HttpPost("callback")]
|
||||
[NonUnify]
|
||||
public async Task<IActionResult> Callback()
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = _httpContextAccessor.HttpContext?.Request
|
||||
?? throw new InvalidOperationException("当前请求上下文不存在");
|
||||
var payloadJson = await ReadPayload(request);
|
||||
await _service.HandleCallback(payloadJson);
|
||||
return new OkObjectResult(new
|
||||
{
|
||||
code = 0,
|
||||
message = "success"
|
||||
});
|
||||
}
|
||||
catch (global::System.Exception exception)
|
||||
{
|
||||
// 契约锁按 HTTP 状态码判断回调是否成功;返回 500 后平台会继续重试。
|
||||
return new ObjectResult(new
|
||||
{
|
||||
code = -1,
|
||||
message = exception.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status500InternalServerError
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁明文回调使用表单格式;同时兼容测试工具直接发送 JSON。
|
||||
/// </summary>
|
||||
private static async Task<string> ReadPayload(HttpRequest request)
|
||||
{
|
||||
if (request.HasFormContentType)
|
||||
{
|
||||
var form = await request.ReadFormAsync();
|
||||
var values = form
|
||||
.OrderBy(item => item.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
item => item.Key,
|
||||
item => item.Value.ToString());
|
||||
return global::System.Text.Json.JsonSerializer.Serialize(values);
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(
|
||||
request.Body,
|
||||
Encoding.UTF8,
|
||||
true,
|
||||
leaveOpen: true);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace SimpleAdmin.Web.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 提供入职任务电子签管理接口。
|
||||
/// </summary>
|
||||
[ApiDescriptionSettings("Application", Tag = "入职电子签约")]
|
||||
[Route("biz/onboarding/electronic-sign")]
|
||||
[RolePermission]
|
||||
public class OnboardingElectronicSignController
|
||||
{
|
||||
private readonly IOnboardingElectronicSignService _service;
|
||||
|
||||
public OnboardingElectronicSignController(
|
||||
IOnboardingElectronicSignService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询入职任务的电子签状态和合同文件。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[DisplayName("查看入职电子签详情")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> Detail(
|
||||
[FromQuery] BaseIdInput input)
|
||||
{
|
||||
return await _service.Detail(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动发起或继续上次失败的契约锁流程。
|
||||
/// </summary>
|
||||
[HttpPost("initiate")]
|
||||
[DisplayName("发起入职电子签")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> Initiate(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
return await _service.Initiate(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保留原流程记录并创建一次全新的契约锁签约流程。
|
||||
/// </summary>
|
||||
[HttpPost("reinitiate")]
|
||||
[DisplayName("重新发起入职电子签")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> Reinitiate(
|
||||
[FromBody] OnboardingElectronicSignReinitiateInput input)
|
||||
{
|
||||
return await _service.Reinitiate(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主动同步契约锁状态,完成后自动归档。
|
||||
/// </summary>
|
||||
[HttpPost("sync")]
|
||||
[DisplayName("同步入职电子签状态")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> Sync(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
return await _service.Sync(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取仍然有效的员工签署地址,过期则重新生成。
|
||||
/// </summary>
|
||||
[HttpPost("get-link")]
|
||||
[DisplayName("获取员工电子签链接")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> GetSignLink(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
return await _service.GetSignLink(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取员工已签、企业未盖章文件的只读浏览页面。
|
||||
/// </summary>
|
||||
[HttpPost("preview-employee-signed")]
|
||||
[DisplayName("预览员工已签合同")]
|
||||
public async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
|
||||
[FromBody] OnboardingElectronicSignPreviewInput input)
|
||||
{
|
||||
return await _service.PreviewEmployeeSignedDocument(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部文件核对完成后,由后台管理员确认企业盖章。
|
||||
/// </summary>
|
||||
[HttpPost("company-seal")]
|
||||
[DisplayName("确认入职合同企业盖章")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> CompanySeal(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
return await _service.CompanySeal(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 催办当前待签员工。
|
||||
/// </summary>
|
||||
[HttpPost("notice")]
|
||||
[DisplayName("催办员工电子签")]
|
||||
public async Task Notice(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
await _service.Notice(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 作废尚未结束的电子签流程。
|
||||
/// </summary>
|
||||
[HttpPost("invalid")]
|
||||
[DisplayName("作废入职电子签")]
|
||||
public async Task Invalid(
|
||||
[FromBody] OnboardingElectronicSignInvalidInput input)
|
||||
{
|
||||
await _service.Invalid(input);
|
||||
}
|
||||
}
|
||||
+80
-5
@@ -2,37 +2,112 @@ using System.ComponentModel;
|
||||
namespace SimpleAdmin.Web.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 提供正式员工档案相关 HTTP 接口。
|
||||
/// 提供员工档案及人事维护相关 HTTP 接口。
|
||||
/// </summary>
|
||||
[ApiDescriptionSettings("Application", Tag = "入职正式员工档案")]
|
||||
[ApiDescriptionSettings("Application", Tag = "入职员工档案")]
|
||||
[Route("biz/onboarding/employee")]
|
||||
[RolePermission]
|
||||
public class OnboardingEmployeeController
|
||||
{
|
||||
private readonly IOnboardingEmployeeService _service;
|
||||
private readonly IFileService _fileService;
|
||||
|
||||
public OnboardingEmployeeController(IOnboardingEmployeeService service)
|
||||
public OnboardingEmployeeController(
|
||||
IOnboardingEmployeeService service,
|
||||
IFileService fileService)
|
||||
{
|
||||
_service = service;
|
||||
_fileService = fileService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询正式员工档案。
|
||||
/// </summary>
|
||||
[HttpGet("page")]
|
||||
[DisplayName("分页查询正式员工档案")]
|
||||
[DisplayName("分页查询员工档案")]
|
||||
public async Task<dynamic> Page([FromQuery] OnboardingEmployeePageInput input)
|
||||
{
|
||||
return await _service.Page(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按当前列表筛选条件导出员工基本信息和合同快照。
|
||||
/// </summary>
|
||||
[HttpPost("export")]
|
||||
[DisplayName("导出员工明细")]
|
||||
public async Task<FileContentResult> Export([FromBody] OnboardingEmployeePageInput input)
|
||||
{
|
||||
var file = await _service.Export(input);
|
||||
return new FileContentResult(
|
||||
file.FileBytes,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
{
|
||||
FileDownloadName = file.FileName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询正式员工详情及来源记录。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[DisplayName("查询正式员工详情")]
|
||||
[DisplayName("查询员工档案详情")]
|
||||
public async Task<dynamic> Detail([FromQuery] BaseIdInput input)
|
||||
{
|
||||
return await _service.Detail(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑员工用工、社保及合同性质等人事信息。
|
||||
/// </summary>
|
||||
[HttpPost("hr-info/update")]
|
||||
[DisplayName("编辑员工人事信息")]
|
||||
public async Task UpdateHrInfo([FromBody] OnboardingEmployeeHrUpdateInput input)
|
||||
{
|
||||
await _service.UpdateHrInfo(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传转正等员工人事材料。
|
||||
/// </summary>
|
||||
[HttpPost("attachment/upload")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
[DisplayName("上传员工人事材料")]
|
||||
public async Task<long> UploadAttachment([FromForm] IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
throw Oops.Bah("请选择需要上传的材料文件");
|
||||
|
||||
var allowedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
||||
".jpg", ".jpeg", ".png", ".webp", ".zip"
|
||||
};
|
||||
var extension = Path.GetExtension(file.FileName);
|
||||
if (!allowedExtensions.Contains(extension))
|
||||
throw Oops.Bah("仅支持 PDF、Word、Excel、图片或 ZIP 文件");
|
||||
|
||||
return await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 人工办理员工转正。
|
||||
/// </summary>
|
||||
[HttpPost("regularization/confirm")]
|
||||
[DisplayName("办理员工转正")]
|
||||
public async Task Regularize([FromBody] OnboardingEmployeeRegularizeInput input)
|
||||
{
|
||||
await _service.Regularize(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销员工转正。
|
||||
/// </summary>
|
||||
[HttpPost("regularization/revoke")]
|
||||
[DisplayName("撤销员工转正")]
|
||||
public async Task RevokeRegularization(
|
||||
[FromBody] OnboardingEmployeeRegularizationRevokeInput input)
|
||||
{
|
||||
await _service.RevokeRegularization(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace SimpleAdmin.Web.Core;
|
||||
|
||||
/// <summary>
|
||||
@@ -25,4 +26,14 @@ public class OnboardingTaskController
|
||||
{
|
||||
return await _service.Create(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在原任务之外批量复制独立的新入职任务。
|
||||
/// </summary>
|
||||
[HttpPost("copy")]
|
||||
[DisplayName("复制入职任务")]
|
||||
public async Task<dynamic> Copy([FromBody] OnboardingTaskCopyInput input)
|
||||
{
|
||||
return await _service.Copy(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ public class BizUserController : IDynamicApiController
|
||||
/// <returns></returns>
|
||||
[HttpGet(template: "template")]
|
||||
[SuppressMonitor]
|
||||
[DisplayName("下载人员导入模板")]
|
||||
public async Task<dynamic> Template()
|
||||
{
|
||||
return await _userService.Template();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
-- OnboardX 契约锁电子签流程表
|
||||
-- 适用数据库:MySQL 8.0+
|
||||
-- 说明:
|
||||
-- 1. 一条电子签流程可以包含同一入职任务下的多份合同。
|
||||
-- 2. ProviderContractId、ProviderDocumentId 一律使用 VARCHAR,避免第三方 19 位 Id 在前端精度丢失。
|
||||
-- 3. 回调事件通过 PayloadHash 幂等处理,可安全接收契约锁重复通知。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ob_electronic_sign_process` (
|
||||
`Id` BIGINT NOT NULL COMMENT '主键',
|
||||
`TaskId` BIGINT NOT NULL COMMENT '入职任务Id',
|
||||
`EmployeeId` BIGINT NOT NULL COMMENT '正式员工Id',
|
||||
`Provider` VARCHAR(30) NOT NULL DEFAULT 'qiyuesuo' COMMENT '电子签服务商',
|
||||
`BizId` VARCHAR(100) NOT NULL COMMENT '本系统幂等业务编号',
|
||||
`IsCurrent` TINYINT(1) NULL DEFAULT 1 COMMENT '是否为任务当前电子签流程,历史流程为空',
|
||||
`ProviderContractId` VARCHAR(100) NULL COMMENT '契约锁合同流程Id',
|
||||
`CategoryId` VARCHAR(100) NOT NULL COMMENT '契约锁业务分类Id快照',
|
||||
`SealId` VARCHAR(100) NOT NULL COMMENT '契约锁印章Id快照',
|
||||
`Subject` VARCHAR(300) NOT NULL COMMENT '电子签流程标题',
|
||||
`EmployeeName` VARCHAR(100) NOT NULL COMMENT '员工姓名快照',
|
||||
`EmployeeMobile` VARCHAR(30) NOT NULL COMMENT '员工手机号快照',
|
||||
`EmployeeIdCardNumber` VARCHAR(30) NULL COMMENT '员工身份证号码快照',
|
||||
`CreatorName` VARCHAR(100) NOT NULL COMMENT '创建人姓名快照',
|
||||
`CreatorMobile` VARCHAR(30) NOT NULL COMMENT '创建人手机号快照',
|
||||
`SignStatus` VARCHAR(40) NOT NULL COMMENT '本系统电子签状态',
|
||||
`ProviderStatus` VARCHAR(60) NULL COMMENT '契约锁原始合同状态',
|
||||
`SignUrl` LONGTEXT NULL COMMENT '员工签署页面地址',
|
||||
`SignUrlExpireAt` DATETIME(3) NULL COMMENT '签署页面地址到期时间',
|
||||
`StartedAt` DATETIME(3) NOT NULL COMMENT '管理员发起时间',
|
||||
`SentAt` DATETIME(3) NULL COMMENT '合同发送时间',
|
||||
`CompletedAt` DATETIME(3) NULL COMMENT '全部签署完成时间',
|
||||
`ArchivedAt` DATETIME(3) NULL COMMENT '归档完成时间',
|
||||
`ArchiveFileId` BIGINT NULL COMMENT '完整归档ZIP文件Id',
|
||||
`LastSyncedAt` DATETIME(3) NULL COMMENT '最后主动同步时间',
|
||||
`LastCallbackAt` DATETIME(3) NULL COMMENT '最后回调时间',
|
||||
`LastError` LONGTEXT NULL COMMENT '最近一次失败原因',
|
||||
`RetryCount` INT NOT NULL DEFAULT 0 COMMENT '失败重试次数',
|
||||
`ReissueReason` VARCHAR(500) NULL COMMENT '重新发起原因',
|
||||
`ReissuedAt` DATETIME(3) NULL COMMENT '被新流程替代时间',
|
||||
`ReissuedByProcessId` BIGINT NULL COMMENT '替代当前流程的新流程Id',
|
||||
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
|
||||
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态',
|
||||
`CreateTime` DATETIME(3) NULL COMMENT '创建时间',
|
||||
`UpdateTime` DATETIME(3) NULL COMMENT '更新时间',
|
||||
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
|
||||
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
|
||||
`CreateUser` VARCHAR(200) NULL COMMENT '创建人',
|
||||
`UpdateUser` VARCHAR(200) NULL COMMENT '更新人',
|
||||
`IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_ob_esign_process_biz` (`BizId`),
|
||||
UNIQUE KEY `uk_ob_esign_process_current` (`TaskId`, `IsCurrent`),
|
||||
UNIQUE KEY `uk_ob_esign_process_provider_contract` (`ProviderContractId`),
|
||||
KEY `idx_ob_esign_process_task` (`TaskId`, `StartedAt`),
|
||||
KEY `idx_ob_esign_process_status` (`SignStatus`, `UpdateTime`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职电子签流程';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ob_electronic_sign_document` (
|
||||
`Id` BIGINT NOT NULL COMMENT '主键',
|
||||
`SignProcessId` BIGINT NOT NULL COMMENT '电子签流程Id',
|
||||
`TaskContractId` BIGINT NOT NULL COMMENT '任务合同Id',
|
||||
`ProviderDocumentId` VARCHAR(100) NOT NULL COMMENT '契约锁文档Id',
|
||||
`DocumentName` VARCHAR(300) NOT NULL COMMENT '文档名称快照',
|
||||
`DocumentSort` INT NOT NULL DEFAULT 1 COMMENT '文档顺序',
|
||||
`OriginalDocxFileId` BIGINT NOT NULL COMMENT '原始DOCX文件Id',
|
||||
`SignedPdfFileId` BIGINT NULL COMMENT '签署完成PDF文件Id',
|
||||
`EmployeeSignKeyword` VARCHAR(200) NOT NULL COMMENT '员工签名关键字快照',
|
||||
`CompanySealKeyword` VARCHAR(200) NOT NULL COMMENT '企业盖章关键字快照',
|
||||
`EmployeeActionId` VARCHAR(100) NULL COMMENT '员工签署动作Id',
|
||||
`CompanyActionId` VARCHAR(100) NULL COMMENT '企业盖章动作Id',
|
||||
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
|
||||
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态',
|
||||
`CreateTime` DATETIME(3) NULL COMMENT '创建时间',
|
||||
`UpdateTime` DATETIME(3) NULL COMMENT '更新时间',
|
||||
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
|
||||
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
|
||||
`CreateUser` VARCHAR(200) NULL COMMENT '创建人',
|
||||
`UpdateUser` VARCHAR(200) NULL COMMENT '更新人',
|
||||
`IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_ob_esign_document_task_contract` (`SignProcessId`, `TaskContractId`),
|
||||
UNIQUE KEY `uk_ob_esign_document_provider` (`ProviderDocumentId`),
|
||||
KEY `idx_ob_esign_document_process_sort` (`SignProcessId`, `DocumentSort`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职电子签文档';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ob_electronic_sign_event` (
|
||||
`Id` BIGINT NOT NULL COMMENT '主键',
|
||||
`SignProcessId` BIGINT NULL COMMENT '电子签流程Id',
|
||||
`PayloadHash` VARCHAR(64) NOT NULL COMMENT '回调内容SHA-256',
|
||||
`EventSource` VARCHAR(30) NOT NULL COMMENT '事件来源',
|
||||
`EventType` VARCHAR(100) NULL COMMENT '契约锁事件类型',
|
||||
`ProviderStatus` VARCHAR(60) NULL COMMENT '契约锁状态',
|
||||
`PayloadJson` LONGTEXT NULL COMMENT '事件原始内容',
|
||||
`ProcessResult` VARCHAR(500) NULL COMMENT '处理结果',
|
||||
`ReceivedAt` DATETIME(3) NOT NULL COMMENT '接收时间',
|
||||
`ProcessedAt` DATETIME(3) NULL COMMENT '处理完成时间',
|
||||
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
|
||||
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态',
|
||||
`CreateTime` DATETIME(3) NULL COMMENT '创建时间',
|
||||
`UpdateTime` DATETIME(3) NULL COMMENT '更新时间',
|
||||
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
|
||||
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
|
||||
`CreateUser` VARCHAR(200) NULL COMMENT '创建人',
|
||||
`UpdateUser` VARCHAR(200) NULL COMMENT '更新人',
|
||||
`IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_ob_esign_event_payload_hash` (`PayloadHash`),
|
||||
KEY `idx_ob_esign_event_process` (`SignProcessId`, `ReceivedAt`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职电子签事件';
|
||||
@@ -0,0 +1,33 @@
|
||||
-- 增加入职电子签“作废后重新发起”能力。
|
||||
-- 已执行 20260903_qiyuesuo_electronic_sign.sql 的数据库需要单独执行本文件。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `ob_electronic_sign_process`
|
||||
ADD COLUMN `IsCurrent` TINYINT(1) NULL DEFAULT NULL
|
||||
COMMENT '是否为任务当前电子签流程,历史流程为空'
|
||||
AFTER `BizId`;
|
||||
|
||||
ALTER TABLE `ob_electronic_sign_process`
|
||||
ADD COLUMN `ReissueReason` VARCHAR(500) NULL
|
||||
COMMENT '重新发起原因'
|
||||
AFTER `RetryCount`,
|
||||
ADD COLUMN `ReissuedAt` DATETIME(3) NULL
|
||||
COMMENT '被新流程替代时间'
|
||||
AFTER `ReissueReason`,
|
||||
ADD COLUMN `ReissuedByProcessId` BIGINT NULL
|
||||
COMMENT '替代当前流程的新流程Id'
|
||||
AFTER `ReissuedAt`;
|
||||
|
||||
-- 雪花 Id 与创建时间同序,每个任务只把最新一条流程标记为当前流程。
|
||||
UPDATE `ob_electronic_sign_process` process
|
||||
INNER JOIN (
|
||||
SELECT `TaskId`, MAX(`Id`) AS `LatestProcessId`
|
||||
FROM `ob_electronic_sign_process`
|
||||
GROUP BY `TaskId`
|
||||
) latest ON latest.`LatestProcessId` = process.`Id`
|
||||
SET process.`IsCurrent` = 1;
|
||||
|
||||
ALTER TABLE `ob_electronic_sign_process`
|
||||
ADD UNIQUE KEY `uk_ob_esign_process_current` (`TaskId`, `IsCurrent`);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 补充电子签署人实名快照。
|
||||
-- 已执行 20260903_qiyuesuo_electronic_sign.sql 的数据库需要单独执行本文件。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `ob_electronic_sign_process`
|
||||
ADD COLUMN `EmployeeIdCardNumber` VARCHAR(30) NULL
|
||||
COMMENT '员工身份证号码快照'
|
||||
AFTER `EmployeeMobile`;
|
||||
@@ -0,0 +1,163 @@
|
||||
-- 员工合同快照:一名正式员工的一份任务合同对应一条记录。
|
||||
-- 常用字段独立存储用于筛选导出,VariablesJson 按 VariableCode 保存全部标准及自定义变量。
|
||||
-- 适用数据库:MySQL 8.0+
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ob_employee_contract_snapshot` (
|
||||
`Id` BIGINT NOT NULL COMMENT '主键',
|
||||
`EmployeeId` BIGINT NOT NULL COMMENT '正式员工Id',
|
||||
`TaskId` BIGINT NOT NULL COMMENT '来源入职任务Id',
|
||||
`TaskContractId` BIGINT NOT NULL COMMENT '来源任务合同Id',
|
||||
`TemplateId` BIGINT NOT NULL COMMENT '合同模板Id',
|
||||
`TemplateVersionId` BIGINT NOT NULL COMMENT '合同模板版本Id',
|
||||
`ContractName` VARCHAR(300) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合同名称快照',
|
||||
`ContractType` VARCHAR(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同类型快照',
|
||||
`ContractStartDate` DATE DEFAULT NULL COMMENT '合同开始日期',
|
||||
`ContractEndDate` DATE DEFAULT NULL COMMENT '合同结束日期',
|
||||
`ContractTerm` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同期限原始文本',
|
||||
`ProbationStartDate` DATE DEFAULT NULL COMMENT '试用期开始日期',
|
||||
`ProbationEndDate` DATE DEFAULT NULL COMMENT '试用期结束日期',
|
||||
`ProbationTerm` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '试用期期限原始文本',
|
||||
`ProbationSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '试用薪资,对应hr.ProbationSalary,不自动推算',
|
||||
`BaseSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '基本工资',
|
||||
`PositionSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '岗位工资',
|
||||
`PerformanceAllowance` DECIMAL(18,2) DEFAULT NULL COMMENT '绩效津贴',
|
||||
`AttendanceBonus` DECIMAL(18,2) DEFAULT NULL COMMENT '全勤奖',
|
||||
`RegularSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '转正后薪资',
|
||||
`WorkCity` VARCHAR(300) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '工作地点或城市',
|
||||
`PositionName` VARCHAR(300) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同岗位名称',
|
||||
`EmployeeNo` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同员工工号',
|
||||
`VariablesJson` JSON NOT NULL COMMENT '按VariableCode索引的完整合同变量快照',
|
||||
`SnapshotVersion` INT NOT NULL DEFAULT 1 COMMENT '快照结构版本',
|
||||
`SnapshotAt` DATETIME(3) NOT NULL COMMENT '快照生成时间',
|
||||
`ExtJson` LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '扩展信息',
|
||||
`Status` VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'enable' COMMENT '记录状态',
|
||||
`CreateTime` DATETIME(3) DEFAULT NULL COMMENT '创建时间',
|
||||
`UpdateTime` DATETIME(3) DEFAULT NULL COMMENT '更新时间',
|
||||
`CreateUserId` BIGINT DEFAULT NULL COMMENT '创建者Id',
|
||||
`UpdateUserId` BIGINT DEFAULT NULL COMMENT '修改者Id',
|
||||
`CreateUser` VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
|
||||
`UpdateUser` VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
|
||||
`IsDelete` TINYINT(1) DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_ob_employee_contract_snapshot_contract` (`TaskContractId`),
|
||||
KEY `idx_ob_employee_contract_snapshot_employee` (`EmployeeId`, `IsDelete`),
|
||||
KEY `idx_ob_employee_contract_snapshot_task` (`TaskId`, `IsDelete`),
|
||||
KEY `idx_ob_employee_contract_snapshot_dates` (`ContractStartDate`, `ContractEndDate`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正式员工合同业务快照';
|
||||
|
||||
-- 为已经审核建档且仍保留任务合同变量的员工补建快照。
|
||||
INSERT IGNORE INTO `ob_employee_contract_snapshot` (
|
||||
`Id`, `EmployeeId`, `TaskId`, `TaskContractId`, `TemplateId`, `TemplateVersionId`,
|
||||
`ContractName`, `ContractType`, `ContractStartDate`, `ContractEndDate`, `ContractTerm`,
|
||||
`ProbationStartDate`, `ProbationEndDate`, `ProbationTerm`, `ProbationSalary`,
|
||||
`BaseSalary`, `PositionSalary`, `PerformanceAllowance`, `AttendanceBonus`, `RegularSalary`,
|
||||
`WorkCity`, `PositionName`, `EmployeeNo`, `VariablesJson`, `SnapshotVersion`, `SnapshotAt`,
|
||||
`Status`, `CreateTime`, `CreateUserId`, `CreateUser`, `IsDelete`
|
||||
)
|
||||
SELECT
|
||||
contract.`Id`,
|
||||
task.`EmployeeId`,
|
||||
task.`Id`,
|
||||
contract.`Id`,
|
||||
contract.`TemplateId`,
|
||||
contract.`TemplateVersionId`,
|
||||
contract.`ContractName`,
|
||||
contract.`ContractType`,
|
||||
CASE
|
||||
WHEN TRIM(variable.`ContractStartDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
|
||||
THEN STR_TO_DATE(TRIM(variable.`ContractStartDateText`), '%Y-%m-%d')
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE
|
||||
WHEN TRIM(variable.`ContractEndDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
|
||||
THEN STR_TO_DATE(TRIM(variable.`ContractEndDateText`), '%Y-%m-%d')
|
||||
ELSE NULL
|
||||
END,
|
||||
variable.`ContractTerm`,
|
||||
CASE
|
||||
WHEN TRIM(variable.`ProbationStartDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
|
||||
THEN STR_TO_DATE(TRIM(variable.`ProbationStartDateText`), '%Y-%m-%d')
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE
|
||||
WHEN TRIM(variable.`ProbationEndDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
|
||||
THEN STR_TO_DATE(TRIM(variable.`ProbationEndDateText`), '%Y-%m-%d')
|
||||
ELSE NULL
|
||||
END,
|
||||
variable.`ProbationTerm`,
|
||||
CASE WHEN TRIM(variable.`ProbationSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`ProbationSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`BaseSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`BaseSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`PositionSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`PositionSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`PerformanceAllowanceText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`PerformanceAllowanceText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`AttendanceBonusText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`AttendanceBonusText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`RegularSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`RegularSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
variable.`WorkCity`,
|
||||
variable.`PositionName`,
|
||||
variable.`EmployeeNo`,
|
||||
COALESCE(variable.`VariablesJson`, JSON_OBJECT()),
|
||||
1,
|
||||
COALESCE(task.`AuditedAt`, employee.`CreateTime`, NOW(3)),
|
||||
'enable',
|
||||
COALESCE(employee.`CreateTime`, NOW(3)),
|
||||
employee.`CreateUserId`,
|
||||
employee.`CreateUser`,
|
||||
0
|
||||
FROM `ob_onboarding_task` task
|
||||
INNER JOIN `ob_employee` employee
|
||||
ON employee.`Id` = task.`EmployeeId`
|
||||
INNER JOIN `ob_task_contract` contract
|
||||
ON contract.`TaskId` = task.`Id`
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
row_value.`TaskContractId`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ContractStartDate' THEN row_value.`VariableValue` END) AS `ContractStartDateText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ContractEndDate' THEN row_value.`VariableValue` END) AS `ContractEndDateText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ContractTerm' THEN row_value.`VariableValue` END) AS `ContractTerm`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationStartDate' THEN row_value.`VariableValue` END) AS `ProbationStartDateText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationEndDate' THEN row_value.`VariableValue` END) AS `ProbationEndDateText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationTerm' THEN row_value.`VariableValue` END) AS `ProbationTerm`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationSalary' THEN row_value.`VariableValue` END) AS `ProbationSalaryText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.BaseSalary' THEN row_value.`VariableValue` END) AS `BaseSalaryText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.PositionSalary' THEN row_value.`VariableValue` END) AS `PositionSalaryText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.PerformanceAllowance' THEN row_value.`VariableValue` END) AS `PerformanceAllowanceText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.AttendanceBonus' THEN row_value.`VariableValue` END) AS `AttendanceBonusText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.RegularSalary' THEN row_value.`VariableValue` END) AS `RegularSalaryText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.WorkCity' THEN row_value.`VariableValue` END) AS `WorkCity`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.PositionName' THEN row_value.`VariableValue` END) AS `PositionName`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.EmployeeNo' THEN row_value.`VariableValue` END) AS `EmployeeNo`,
|
||||
JSON_OBJECTAGG(
|
||||
row_value.`VariableCode`,
|
||||
JSON_OBJECT(
|
||||
'variableName', row_value.`VariableName`,
|
||||
'variableType', row_value.`VariableType`,
|
||||
'dataType', row_value.`DataType`,
|
||||
'variableValue', row_value.`VariableValue`,
|
||||
'sourceType', row_value.`SourceType`,
|
||||
'isManualOverride', row_value.`IsManualOverride`,
|
||||
'sortCode', row_value.`SortCode`
|
||||
)
|
||||
) AS `VariablesJson`
|
||||
FROM `ob_task_contract_variable` row_value
|
||||
WHERE row_value.`IsDelete` = 0
|
||||
GROUP BY row_value.`TaskContractId`
|
||||
) variable
|
||||
ON variable.`TaskContractId` = contract.`Id`
|
||||
WHERE task.`EmployeeId` IS NOT NULL
|
||||
AND task.`IsDelete` = 0
|
||||
AND employee.`IsDelete` = 0
|
||||
AND contract.`IsDelete` = 0;
|
||||
|
||||
SELECT
|
||||
COUNT(*) AS `员工合同快照数量`,
|
||||
COUNT(DISTINCT `EmployeeId`) AS `已归档员工数量`,
|
||||
COUNT(DISTINCT `TaskContractId`) AS `已归档合同数量`
|
||||
FROM `ob_employee_contract_snapshot`
|
||||
WHERE `IsDelete` = 0;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- OnboardX 契约锁公司级业务分类与印章配置
|
||||
-- 适用数据库:MySQL 8.0+
|
||||
-- 可重复执行;仅增加字段,不覆盖任何公司已有配置。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS `ob_qys_company_add_column_if_missing`;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE `ob_qys_company_add_column_if_missing`(
|
||||
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 = 'ob_company'
|
||||
AND COLUMN_NAME = p_column_name
|
||||
) THEN
|
||||
SET @ddl = CONCAT(
|
||||
'ALTER TABLE `ob_company` ADD COLUMN `',
|
||||
p_column_name, '` ', p_column_definition
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL `ob_qys_company_add_column_if_missing`(
|
||||
'QiyuesuoCategoryId',
|
||||
'VARCHAR(100) NULL COMMENT ''契约锁默认业务分类Id(按公司独立配置)'' AFTER `ContactPhone`'
|
||||
);
|
||||
CALL `ob_qys_company_add_column_if_missing`(
|
||||
'QiyuesuoSealId',
|
||||
'VARCHAR(100) NULL COMMENT ''契约锁企业印章Id(按公司独立配置)'' AFTER `QiyuesuoCategoryId`'
|
||||
);
|
||||
|
||||
DROP PROCEDURE IF EXISTS `ob_qys_company_add_column_if_missing`;
|
||||
|
||||
SELECT
|
||||
Id,
|
||||
CompanyCode,
|
||||
CompanyName,
|
||||
LegalName,
|
||||
QiyuesuoCategoryId,
|
||||
QiyuesuoSealId,
|
||||
CASE
|
||||
WHEN NULLIF(TRIM(QiyuesuoCategoryId), '') IS NOT NULL
|
||||
AND NULLIF(TRIM(QiyuesuoSealId), '') IS NOT NULL THEN '已配置'
|
||||
ELSE '待配置'
|
||||
END AS QiyuesuoConfigStatus
|
||||
FROM ob_company
|
||||
WHERE IsDelete = 0 OR IsDelete IS NULL
|
||||
ORDER BY CompanyName;
|
||||
@@ -0,0 +1,172 @@
|
||||
-- 员工用工类型、人工转正、社保状态及人事变更材料。
|
||||
-- 适用数据库:MySQL 8.0+
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS ob_upgrade_employee_hr_management;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE ob_upgrade_employee_hr_management()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_onboarding_task'
|
||||
AND COLUMN_NAME = 'EmploymentType'
|
||||
) THEN
|
||||
ALTER TABLE ob_onboarding_task
|
||||
ADD COLUMN EmploymentType VARCHAR(20) COLLATE utf8mb4_unicode_ci
|
||||
NOT NULL DEFAULT 'full_time' COMMENT '用工类型:full_time正式、part_time兼职'
|
||||
AFTER PositionId;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee'
|
||||
AND COLUMN_NAME = 'EmploymentType'
|
||||
) THEN
|
||||
ALTER TABLE ob_employee
|
||||
ADD COLUMN EmploymentType VARCHAR(20) COLLATE utf8mb4_unicode_ci
|
||||
NOT NULL DEFAULT 'full_time' COMMENT '用工类型:full_time正式、part_time兼职'
|
||||
AFTER PositionId;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee'
|
||||
AND COLUMN_NAME = 'RegularizationStatus'
|
||||
) THEN
|
||||
ALTER TABLE ob_employee
|
||||
ADD COLUMN RegularizationStatus VARCHAR(20) COLLATE utf8mb4_unicode_ci
|
||||
NOT NULL DEFAULT 'probation' COMMENT '转正状态:probation、regular、not_applicable'
|
||||
AFTER EmploymentType;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee'
|
||||
AND COLUMN_NAME = 'SocialInsuranceStatus'
|
||||
) THEN
|
||||
ALTER TABLE ob_employee
|
||||
ADD COLUMN SocialInsuranceStatus VARCHAR(20) COLLATE utf8mb4_unicode_ci
|
||||
NOT NULL DEFAULT 'unknown' COMMENT '社保状态:unknown、not_purchased、purchased、stopped、not_applicable'
|
||||
AFTER RegularizationStatus;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee_contract_snapshot'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee_contract_snapshot'
|
||||
AND COLUMN_NAME = 'ProbationSalary'
|
||||
) THEN
|
||||
ALTER TABLE ob_employee_contract_snapshot
|
||||
ADD COLUMN ProbationSalary DECIMAL(18,2) DEFAULT NULL
|
||||
COMMENT '试用薪资,对应hr.ProbationSalary,不自动推算'
|
||||
AFTER ProbationTerm;
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
CALL ob_upgrade_employee_hr_management();
|
||||
DROP PROCEDURE IF EXISTS ob_upgrade_employee_hr_management;
|
||||
|
||||
-- 历史任务及员工默认按正式用工处理;历史转正日期仅用于一次性初始化状态。
|
||||
UPDATE ob_onboarding_task
|
||||
SET EmploymentType = 'full_time'
|
||||
WHERE EmploymentType IS NULL OR EmploymentType NOT IN ('full_time', 'part_time');
|
||||
|
||||
UPDATE ob_employee
|
||||
SET
|
||||
EmploymentType = CASE
|
||||
WHEN EmploymentType = 'part_time' THEN 'part_time'
|
||||
ELSE 'full_time'
|
||||
END,
|
||||
RegularizationStatus = CASE
|
||||
WHEN EmploymentType = 'part_time' THEN 'not_applicable'
|
||||
WHEN RegularizationDate IS NOT NULL THEN 'regular'
|
||||
ELSE 'probation'
|
||||
END,
|
||||
SocialInsuranceStatus = CASE
|
||||
WHEN SocialInsuranceStatus IN (
|
||||
'unknown', 'not_purchased', 'purchased', 'stopped', 'not_applicable'
|
||||
) THEN SocialInsuranceStatus
|
||||
ELSE 'unknown'
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ob_employee_change_record (
|
||||
Id BIGINT NOT NULL COMMENT '主键',
|
||||
EmployeeId BIGINT NOT NULL COMMENT '员工Id',
|
||||
ChangeType VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更类型',
|
||||
EffectiveDate DATE DEFAULT NULL COMMENT '业务生效日期',
|
||||
BeforeJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '变更前快照',
|
||||
AfterJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '变更后快照',
|
||||
Remark VARCHAR(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
ExtJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '扩展信息',
|
||||
Status VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'enable' COMMENT '记录状态',
|
||||
CreateTime DATETIME(3) DEFAULT NULL COMMENT '创建时间',
|
||||
UpdateTime DATETIME(3) DEFAULT NULL COMMENT '更新时间',
|
||||
CreateUserId BIGINT DEFAULT NULL COMMENT '创建者Id',
|
||||
UpdateUserId BIGINT DEFAULT NULL COMMENT '修改者Id',
|
||||
CreateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
|
||||
UpdateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
|
||||
IsDelete TINYINT(1) DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (Id),
|
||||
KEY idx_ob_employee_change_record_employee (EmployeeId, IsDelete, CreateTime),
|
||||
KEY idx_ob_employee_change_record_type (ChangeType, EffectiveDate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工人事信息变更记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ob_employee_attachment (
|
||||
Id BIGINT NOT NULL COMMENT '主键',
|
||||
EmployeeId BIGINT NOT NULL COMMENT '员工Id',
|
||||
ChangeRecordId BIGINT DEFAULT NULL COMMENT '关联人事变更记录Id',
|
||||
BusinessType VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '附件业务类型',
|
||||
FileId BIGINT NOT NULL COMMENT '系统文件Id',
|
||||
FileName VARCHAR(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上传时文件名快照',
|
||||
UploadedAt DATETIME(3) NOT NULL COMMENT '上传关联时间',
|
||||
ExtJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '扩展信息',
|
||||
Status VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'enable' COMMENT '记录状态',
|
||||
CreateTime DATETIME(3) DEFAULT NULL COMMENT '创建时间',
|
||||
UpdateTime DATETIME(3) DEFAULT NULL COMMENT '更新时间',
|
||||
CreateUserId BIGINT DEFAULT NULL COMMENT '创建者Id',
|
||||
UpdateUserId BIGINT DEFAULT NULL COMMENT '修改者Id',
|
||||
CreateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
|
||||
UpdateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
|
||||
IsDelete TINYINT(1) DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (Id),
|
||||
KEY idx_ob_employee_attachment_employee (EmployeeId, BusinessType, IsDelete),
|
||||
KEY idx_ob_employee_attachment_change (ChangeRecordId, IsDelete),
|
||||
KEY idx_ob_employee_attachment_file (FileId)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工人事材料附件';
|
||||
|
||||
-- 标准试用薪资变量;已有同编码历史数据时恢复并统一为内置数值变量。
|
||||
INSERT INTO ob_variable_definition (
|
||||
Id, VariableCode, VariableName, VariableCategory, DataType,
|
||||
IsBuiltIn, Required, SortCode, Remark, Status,
|
||||
CreateTime, IsDelete
|
||||
) VALUES (
|
||||
841400000000115, 'hr.ProbationSalary', '试用薪资(元)', 'hr', 'number',
|
||||
1, 0, 335, '合同约定试用期薪资,不自动推算', 'enable',
|
||||
NOW(), 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
VariableName = VALUES(VariableName),
|
||||
VariableCategory = 'hr',
|
||||
DataType = 'number',
|
||||
IsBuiltIn = 1,
|
||||
SortCode = 335,
|
||||
Remark = VALUES(Remark),
|
||||
Status = 'enable',
|
||||
IsDelete = 0;
|
||||
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM ob_employee_change_record) AS '员工人事变更记录数',
|
||||
(SELECT COUNT(*) FROM ob_employee_attachment) AS '员工人事附件数',
|
||||
(SELECT COUNT(*) FROM ob_variable_definition
|
||||
WHERE VariableCode = 'hr.ProbationSalary' AND IsDelete = 0) AS '试用薪资标准变量数';
|
||||
@@ -49,6 +49,35 @@ BEGIN
|
||||
START TRANSACTION;
|
||||
|
||||
-- 一、删除任务运行数据及快照。
|
||||
-- 兼容尚未执行员工人事管理迁移的旧数据库。
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee_attachment'
|
||||
) THEN
|
||||
DELETE FROM ob_employee_attachment;
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee_change_record'
|
||||
) THEN
|
||||
DELETE FROM ob_employee_change_record;
|
||||
END IF;
|
||||
-- 兼容尚未执行员工合同快照迁移的旧数据库。
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM `information_schema`.`TABLES`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_NAME` = 'ob_employee_contract_snapshot'
|
||||
) THEN
|
||||
SET @delete_employee_contract_snapshot_sql = 'DELETE FROM `ob_employee_contract_snapshot`';
|
||||
PREPARE delete_employee_contract_snapshot_statement FROM @delete_employee_contract_snapshot_sql;
|
||||
EXECUTE delete_employee_contract_snapshot_statement;
|
||||
DEALLOCATE PREPARE delete_employee_contract_snapshot_statement;
|
||||
END IF;
|
||||
DELETE FROM `ob_task_operation_log`;
|
||||
DELETE FROM `ob_task_audit`;
|
||||
DELETE FROM `ob_task_material`;
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
-- ============================================================================
|
||||
-- OnboardX 基础业务假数据初始化脚本(MySQL 8.0+)
|
||||
--
|
||||
-- 使用:
|
||||
-- 1. 先执行 20260828_reset_onboarding_business_data_v2.sql;
|
||||
-- 2. 再执行本脚本;
|
||||
-- 3. 发起任务时选择:
|
||||
-- 重庆示例科技有限公司 / 技术部 / 后端开发工程师 /
|
||||
-- 技术岗位标准入职模板(测试)。
|
||||
--
|
||||
-- 特点:
|
||||
-- - 不删除任何数据;
|
||||
-- - 不覆盖保留的 ob_variable_definition 和 ob_material_type;
|
||||
-- - 只生成基础配置,不生成任务、员工或电子签约流程;
|
||||
-- - 固定业务 Id + ON DUPLICATE KEY UPDATE,可重复执行。
|
||||
--
|
||||
-- 合同源文件默认自动选择 sys_file 中最近的 DOCX,并优先排除 OB20 开头的
|
||||
-- 已生成任务合同。若自动选择不对,把 NULL 改为正确的源 DOCX 文件 Id。
|
||||
-- 例如:SET @ob_seed_source_docx_file_id := 842701790859333;
|
||||
-- ============================================================================
|
||||
|
||||
USE `OnboardX`;
|
||||
SET NAMES utf8mb4;
|
||||
SET @ob_seed_source_docx_file_id := NULL;
|
||||
|
||||
SET @ob_seed_source_docx_file_id := COALESCE(
|
||||
@ob_seed_source_docx_file_id,
|
||||
(
|
||||
SELECT file.`Id`
|
||||
FROM `sys_file` file
|
||||
WHERE LOWER(TRIM(LEADING '.' FROM COALESCE(file.`Suffix`, ''))) = 'docx'
|
||||
AND COALESCE(file.`IsDelete`, 0) = 0
|
||||
ORDER BY
|
||||
CASE WHEN UPPER(COALESCE(file.`Name`, '')) LIKE 'OB20%' THEN 1 ELSE 0 END,
|
||||
file.`CreateTime` DESC,
|
||||
file.`Id` DESC
|
||||
LIMIT 1
|
||||
)
|
||||
);
|
||||
|
||||
DROP PROCEDURE IF EXISTS `ob_assert_seed_prerequisites`;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE `ob_assert_seed_prerequisites`()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM `ob_variable_definition`
|
||||
WHERE COALESCE(`IsDelete`, 0) = 0
|
||||
AND LOWER(COALESCE(`Status`, 'enable')) = 'enable'
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '初始化失败:ob_variable_definition 没有启用数据';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM `ob_material_type`
|
||||
WHERE COALESCE(`IsDelete`, 0) = 0
|
||||
AND LOWER(COALESCE(`Status`, 'enable')) = 'enable'
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '初始化失败:ob_material_type 没有启用数据';
|
||||
END IF;
|
||||
|
||||
IF @ob_seed_source_docx_file_id IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM `sys_file`
|
||||
WHERE `Id` = @ob_seed_source_docx_file_id
|
||||
AND LOWER(TRIM(LEADING '.' FROM COALESCE(`Suffix`, ''))) = 'docx'
|
||||
AND COALESCE(`IsDelete`, 0) = 0
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '初始化失败:没有可用的合同源 DOCX,请在脚本顶部指定文件 Id';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL `ob_assert_seed_prerequisites`();
|
||||
DROP PROCEDURE IF EXISTS `ob_assert_seed_prerequisites`;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
-- 1. 公司
|
||||
INSERT INTO `ob_company`
|
||||
(`Id`, `CompanyCode`, `CompanyName`, `LegalName`, `UnifiedSocialCreditCode`,
|
||||
`LegalRepresentative`, `RegisteredAddress`, `ContactPhone`, `Remark`,
|
||||
`Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
VALUES
|
||||
(8800000000000001, 'DEMO-COMPANY-001', '重庆示例科技有限公司', '重庆示例科技有限公司',
|
||||
'91500000MA5UDEMO01', '张示例', '重庆市渝北区示例大道 100 号', '023-88886666',
|
||||
'测试公司,可在后台修改', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`CompanyCode` = VALUES(`CompanyCode`),
|
||||
`CompanyName` = VALUES(`CompanyName`),
|
||||
`LegalName` = VALUES(`LegalName`),
|
||||
`UnifiedSocialCreditCode` = VALUES(`UnifiedSocialCreditCode`),
|
||||
`LegalRepresentative` = VALUES(`LegalRepresentative`),
|
||||
`RegisteredAddress` = VALUES(`RegisteredAddress`),
|
||||
`ContactPhone` = VALUES(`ContactPhone`),
|
||||
`Remark` = VALUES(`Remark`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
-- 2. 部门
|
||||
INSERT INTO `ob_department`
|
||||
(`Id`, `CompanyId`, `DepartmentCode`, `DepartmentName`, `SortCode`, `Remark`,
|
||||
`Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
VALUES
|
||||
(8800000000000101, 8800000000000001, 'DEMO-DEPT-HR', '人事部', 10,
|
||||
'负责招聘、入职和员工关系', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000102, 8800000000000001, 'DEMO-DEPT-TECH', '技术部', 20,
|
||||
'负责产品研发和质量保障', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000103, 8800000000000001, 'DEMO-DEPT-OPERATION', '运营部', 30,
|
||||
'负责产品运营和客户服务', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`CompanyId` = VALUES(`CompanyId`),
|
||||
`DepartmentCode` = VALUES(`DepartmentCode`),
|
||||
`DepartmentName` = VALUES(`DepartmentName`),
|
||||
`SortCode` = VALUES(`SortCode`),
|
||||
`Remark` = VALUES(`Remark`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
-- 3. 岗位及默认人事合同参数
|
||||
INSERT INTO `ob_position`
|
||||
(`Id`, `CompanyId`, `DepartmentId`, `PositionCode`, `PositionName`,
|
||||
`DefaultContractYears`, `DefaultProbationMonths`, `DefaultParamsJson`, `Remark`,
|
||||
`Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
VALUES
|
||||
(8800000000000201, 8800000000000001, 8800000000000101,
|
||||
'DEMO-HR-SPECIALIST', '人事专员', 3, 3,
|
||||
JSON_OBJECT('hr.BaseSalary', '4500', 'hr.PositionSalary', '1000',
|
||||
'hr.PerformanceAllowance', '500', 'hr.AttendanceBonus', '200',
|
||||
'hr.RegularSalary', '6200', 'hr.WorkCity', '重庆',
|
||||
'hr.PositionName', '人事专员', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'),
|
||||
'测试岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000202, 8800000000000001, 8800000000000102,
|
||||
'DEMO-BACKEND-ENGINEER', '后端开发工程师', 3, 3,
|
||||
JSON_OBJECT('hr.BaseSalary', '6500', 'hr.PositionSalary', '2500',
|
||||
'hr.PerformanceAllowance', '1000', 'hr.AttendanceBonus', '200',
|
||||
'hr.RegularSalary', '10200', 'hr.WorkCity', '重庆',
|
||||
'hr.PositionName', '后端开发工程师', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'),
|
||||
'完整流程推荐使用此岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000203, 8800000000000001, 8800000000000102,
|
||||
'DEMO-FRONTEND-ENGINEER', '前端开发工程师', 3, 3,
|
||||
JSON_OBJECT('hr.BaseSalary', '6200', 'hr.PositionSalary', '2300',
|
||||
'hr.PerformanceAllowance', '1000', 'hr.AttendanceBonus', '200',
|
||||
'hr.RegularSalary', '9700', 'hr.WorkCity', '重庆',
|
||||
'hr.PositionName', '前端开发工程师', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'),
|
||||
'测试岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000204, 8800000000000001, 8800000000000102,
|
||||
'DEMO-QA-ENGINEER', '测试工程师', 3, 3,
|
||||
JSON_OBJECT('hr.BaseSalary', '5500', 'hr.PositionSalary', '1800',
|
||||
'hr.PerformanceAllowance', '800', 'hr.AttendanceBonus', '200',
|
||||
'hr.RegularSalary', '8300', 'hr.WorkCity', '重庆',
|
||||
'hr.PositionName', '测试工程师', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'),
|
||||
'测试岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000205, 8800000000000001, 8800000000000103,
|
||||
'DEMO-OPERATION-SPECIALIST', '运营专员', 3, 3,
|
||||
JSON_OBJECT('hr.BaseSalary', '4500', 'hr.PositionSalary', '1200',
|
||||
'hr.PerformanceAllowance', '800', 'hr.AttendanceBonus', '200',
|
||||
'hr.RegularSalary', '6700', 'hr.WorkCity', '重庆',
|
||||
'hr.PositionName', '运营专员', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'),
|
||||
'测试岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`CompanyId` = VALUES(`CompanyId`),
|
||||
`DepartmentId` = VALUES(`DepartmentId`),
|
||||
`PositionCode` = VALUES(`PositionCode`),
|
||||
`PositionName` = VALUES(`PositionName`),
|
||||
`DefaultContractYears` = VALUES(`DefaultContractYears`),
|
||||
`DefaultProbationMonths` = VALUES(`DefaultProbationMonths`),
|
||||
`DefaultParamsJson` = VALUES(`DefaultParamsJson`),
|
||||
`Remark` = VALUES(`Remark`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
-- 4. 技术部合同模板及适用岗位
|
||||
INSERT INTO `ob_contract_template`
|
||||
(`Id`, `CompanyId`, `DepartmentId`, `TemplateCode`, `TemplateName`, `ContractType`,
|
||||
`Description`, `Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
VALUES
|
||||
(8800000000000301, 8800000000000001, 8800000000000102,
|
||||
'DEMO-LABOR-CONTRACT-001', '技术岗位劳动合同(测试)', '劳动合同',
|
||||
'测试合同模板,源 DOCX 由脚本顶部变量决定',
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`CompanyId` = VALUES(`CompanyId`),
|
||||
`DepartmentId` = VALUES(`DepartmentId`),
|
||||
`TemplateCode` = VALUES(`TemplateCode`),
|
||||
`TemplateName` = VALUES(`TemplateName`),
|
||||
`ContractType` = VALUES(`ContractType`),
|
||||
`Description` = VALUES(`Description`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
INSERT INTO `ob_contract_template_position`
|
||||
(`Id`, `ContractTemplateId`, `PositionId`, `Status`, `CreateTime`, `UpdateTime`,
|
||||
`CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
VALUES
|
||||
(8800000000000401, 8800000000000301, 8800000000000202,
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000402, 8800000000000301, 8800000000000203,
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000403, 8800000000000301, 8800000000000204,
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`ContractTemplateId` = VALUES(`ContractTemplateId`),
|
||||
`PositionId` = VALUES(`PositionId`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
-- 5. 已启用合同版本
|
||||
INSERT INTO `ob_contract_template_version`
|
||||
(`Id`, `TemplateId`, `VersionNo`, `SourceDocxFileId`, `FileHash`,
|
||||
`EffectiveDate`, `ExpiryDate`, `VersionStatus`, `Remark`,
|
||||
`Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
VALUES
|
||||
(8800000000000311, 8800000000000301, '1.0', @ob_seed_source_docx_file_id,
|
||||
CONCAT('seed-file-', @ob_seed_source_docx_file_id),
|
||||
CURDATE(), DATE_ADD(CURDATE(), INTERVAL 10 YEAR), 'enabled', '测试合同版本',
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`TemplateId` = VALUES(`TemplateId`),
|
||||
`VersionNo` = VALUES(`VersionNo`),
|
||||
`SourceDocxFileId` = VALUES(`SourceDocxFileId`),
|
||||
`FileHash` = VALUES(`FileHash`),
|
||||
`EffectiveDate` = VALUES(`EffectiveDate`),
|
||||
`ExpiryDate` = VALUES(`ExpiryDate`),
|
||||
`VersionStatus` = 'enabled',
|
||||
`Remark` = VALUES(`Remark`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
-- SQL 不能解压 DOCX 识别占位符,因此复制当前启用的四类变量字典。
|
||||
-- DOCX 未使用的变量不会改变文件;这样能避免实际占位符初始化时遗漏。
|
||||
INSERT INTO `ob_template_variable`
|
||||
(`Id`, `TemplateVersionId`, `VariableCode`, `VariableName`, `VariableType`, `DataType`,
|
||||
`EmployeeField`, `DefaultValue`, `Required`, `HrEditable`, `SortCode`, `Remark`,
|
||||
`Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
SELECT
|
||||
8000000000000000 + definition.`Id`,
|
||||
8800000000000311,
|
||||
definition.`VariableCode`,
|
||||
definition.`VariableName`,
|
||||
CASE LOWER(definition.`VariableCategory`)
|
||||
WHEN 'employee' THEN 'employee'
|
||||
WHEN 'hr' THEN 'contract'
|
||||
WHEN 'company' THEN 'company'
|
||||
WHEN 'system' THEN 'system'
|
||||
END,
|
||||
COALESCE(NULLIF(definition.`DataType`, ''), 'string'),
|
||||
CASE
|
||||
WHEN LOWER(definition.`VariableCategory`) IN ('employee', 'company')
|
||||
THEN SUBSTRING_INDEX(definition.`VariableCode`, '.', -1)
|
||||
ELSE NULL
|
||||
END,
|
||||
definition.`DefaultValue`,
|
||||
COALESCE(definition.`Required`, 0),
|
||||
CASE WHEN LOWER(definition.`VariableCategory`) = 'hr' THEN 1 ELSE 0 END,
|
||||
COALESCE(definition.`SortCode`, 100),
|
||||
definition.`Remark`,
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0
|
||||
FROM `ob_variable_definition` definition
|
||||
WHERE COALESCE(definition.`IsDelete`, 0) = 0
|
||||
AND LOWER(COALESCE(definition.`Status`, 'enable')) = 'enable'
|
||||
AND LOWER(definition.`VariableCategory`) IN ('employee', 'hr', 'company', 'system')
|
||||
AND definition.`Id` BETWEEN 1 AND 1007199254740991
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`VariableName` = VALUES(`VariableName`),
|
||||
`VariableType` = VALUES(`VariableType`),
|
||||
`DataType` = VALUES(`DataType`),
|
||||
`EmployeeField` = VALUES(`EmployeeField`),
|
||||
`DefaultValue` = VALUES(`DefaultValue`),
|
||||
`Required` = VALUES(`Required`),
|
||||
`HrEditable` = VALUES(`HrEditable`),
|
||||
`SortCode` = VALUES(`SortCode`),
|
||||
`Remark` = VALUES(`Remark`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
-- 6. 可直接发起任务的入职模板
|
||||
INSERT INTO `ob_onboarding_template`
|
||||
(`Id`, `TemplateCode`, `TemplateName`, `CompanyId`, `DepartmentId`, `Description`,
|
||||
`Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
VALUES
|
||||
(8800000000000501, 'DEMO-ONBOARDING-TECH', '技术岗位标准入职模板(测试)',
|
||||
8800000000000001, 8800000000000102,
|
||||
'适用于后端、前端和测试工程师,可用于完整入职流程测试',
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`TemplateCode` = VALUES(`TemplateCode`),
|
||||
`TemplateName` = VALUES(`TemplateName`),
|
||||
`CompanyId` = VALUES(`CompanyId`),
|
||||
`DepartmentId` = VALUES(`DepartmentId`),
|
||||
`Description` = VALUES(`Description`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
INSERT INTO `ob_onboarding_template_position`
|
||||
(`Id`, `OnboardingTemplateId`, `PositionId`, `Status`, `CreateTime`, `UpdateTime`,
|
||||
`CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
VALUES
|
||||
(8800000000000601, 8800000000000501, 8800000000000202,
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000602, 8800000000000501, 8800000000000203,
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0),
|
||||
(8800000000000603, 8800000000000501, 8800000000000204,
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`OnboardingTemplateId` = VALUES(`OnboardingTemplateId`),
|
||||
`PositionId` = VALUES(`PositionId`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
INSERT INTO `ob_onboarding_template_contract`
|
||||
(`Id`, `OnboardingTemplateId`, `ContractTemplateId`, `ContractTemplateVersionId`,
|
||||
`Required`, `PrintCopies`, `SortCode`, `Status`, `CreateTime`, `UpdateTime`,
|
||||
`CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
VALUES
|
||||
(8800000000000701, 8800000000000501, 8800000000000301, 8800000000000311,
|
||||
1, 1, 10, 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`OnboardingTemplateId` = VALUES(`OnboardingTemplateId`),
|
||||
`ContractTemplateId` = VALUES(`ContractTemplateId`),
|
||||
`ContractTemplateVersionId` = VALUES(`ContractTemplateVersionId`),
|
||||
`Required` = 1,
|
||||
`PrintCopies` = 1,
|
||||
`SortCode` = 10,
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
-- 所有启用资料类型都会显示;一寸白底证件照必传,其余选传。
|
||||
INSERT INTO `ob_onboarding_template_material`
|
||||
(`Id`, `OnboardingTemplateId`, `MaterialTypeId`, `Required`, `SortCode`,
|
||||
`Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`)
|
||||
SELECT
|
||||
7000000000000000 + material.`Id`,
|
||||
8800000000000501,
|
||||
material.`Id`,
|
||||
CASE
|
||||
WHEN material.`MaterialName` LIKE '%一寸白底%'
|
||||
OR LOWER(material.`MaterialCode`) IN ('photo', 'id_photo', 'head_photo') THEN 1
|
||||
ELSE 0
|
||||
END,
|
||||
ROW_NUMBER() OVER (ORDER BY material.`Id`) * 10,
|
||||
'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0
|
||||
FROM `ob_material_type` material
|
||||
WHERE COALESCE(material.`IsDelete`, 0) = 0
|
||||
AND LOWER(COALESCE(material.`Status`, 'enable')) = 'enable'
|
||||
AND material.`Id` BETWEEN 1 AND 2007199254740991
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`Required` = VALUES(`Required`),
|
||||
`SortCode` = VALUES(`SortCode`),
|
||||
`Status` = 'ENABLE',
|
||||
`UpdateTime` = NOW(3),
|
||||
`UpdateUser` = 'seed-script',
|
||||
`IsDelete` = 0;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 执行结果:先核对自动选中的源 DOCX,再核对初始化数量。
|
||||
SELECT
|
||||
'SUCCESS' AS `Result`,
|
||||
@ob_seed_source_docx_file_id AS `SourceDocxFileId`,
|
||||
(SELECT `Name` FROM `sys_file` WHERE `Id` = @ob_seed_source_docx_file_id LIMIT 1) AS `SourceDocxFileName`,
|
||||
8800000000000001 AS `CompanyId`,
|
||||
8800000000000102 AS `DepartmentId`,
|
||||
8800000000000202 AS `RecommendedPositionId`,
|
||||
8800000000000501 AS `OnboardingTemplateId`;
|
||||
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM `ob_company`
|
||||
WHERE `Id` = 8800000000000001 AND COALESCE(`IsDelete`, 0) = 0) AS `CompanyCount`,
|
||||
(SELECT COUNT(*) FROM `ob_department`
|
||||
WHERE `CompanyId` = 8800000000000001 AND COALESCE(`IsDelete`, 0) = 0) AS `DepartmentCount`,
|
||||
(SELECT COUNT(*) FROM `ob_position`
|
||||
WHERE `CompanyId` = 8800000000000001 AND COALESCE(`IsDelete`, 0) = 0) AS `PositionCount`,
|
||||
(SELECT COUNT(*) FROM `ob_template_variable`
|
||||
WHERE `TemplateVersionId` = 8800000000000311 AND COALESCE(`IsDelete`, 0) = 0) AS `ContractVariableCount`,
|
||||
(SELECT COUNT(*) FROM `ob_onboarding_template_material`
|
||||
WHERE `OnboardingTemplateId` = 8800000000000501 AND COALESCE(`IsDelete`, 0) = 0) AS `MaterialCount`;
|
||||
@@ -0,0 +1,227 @@
|
||||
-- OnboardX 入职人员与电子签约业务数据清理脚本
|
||||
-- 适用数据库:MySQL 8.0+
|
||||
--
|
||||
-- 会物理删除:
|
||||
-- 1. 正式员工档案;
|
||||
-- 2. 入职任务及候选人填写、材料、审核、合同快照等任务数据;
|
||||
-- 3. 契约锁流程、文档映射、回调及后台预览/盖章事件。
|
||||
--
|
||||
-- 会保留:
|
||||
-- 公司、部门、岗位、入职模板、合同模板及版本、模板适用范围、
|
||||
-- 合同变量字典、模板变量、上传资料类型及其他系统基础配置。
|
||||
--
|
||||
-- 特别注意:
|
||||
-- 1. 执行前请先备份数据库;本脚本提交后不可恢复。
|
||||
-- 2. 请先在契约锁作废仍在签署中的测试流程,或临时关闭回调;
|
||||
-- 本脚本只清理本地数据库,不能删除或作废契约锁平台上的合同。
|
||||
-- 3. 本脚本不删除 sys_file/dev_file 等系统文件记录,也不删除 OSS 对象,
|
||||
-- 避免误删合同模板源文件或其他模块共用文件。
|
||||
-- 4. DELETE 会同时清理正常数据和已经软删除的数据。
|
||||
|
||||
USE `OnboardX`;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS `ob_reset_onboarding_personnel_and_sign_data`;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE `ob_reset_onboarding_personnel_and_sign_data`()
|
||||
BEGIN
|
||||
DECLARE old_foreign_key_checks INT DEFAULT 1;
|
||||
DECLARE finished INT DEFAULT 0;
|
||||
DECLARE target_table_name VARCHAR(64);
|
||||
DECLARE before_rows BIGINT DEFAULT 0;
|
||||
DECLARE after_rows BIGINT DEFAULT 0;
|
||||
|
||||
-- 只允许删除明确列出的流程数据表。新版本即使增加 ob_ 基础表,也不会被误删。
|
||||
DECLARE target_table_cursor CURSOR FOR
|
||||
SELECT `TABLE_NAME`
|
||||
FROM `information_schema`.`TABLES`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_TYPE` = 'BASE TABLE'
|
||||
AND `TABLE_NAME` IN (
|
||||
'ob_electronic_sign_event',
|
||||
'ob_electronic_sign_document',
|
||||
'ob_electronic_sign_process',
|
||||
'ob_employee_attachment',
|
||||
'ob_employee_change_record',
|
||||
'ob_employee_contract_snapshot',
|
||||
'ob_task_operation_log',
|
||||
'ob_task_audit',
|
||||
'ob_task_material',
|
||||
'ob_task_form_value',
|
||||
'ob_task_employee_info',
|
||||
'ob_task_contract_variable',
|
||||
'ob_task_contract',
|
||||
'ob_task_link',
|
||||
'ob_task_material_requirement',
|
||||
'ob_task_form_requirement',
|
||||
'ob_onboarding_task',
|
||||
'ob_employee'
|
||||
)
|
||||
ORDER BY FIELD(
|
||||
`TABLE_NAME`,
|
||||
'ob_electronic_sign_event',
|
||||
'ob_electronic_sign_document',
|
||||
'ob_electronic_sign_process',
|
||||
'ob_employee_attachment',
|
||||
'ob_employee_change_record',
|
||||
'ob_employee_contract_snapshot',
|
||||
'ob_task_operation_log',
|
||||
'ob_task_audit',
|
||||
'ob_task_material',
|
||||
'ob_task_form_value',
|
||||
'ob_task_employee_info',
|
||||
'ob_task_contract_variable',
|
||||
'ob_task_contract',
|
||||
'ob_task_link',
|
||||
'ob_task_material_requirement',
|
||||
'ob_task_form_requirement',
|
||||
'ob_onboarding_task',
|
||||
'ob_employee'
|
||||
);
|
||||
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET finished = 1;
|
||||
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
||||
BEGIN
|
||||
ROLLBACK;
|
||||
SET FOREIGN_KEY_CHECKS = old_foreign_key_checks;
|
||||
RESIGNAL;
|
||||
END;
|
||||
|
||||
IF LOWER(DATABASE()) <> 'onboardx' THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '当前数据库不是 OnboardX,已拒绝执行人员与签约数据清理';
|
||||
END IF;
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS `tmp_ob_reset_result`;
|
||||
CREATE TEMPORARY TABLE `tmp_ob_reset_result` (
|
||||
`TableName` VARCHAR(64) NOT NULL,
|
||||
`BeforeRows` BIGINT NOT NULL,
|
||||
`DeletedRows` BIGINT NOT NULL,
|
||||
`AfterRows` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`TableName`)
|
||||
);
|
||||
|
||||
SET old_foreign_key_checks = @@FOREIGN_KEY_CHECKS;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
OPEN target_table_cursor;
|
||||
|
||||
delete_loop: LOOP
|
||||
FETCH target_table_cursor INTO target_table_name;
|
||||
IF finished = 1 THEN
|
||||
LEAVE delete_loop;
|
||||
END IF;
|
||||
|
||||
SET @ob_reset_row_count = 0;
|
||||
SET @ob_reset_count_sql = CONCAT(
|
||||
'SELECT COUNT(*) INTO @ob_reset_row_count FROM `',
|
||||
REPLACE(target_table_name, '`', '``'),
|
||||
'`'
|
||||
);
|
||||
PREPARE ob_reset_count_statement FROM @ob_reset_count_sql;
|
||||
EXECUTE ob_reset_count_statement;
|
||||
DEALLOCATE PREPARE ob_reset_count_statement;
|
||||
SET before_rows = COALESCE(@ob_reset_row_count, 0);
|
||||
|
||||
SET @ob_reset_delete_sql = CONCAT(
|
||||
'DELETE FROM `',
|
||||
REPLACE(target_table_name, '`', '``'),
|
||||
'`'
|
||||
);
|
||||
PREPARE ob_reset_delete_statement FROM @ob_reset_delete_sql;
|
||||
EXECUTE ob_reset_delete_statement;
|
||||
DEALLOCATE PREPARE ob_reset_delete_statement;
|
||||
|
||||
SET @ob_reset_row_count = 0;
|
||||
PREPARE ob_reset_count_statement FROM @ob_reset_count_sql;
|
||||
EXECUTE ob_reset_count_statement;
|
||||
DEALLOCATE PREPARE ob_reset_count_statement;
|
||||
SET after_rows = COALESCE(@ob_reset_row_count, 0);
|
||||
|
||||
INSERT INTO `tmp_ob_reset_result` (
|
||||
`TableName`,
|
||||
`BeforeRows`,
|
||||
`DeletedRows`,
|
||||
`AfterRows`
|
||||
) VALUES (
|
||||
target_table_name,
|
||||
before_rows,
|
||||
before_rows - after_rows,
|
||||
after_rows
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
CLOSE target_table_cursor;
|
||||
|
||||
COMMIT;
|
||||
SET FOREIGN_KEY_CHECKS = old_foreign_key_checks;
|
||||
|
||||
SELECT
|
||||
`TableName` AS `已清理表`,
|
||||
`BeforeRows` AS `清理前`,
|
||||
`DeletedRows` AS `删除数量`,
|
||||
`AfterRows` AS `清理后`
|
||||
FROM `tmp_ob_reset_result`
|
||||
ORDER BY FIELD(
|
||||
`TableName`,
|
||||
'ob_electronic_sign_event',
|
||||
'ob_electronic_sign_document',
|
||||
'ob_electronic_sign_process',
|
||||
'ob_employee_attachment',
|
||||
'ob_employee_change_record',
|
||||
'ob_employee_contract_snapshot',
|
||||
'ob_task_operation_log',
|
||||
'ob_task_audit',
|
||||
'ob_task_material',
|
||||
'ob_task_form_value',
|
||||
'ob_task_employee_info',
|
||||
'ob_task_contract_variable',
|
||||
'ob_task_contract',
|
||||
'ob_task_link',
|
||||
'ob_task_material_requirement',
|
||||
'ob_task_form_requirement',
|
||||
'ob_onboarding_task',
|
||||
'ob_employee'
|
||||
);
|
||||
|
||||
DROP TEMPORARY TABLE IF EXISTS `tmp_ob_reset_result`;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL `ob_reset_onboarding_personnel_and_sign_data`();
|
||||
DROP PROCEDURE IF EXISTS `ob_reset_onboarding_personnel_and_sign_data`;
|
||||
|
||||
-- 最终校验:下面存在的流程表都应为 0 行。
|
||||
SELECT
|
||||
`TABLE_NAME` AS `流程数据表`,
|
||||
`TABLE_ROWS` AS `估算剩余行数`
|
||||
FROM `information_schema`.`TABLES`
|
||||
WHERE `TABLE_SCHEMA` = DATABASE()
|
||||
AND `TABLE_TYPE` = 'BASE TABLE'
|
||||
AND `TABLE_NAME` IN (
|
||||
'ob_electronic_sign_event',
|
||||
'ob_electronic_sign_document',
|
||||
'ob_electronic_sign_process',
|
||||
'ob_employee_attachment',
|
||||
'ob_employee_change_record',
|
||||
'ob_employee_contract_snapshot',
|
||||
'ob_task_operation_log',
|
||||
'ob_task_audit',
|
||||
'ob_task_material',
|
||||
'ob_task_form_value',
|
||||
'ob_task_employee_info',
|
||||
'ob_task_contract_variable',
|
||||
'ob_task_contract',
|
||||
'ob_task_link',
|
||||
'ob_task_material_requirement',
|
||||
'ob_task_form_requirement',
|
||||
'ob_onboarding_task',
|
||||
'ob_employee'
|
||||
)
|
||||
ORDER BY `TABLE_NAME`;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 契约锁测试环境公司配置初始化
|
||||
-- 先执行 migrations/20260908_qiyuesuo_company_config.sql。
|
||||
-- 本脚本包含测试环境 Id;不要直接用于契约锁正式环境。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
UPDATE ob_company
|
||||
SET QiyuesuoCategoryId = '3533041076673835142',
|
||||
QiyuesuoSealId = '3533379903657587306',
|
||||
UpdateTime = NOW(3)
|
||||
WHERE (IsDelete = 0 OR IsDelete IS NULL)
|
||||
AND (
|
||||
TRIM(CompanyName) = '重庆远轩教育科技公司'
|
||||
OR TRIM(LegalName) = '重庆远轩教育科技公司'
|
||||
);
|
||||
|
||||
UPDATE ob_company
|
||||
SET QiyuesuoCategoryId = '3534511390926574114',
|
||||
QiyuesuoSealId = '3534750711407686595',
|
||||
UpdateTime = NOW(3)
|
||||
WHERE (IsDelete = 0 OR IsDelete IS NULL)
|
||||
AND (
|
||||
TRIM(CompanyName) = '重庆海豚科技公司'
|
||||
OR TRIM(LegalName) = '重庆海豚科技公司'
|
||||
);
|
||||
|
||||
SELECT
|
||||
Id,
|
||||
CompanyCode,
|
||||
CompanyName,
|
||||
LegalName AS QiyuesuoTenantName,
|
||||
QiyuesuoCategoryId,
|
||||
QiyuesuoSealId
|
||||
FROM ob_company
|
||||
WHERE (IsDelete = 0 OR IsDelete IS NULL)
|
||||
AND (
|
||||
TRIM(CompanyName) IN ('重庆远轩教育科技公司', '重庆海豚科技公司')
|
||||
OR TRIM(LegalName) IN ('重庆远轩教育科技公司', '重庆海豚科技公司')
|
||||
)
|
||||
ORDER BY CompanyName;
|
||||
Reference in New Issue
Block a user