Compare commits

...
Author SHA1 Message Date
fuenmao 0803d9b295 测试分支 2026-11-10 09:53:14 +08:00
fuenmao 83854ce901 新增公司盖章与骑缝章配置 2026-10-15 17:41:55 +08:00
fuenmao 0bb8263431 合同映射调整 2026-10-15 10:40:51 +08:00
fuenmao 14cfd4146e 修改正式配置 2026-10-14 16:03:12 +08:00
fuenmao 3eae5f8e5f 契约锁参数调整 2026-09-20 14:04:45 +08:00
fuenmao ff7e9c1496 相关问题修复 2026-09-18 20:44:57 +08:00
fuenmao c42d82d100 修复权限问题 2026-09-18 17:34:15 +08:00
fuenmao fa48e73b84 相关bug调整 2026-09-18 16:08:14 +08:00
fuenmao ab0248d73d 修改相关问题 2026-09-18 10:53:46 +08:00
fuenmao bd20ceac4e 修复相关bug 2026-09-17 16:04:14 +08:00
fuenmao aaec170c4f 新增必填项验证 2026-09-17 14:33:45 +08:00
fuenmao 9de05b44b8 修改相关bug 2026-09-17 11:37:39 +08:00
fuenmao e9894538e8 修复相关漏洞 2026-09-17 09:53:50 +08:00
fuenmao 20ea1bc70c 修复文件模版占位符错误问题 2026-09-15 16:43:45 +08:00
fuenmao 43a498162e 优化文档下载 2026-09-14 17:55:05 +08:00
fuenmao f004aec10f 修复相关bug 2026-09-14 15:59:37 +08:00
fuenmao 20409635d6 修复入职权限问题 2026-09-14 14:59:32 +08:00
fuenmao 1d925bd10b 员工入职模版修复 2026-09-14 10:44:30 +08:00
fuenmao 378bf7fe51 新增员工确认信息实名认证相关信息板块 2026-09-11 11:31:09 +08:00
fuenmao 571db6fc55 新增员工统计 2026-09-11 10:38:20 +08:00
fuenmao f858b04701 权限相关调整 2026-09-10 16:43:11 +08:00
fuenmao 1fd557a19e 配置调整 2026-09-09 17:08:23 +08:00
fuenmao 95b14b198a 111 2026-09-09 14:57:41 +08:00
fuenmao c015bc356c 完善第三方签署 2026-09-08 17:48:30 +08:00
fuenmao da777327f5 电子签基础功能 2026-09-07 16:17:49 +08:00
fuenmao 384eaff444 相关优化 2026-09-02 15:55:00 +08:00
fuenmao 6151872b6b 入职任务预览,入职任务cope,验证填入的信息 2026-08-29 17:26:29 +08:00
fuenmao 1d42c52d53 Merge branch 'master' of https://gitea.cnzhyx.cn/yuanlin/OnboardXAdmin 2026-08-29 11:27:09 +08:00
fuenmao b5ae0a44c7 正式库提交 2026-08-29 11:26:56 +08:00
109 changed files with 10742 additions and 406 deletions
@@ -9,5 +9,27 @@
"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": "mOCjDn0gMR",
"AppSecret": "5lPmSSRd7n8IhO3BvtWZJkBP1QNC59",
"SignatureVersion": "",
"CreatorName": "李珍",
"CreatorMobile": "13629784055",
"CallbackUrl": "https://onboardx-api.cnzhyx.cn/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
}
}
@@ -35,25 +35,35 @@ public class ObCompany : BaseEntity
/// <summary>
/// 统一社会信用代码。
/// </summary>
public string UnifiedSocialCreditCode { get; set; }
public virtual string UnifiedSocialCreditCode { get; set; }
/// <summary>
/// 法定代表人姓名。
/// </summary>
public string LegalRepresentative { get; set; }
public virtual string LegalRepresentative { get; set; }
/// <summary>
/// 工商注册地址。
/// </summary>
public string RegisteredAddress { get; set; }
public virtual string RegisteredAddress { get; set; }
/// <summary>
/// 公司联系电话。
/// </summary>
public string ContactPhone { get; set; }
public virtual string ContactPhone { get; set; }
/// <summary>
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
/// </summary>
public virtual string QiyuesuoCategoryId { get; set; }
/// <summary>
/// 契约锁企业印章 Id;每个签约公司独立配置。
/// </summary>
public virtual string QiyuesuoSealId { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
public string Remark { get; set; }
public virtual string Remark { get; set; }
}
@@ -52,6 +52,12 @@ public class ObContractTemplateVersion : BaseEntity
/// </summary>
public string VersionStatus { get; set; } = ContractTemplateVersionStatus.Draft;
/// <summary>本版本是否需要公司盖章;历史空值沿用需要盖章。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>本版本是否需要骑缝章;历史空值沿用系统骑缝章配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务部门档案,属于某家入职公司。
@@ -30,7 +30,7 @@ public class ObDepartment : BaseEntity
/// <summary>
/// 业务备注。
/// </summary>
public string Remark { get; set; }
public virtual string Remark { get; set; }
/// <summary>
/// 所属公司名称 (展示用)
@@ -0,0 +1,70 @@
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>发起本次签约时冻结的公司盖章要求。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>发起本次签约时冻结的骑缝章要求。</summary>
public bool? AcrossPageSealEnabled { 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>
@@ -56,7 +56,7 @@ public class ObPosition : BaseEntity
/// <summary>
/// 业务备注。
/// </summary>
public string Remark { get; set; }
public virtual string Remark { get; set; }
/// <summary>
/// 所属公司名称 (展示用)
@@ -52,6 +52,12 @@ public class ObTaskContract : BaseEntity
/// </summary>
public int PrintCopies { get; set; } = 1;
/// <summary>任务创建时冻结的公司盖章要求;历史空值沿用需要盖章。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>任务创建时冻结的骑缝章要求;历史空值沿用系统配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary>
/// 任务合同当前状态。
/// </summary>
@@ -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;
}
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职任务审核与退回业务能力。
@@ -6,22 +6,30 @@
public class OnboardingAuditService : IOnboardingAuditService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _config;
private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingEmployeeContractSnapshotService _employeeContractSnapshotService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingAuditService(
IConfigService config,
IOnboardingContractSnapshotService snapshotService)
IOnboardingContractSnapshotService snapshotService,
IOnboardingEmployeeContractSnapshotService employeeContractSnapshotService,
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_config = config;
_snapshotService = snapshotService;
_employeeContractSnapshotService = employeeContractSnapshotService;
_dataPermission = dataPermission;
}
/// <summary>
/// 审核通过入职任务,并在同一事务中创建正式员工档案。
/// </summary>
public async Task Approve(OnboardingAuditActionInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ApproveCore(input), protectIdentity: true);
}
private async Task ApproveCore(OnboardingAuditActionInput input)
{
var task = await GetAuditableTask(input.TaskId);
var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
@@ -47,10 +55,33 @@ public class OnboardingAuditService : IOnboardingAuditService
var contractStartDate = await ResolveContractStartDate(task.Id);
var employee = employeeInfo.Adapt<ObEmployee>();
if (string.IsNullOrWhiteSpace(employee.EmployeeNo))
{
var taskContractIds = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id)
.Select(item => item.Id)
.ToListAsync();
if (taskContractIds.Count > 0)
{
employee.EmployeeNo = await _db.Queryable<ObTaskContractVariable>()
.Where(item => taskContractIds.Contains(item.TaskContractId) && item.VariableCode == "hr.EmployeeNo" && item.VariableValue != null && item.VariableValue != "")
.OrderBy(item => item.SortCode)
.Select(item => item.VariableValue)
.FirstAsync();
}
}
employee.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
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);
@@ -69,8 +100,9 @@ public class OnboardingAuditService : IOnboardingAuditService
employee.ExtraInfoJson = global::System.Text.Json.JsonSerializer.Serialize(employeeInfo);
employee.Status = CommonStatusConst.ENABLE;
employee.CreateTime = DateTime.Now;
employee.CreateUserId = UserManager.UserId;
employee.CreateUser = UserManager.Name;
// 员工档案归属于原任务创建人,而不是执行审核的人。
employee.CreateUserId = task.CreateUserId;
employee.CreateUser = task.CreateUser;
employee.UpdateTime = null;
employee.UpdateUserId = null;
employee.UpdateUser = null;
@@ -97,6 +129,25 @@ public class OnboardingAuditService : IOnboardingAuditService
await _snapshotService.Build(task.Id, employeeInfo);
await _db.Insertable(employee).ExecuteCommandAsync();
// 全局插入审计会把 CreateUserId 设置为当前审核人。管理员代审核他人任务时,
// 员工档案仍应归属于原任务创建人,否则普通人事将无法继续维护自己发起的员工。
if (task.CreateUserId.HasValue && task.CreateUserId != UserManager.UserId)
{
await _db.Updateable<ObEmployee>()
.SetColumns(item => item.CreateUserId == task.CreateUserId)
.SetColumns(item => item.CreateUser == task.CreateUser)
.Where(item => item.Id == employee.Id)
.ExecuteCommandAsync();
var ownerPersisted = await _db.Queryable<ObEmployee>()
.AnyAsync(item => item.Id == employee.Id
&& item.CreateUserId == task.CreateUserId);
if (!ownerPersisted)
throw Oops.Bah("员工档案创建人归属保存失败,请稍后重试");
}
await _employeeContractSnapshotService.Build(employee.Id, task.Id);
task.EmployeeId = employee.Id;
task.TaskStatus = OnboardingTaskStatus.Approved;
task.AuditedAt = DateTime.Now;
@@ -114,6 +165,11 @@ public class OnboardingAuditService : IOnboardingAuditService
/// 退回入职任务,保存审核意见并允许候选人补充资料。
/// </summary>
public async Task Reject(OnboardingAuditActionInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => RejectCore(input));
}
private async Task RejectCore(OnboardingAuditActionInput input)
{
if (string.IsNullOrWhiteSpace(input.AuditOpinion))
{
@@ -160,15 +216,10 @@ public class OnboardingAuditService : IOnboardingAuditService
throw Oops.Bah("当前任务不在审核中");
}
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
?? throw Oops.Bah("请配置入职审核人");
// 审核人由全局配置与公司专属配置合并得出,不依赖独立业务表。
var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId);
if (!reviewerIds.Contains(UserManager.UserId))
{
throw Oops.Bah("您不是该入职任务的审核人");
}
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return task;
}
@@ -197,7 +248,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,25 @@
using SqlSugar;
namespace SimpleAdmin.Application;
/// <summary>版本盖章配置局部更新,避免覆盖另一个开关、版本状态或历史任务快照。</summary>
internal static class ContractTemplateVersionSealConfiguration
{
internal static IUpdateable<ObContractTemplateVersion> BuildUpdate(
ISqlSugarClient db,
OnboardingContractVersionSealInput input)
{
if (input.Id <= 0)
throw Oops.Bah("版本Id不能为空");
if (!input.CompanySealEnabled.HasValue && !input.AcrossPageSealEnabled.HasValue)
throw Oops.Bah("请指定需要修改的盖章开关");
var update = db.Updateable<ObContractTemplateVersion>()
.Where(item => item.Id == input.Id);
if (input.CompanySealEnabled.HasValue)
update = update.SetColumns(item => item.CompanySealEnabled == input.CompanySealEnabled);
if (input.AcrossPageSealEnabled.HasValue)
update = update.SetColumns(item => item.AcrossPageSealEnabled == input.AcrossPageSealEnabled);
return update;
}
}
@@ -0,0 +1,21 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 签署材料模板版本的时间有效性规则。
/// </summary>
internal static class ContractTemplateVersionValidity
{
internal static void ValidateRange(DateTime? effectiveAt, DateTime? expiresAt)
{
if (effectiveAt.HasValue && expiresAt.HasValue && expiresAt.Value <= effectiveAt.Value)
throw Oops.Bah("失效时间必须晚于生效时间");
}
internal static bool IsAvailable(ObContractTemplateVersion version, DateTime now)
{
return version != null
&& version.VersionStatus == ContractTemplateVersionStatus.Enabled
&& (!version.EffectiveDate.HasValue || version.EffectiveDate.Value <= now)
&& (!version.ExpiryDate.HasValue || version.ExpiryDate.Value > now);
}
}
@@ -0,0 +1,13 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务数据操作权限校验。
/// </summary>
public interface IOnboardingDataPermissionService : ITransient
{
Task<bool> CanManage(long? createUserId, string manageAllPermissionCode);
Task EnsureCanManage(long? createUserId, string manageAllPermissionCode, string dataName);
Task EnsureCanManage(IEnumerable<long?> createUserIds, string manageAllPermissionCode, string dataName);
}
@@ -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;
}
}
@@ -0,0 +1,169 @@
using System.Data.Common;
using System.Security.Cryptography;
using System.Text;
namespace SimpleAdmin.Application;
/// <summary>
/// 所有修改同一任务的入口共用 MySQL 命名锁,适用于同一主库上的多个后端实例。
/// 锁使用独立、非池化连接,不在第三方请求期间持有业务数据库事务。
/// </summary>
internal static class OnboardingConcurrency
{
public static Task RunTask(SqlSugarScope db, long taskId, Func<Task> action,
bool transactional = false, bool protectIdentity = false)
=> RunTask(db, taskId, async () => { await action(); return true; },
transactional, protectIdentity);
public static Task<T> RunTask<T>(SqlSugarScope db, long taskId, Func<Task<T>> action,
bool transactional = false, bool protectIdentity = false)
=> Run(db, $"task:{taskId}", () => protectIdentity
? RunPerson(db, taskId, () => InTransaction(db, action, transactional))
: InTransaction(db, action, transactional));
public static Task RunEmployee(SqlSugarScope db, long employeeId, Func<Task> action)
=> Run(db, $"employee:{employeeId}", async () => { await action(); return true; });
public static Task RunPublic(SqlSugarScope db, string token, Func<Task> action,
bool transactional = false, bool protectIdentity = false)
=> RunPublic(db, token, async () => { await action(); return true; },
transactional, protectIdentity);
public static async Task<T> RunPublic<T>(SqlSugarScope db, string token, Func<Task<T>> action,
bool transactional = false, bool protectIdentity = false)
{
if (string.IsNullOrWhiteSpace(token))
throw Oops.Bah("入职链接 Token 不能为空");
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
var link = await db.Queryable<ObTaskLink>().FirstAsync(item => item.TokenHash == hash)
?? throw Oops.Bah("链接不存在");
// 此处只定位锁;被保护的方法必须在锁内重新检查链接、任务状态和归属。
return await RunTask(db, link.TaskId, action, transactional, protectIdentity);
}
public static Task RunContract(SqlSugarScope db, long contractId, Func<Task> action,
bool transactional = false)
=> RunContract(db, contractId, async () => { await action(); return true; }, transactional);
public static async Task<T> RunContract<T>(SqlSugarScope db, long contractId, Func<Task<T>> action,
bool transactional = false)
{
var contract = await db.Queryable<ObTaskContract>().InSingleAsync(contractId)
?? throw Oops.Bah("任务合同不存在");
return await RunTask(db, contract.TaskId, action, transactional);
}
public static async Task RunMaterial(SqlSugarScope db, long materialId, Func<Task> action)
{
var material = await db.Queryable<ObTaskMaterial>().InSingleAsync(materialId)
?? throw Oops.Bah("任务资料不存在");
await RunTask(db, material.TaskId, action, transactional: true);
}
private static async Task<T> RunPerson<T>(SqlSugarScope db, long taskId, Func<Task<T>> action)
{
var info = await db.Queryable<ObTaskEmployeeInfo>().FirstAsync(item => item.TaskId == taskId)
?? throw Oops.Bah("请先完整填写基本信息");
// 统一顺序:任务 -> 身份证 -> 手机;跨任务提交/建档的查重与写入也串行。
return await Run(db, "identity:" + info.IdCardNumber?.Trim().ToUpperInvariant(),
() => Run(db, "phone:" + info.Phone?.Trim(), action));
}
private static async Task<T> InTransaction<T>(SqlSugarScope db, Func<Task<T>> action, bool enabled)
{
if (!enabled || db.Ado.Transaction != null)
return await action();
await db.Ado.BeginTranAsync();
try
{
var result = await action();
await db.Ado.CommitTranAsync();
return result;
}
catch
{
await db.Ado.RollbackTranAsync();
throw;
}
}
private static Task<T> Run<T>(SqlSugarScope db, string resource, Func<Task<T>> action)
{
if (db.CurrentConnectionConfig.DbType != global::SqlSugar.DbType.MySql)
throw Oops.Bah("入职并发保护需要 MySQL 数据库");
// 使用已选择的业务主库,不能在只读副本/不同主库上分别申请锁。
var database = db.Ado.Connection.Database;
var key = Convert.ToHexString(SHA256.HashData(
Encoding.UTF8.GetBytes($"onboardx:{database}:{resource}")));
return OnboardingOperationGate.Run(key, () => Acquire(db, key), action);
}
private static async Task<IAsyncDisposable> Acquire(SqlSugarScope db, string key)
{
var connection = (DbConnection)Activator.CreateInstance(db.Ado.Connection.GetType());
var builder = new DbConnectionStringBuilder
{
ConnectionString = db.CurrentConnectionConfig.ConnectionString
};
// 确保异常关闭时会真正关闭持锁会话,不把命名锁留在连接池里。
builder["Pooling"] = false;
connection.ConnectionString = builder.ConnectionString;
try
{
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT GET_LOCK(@lockName, 0)";
command.CommandTimeout = 5;
AddKey(command, key);
var result = await command.ExecuteScalarAsync();
if (result == null || result == DBNull.Value || Convert.ToInt32(result) != 1)
throw Oops.Bah("当前数据正在处理,请稍后刷新重试");
return new NamedLock(connection, key);
}
catch
{
await connection.DisposeAsync();
throw;
}
}
private static void AddKey(DbCommand command, string key)
{
var parameter = command.CreateParameter();
parameter.ParameterName = "@lockName";
parameter.Value = key;
command.Parameters.Add(parameter);
}
private sealed class NamedLock : IAsyncDisposable
{
private readonly DbConnection _connection;
private readonly string _key;
public NamedLock(DbConnection connection, string key)
{
_connection = connection;
_key = key;
}
public async ValueTask DisposeAsync()
{
try
{
await using var command = _connection.CreateCommand();
command.CommandText = "SELECT RELEASE_LOCK(@lockName)";
command.CommandTimeout = 5;
AddKey(command, _key);
await command.ExecuteScalarAsync();
}
catch
{
// 非池化物理连接关闭会释放会话锁;释放错误不覆盖业务原异常。
}
finally
{
await _connection.DisposeAsync();
}
}
}
}
@@ -0,0 +1,50 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务数据操作权限校验。
/// 普通人事可查看全部业务数据,但只能操作本人创建的数据;拥有模块“管理全部”按钮权限的人员可操作全部数据。
/// </summary>
public class OnboardingDataPermissionService : IOnboardingDataPermissionService
{
private readonly ISysUserService _sysUserService;
public OnboardingDataPermissionService(ISysUserService sysUserService)
{
_sysUserService = sysUserService;
}
public async Task<bool> CanManage(long? createUserId, string manageAllPermissionCode)
{
if (UserManager.SuperAdmin)
return true;
if (createUserId.HasValue && createUserId.Value == UserManager.UserId)
return true;
var buttonCodes = await _sysUserService.GetButtonCodeList(UserManager.UserId);
return buttonCodes.Contains(manageAllPermissionCode);
}
public async Task EnsureCanManage(long? createUserId, string manageAllPermissionCode, string dataName)
{
if (!await CanManage(createUserId, manageAllPermissionCode))
throw Oops.Bah($"只能操作本人创建的{dataName}");
}
public async Task EnsureCanManage(
IEnumerable<long?> createUserIds,
string manageAllPermissionCode,
string dataName)
{
var owners = createUserIds.Distinct().ToList();
if (owners.Count == 0)
return;
if (UserManager.SuperAdmin || owners.All(id => id.HasValue && id.Value == UserManager.UserId))
return;
var buttonCodes = await _sysUserService.GetButtonCodeList(UserManager.UserId);
if (!buttonCodes.Contains(manageAllPermissionCode))
throw Oops.Bah($"所选数据中包含他人创建的{dataName},不能操作");
}
}
@@ -0,0 +1,35 @@
using System.Threading;
namespace SimpleAdmin.Application;
/// <summary>
/// 使同一异步调用链可重入,独立请求仍须取得外部互斥锁。
/// 不在 Acquire 中设置 AsyncLocal,避免值不能流回调用方的问题。
/// </summary>
internal static class OnboardingOperationGate
{
private sealed record Frame(string Key, Frame Parent);
private static readonly AsyncLocal<Frame> Held = new();
internal static async Task<T> Run<T>(
string key,
Func<Task<IAsyncDisposable>> acquire,
Func<Task<T>> action)
{
for (var frame = Held.Value; frame != null; frame = frame.Parent)
if (frame.Key == key)
return await action();
await using var lease = await acquire();
var previous = Held.Value;
Held.Value = new Frame(key, previous);
try
{
return await action();
}
finally
{
Held.Value = previous;
}
}
}
@@ -0,0 +1,13 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务按钮权限编码。
/// </summary>
public static class OnboardingPermissionCodes
{
public const string SetupManageAll = "onboardingSetupManageAll";
public const string EntryConfigManageAll = "onboardingEntryConfigManageAll";
public const string ContractManageAll = "onboardingContractManageAll";
public const string TaskManageAll = "onboardingTaskManageAll";
public const string EmployeeManageAll = "onboardingEmployeeManageAll";
}
@@ -1,64 +0,0 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 解析入职审核人系统配置,统一支持全局审核人和公司专属审核人。
/// </summary>
internal static class OnboardingReviewerConfigParser
{
private const string ConfigKey = "onboard.reviewer.userIds";
/// <summary>
/// 获取指定公司的全部有效审核人用户 Id。
/// </summary>
public static HashSet<long> Parse(string configValue, long companyId)
{
if (string.IsNullOrWhiteSpace(configValue))
throw Oops.Bah($"系统配置 {ConfigKey} 不能为空");
try
{
using var document = global::System.Text.Json.JsonDocument.Parse(configValue);
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Object)
throw new global::System.Text.Json.JsonException();
var reviewerIds = new HashSet<long>();
if (document.RootElement.TryGetProperty("all", out var allReviewers))
AddReviewers(allReviewers, reviewerIds);
if (document.RootElement.TryGetProperty("company", out var companyReviewers))
{
if (companyReviewers.ValueKind != global::System.Text.Json.JsonValueKind.Object)
throw new global::System.Text.Json.JsonException();
if (companyReviewers.TryGetProperty(companyId.ToString(), out var reviewers))
AddReviewers(reviewers, reviewerIds);
}
return reviewerIds;
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah(
$"系统配置 {ConfigKey} 格式不正确,应为包含 all 数组和 company 对象的 JSON");
}
}
/// <summary>
/// 将 JSON 数组中的审核人 Id 加入集合,并拒绝非整数配置。
/// </summary>
private static void AddReviewers(
global::System.Text.Json.JsonElement element,
HashSet<long> reviewerIds)
{
if (element.ValueKind != global::System.Text.Json.JsonValueKind.Array)
throw new global::System.Text.Json.JsonException();
foreach (var reviewer in element.EnumerateArray())
{
if (!reviewer.TryGetInt64(out var reviewerId) || reviewerId <= 0)
throw new global::System.Text.Json.JsonException();
reviewerIds.Add(reviewerId);
}
}
}
@@ -30,19 +30,64 @@ public class OnboardingCompanyAddInput : ObCompany
/// 公司业务编码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司编码不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司编码不能超过50个字符")]
public override string CompanyCode { get; set; }
/// <summary>
/// 公司名称。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司名称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司名称不能超过50个字符")]
public override string CompanyName { get; set; }
/// <summary>
/// 合同甲方完整法定名称。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "合同甲方全称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(100, ErrorMessage = "合同甲方全称不能超过100个字符")]
public override string LegalName { get; set; }
/// <summary>
/// 统一社会信用代码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(18, ErrorMessage = "统一社会信用代码不能超过18位")]
public override string UnifiedSocialCreditCode { get; set; }
/// <summary>
/// 法定代表人姓名。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "法定代表人不能超过20个字符")]
public override string LegalRepresentative { get; set; }
/// <summary>
/// 工商注册地址。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(150, ErrorMessage = "注册地址不能超过150个字符")]
public override string RegisteredAddress { get; set; }
/// <summary>
/// 公司联系电话。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "联系电话不能超过20个字符")]
public override string ContactPhone { get; set; }
/// <summary>
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁业务分类Id不能超过19位数字")]
public override string QiyuesuoCategoryId { get; set; }
/// <summary>
/// 契约锁企业印章 Id;每个签约公司独立配置。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁印章Id不能超过19位数字")]
public override string QiyuesuoSealId { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(300, ErrorMessage = "备注不能超过300个字符")]
public override string Remark { get; set; }
}
/// <summary>
@@ -5,6 +5,13 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingCompanyService
{
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingCompanyService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询入职业务公司。
/// </summary>
@@ -14,7 +21,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyCode), it => it.CompanyCode.Contains(input.CompanyCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyName), it => it.CompanyName.Contains(input.CompanyName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyCode", "CompanyName", "LegalName", "ContactPhone"))
.OrderBy(it => it.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
}
@@ -43,6 +50,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,7 +60,12 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
/// </summary>
public async Task Edit(OnboardingCompanyEditInput input)
{
await Detail(new BaseIdInput { Id = input.Id });
NormalizeAndValidateQiyuesuoConfig(input);
var company = await Detail(new BaseIdInput { Id = input.Id });
await _dataPermission.EnsureCanManage(
company.CreateUserId,
OnboardingPermissionCodes.SetupManageAll,
"公司");
await EnsureCodeUnique(input.CompanyCode, input.Id);
await UpdateAsync(input.Adapt<ObCompany>());
}
@@ -65,6 +78,16 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
if (input.Ids.Count == 0)
return;
var companies = await Context.Queryable<ObCompany>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (companies.Count != input.Ids.Distinct().Count())
throw Oops.Bah("公司不存在");
await _dataPermission.EnsureCanManage(
companies.Select(item => item.CreateUserId),
OnboardingPermissionCodes.SetupManageAll,
"公司");
var hasPositions = await Context.Queryable<ObPosition>()
.AnyAsync(it => input.Ids.Contains(it.CompanyId));
if (hasPositions)
@@ -98,6 +121,36 @@ 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 (!company.QiyuesuoCategoryId.All(char.IsDigit)
|| !company.QiyuesuoSealId.All(char.IsDigit))
{
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写纯数字");
}
if (!long.TryParse(company.QiyuesuoCategoryId, out _)
|| !long.TryParse(company.QiyuesuoSealId, out _))
{
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 超出有效数字范围(最大支持19位长整数)");
}
}
private async Task EnsureCodeUnique(string code, long id)
{
var exists = await Context.Queryable<ObCompany>()
@@ -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; }
}
@@ -5,8 +5,25 @@ 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>
Task<long> Generate(OnboardingGenerateContractInput input);
/// <summary>
/// 员工公开入口已验证一次性任务链接后,生成该链接所属的合同文件。
/// </summary>
Task<long> GenerateForPublicAccess(long taskContractId, long taskId);
}
@@ -7,26 +7,342 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
{
private readonly SqlSugarScope _db;
private readonly IFileService _fileService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingContractDocumentService(IFileService fileService)
public OnboardingContractDocumentService(
IFileService fileService,
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_fileService = fileService;
_dataPermission = dataPermission;
}
/// <summary>
/// 在任务创建前生成指定合同的临时 DOCX,仅通过响应返回,不写文件表和 OSS。
/// </summary>
public async Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
OnboardingContractPreviewInput input)
{
var context = await LoadPreviewContext(input);
var contractConfig = input.ContractTemplateVersionId.HasValue
? context.ContractConfigs.FirstOrDefault(item =>
item.ContractTemplateVersionId == input.ContractTemplateVersionId.Value)
: context.ContractConfigs.FirstOrDefault();
if (contractConfig == null)
throw Oops.Bah("所选合同不属于当前入职模板或已被移除");
return await RenderPreview(input, context, contractConfig, null);
}
/// <summary>
/// 在任务创建前生成全部合同预览,并按模板顺序打包为 ZIP。
/// </summary>
public async Task<OnboardingContractPreviewOutput> PreviewAllBeforeCreate(
OnboardingContractPreviewInput input)
{
var context = await LoadPreviewContext(input);
using var zipBytes = new MemoryStream();
using (var archive = new global::System.IO.Compression.ZipArchive(
zipBytes,
global::System.IO.Compression.ZipArchiveMode.Create,
true))
{
for (var index = 0; index < context.ContractConfigs.Count; index++)
{
var preview = await RenderPreview(
input,
context,
context.ContractConfigs[index],
index + 1);
var entry = archive.CreateEntry(
preview.FileName,
global::System.IO.Compression.CompressionLevel.Fastest);
await using var entryStream = entry.Open();
await entryStream.WriteAsync(preview.FileBytes);
}
}
return new OnboardingContractPreviewOutput
{
FileBytes = zipBytes.ToArray(),
FileName = $"全部合同_人事预览_{DateTime.Now:yyyyMMddHHmmss}.zip"
};
}
/// <summary>
/// 校验预览上下文,并读取入职模板锁定的全部合同配置。
/// </summary>
private async Task<ContractPreviewContext> LoadPreviewContext(
OnboardingContractPreviewInput input)
{
var company = await _db.Queryable<ObCompany>()
.FirstAsync(item => item.Id == input.CompanyId
&& item.Status == CommonStatusConst.ENABLE)
?? throw Oops.Bah("公司不存在或未启用");
var departmentExists = await _db.Queryable<ObDepartment>()
.AnyAsync(item => item.Id == input.DepartmentId
&& item.CompanyId == input.CompanyId
&& item.Status == CommonStatusConst.ENABLE);
if (!departmentExists)
throw Oops.Bah("部门不存在、未启用或不属于该公司");
var positionExists = await _db.Queryable<ObPosition>()
.AnyAsync(item => item.Id == input.PositionId
&& item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == CommonStatusConst.ENABLE);
if (!positionExists)
throw Oops.Bah("岗位不存在、未启用或不属于该部门/公司");
var onboardingTemplate = await _db.Queryable<ObOnboardingTemplate>()
.FirstAsync(item => item.Id == input.OnboardingTemplateId
&& item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == CommonStatusConst.ENABLE)
?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司、部门");
var supportsPosition = await _db.Queryable<ObOnboardingTemplatePosition>()
.AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id
&& item.PositionId == input.PositionId);
if (!supportsPosition)
throw Oops.Bah("所选入职模板不适用于该岗位");
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
.OrderBy(item => item.SortCode)
.OrderBy(item => item.Id)
.ToListAsync();
if (contractConfigs.Count == 0)
throw Oops.Bah("入职模板尚未配置合同文件");
var duplicateVersion = contractConfigs
.GroupBy(item => item.ContractTemplateVersionId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateVersion != null)
throw Oops.Bah("入职模板中存在重复的合同版本,请先调整模板配置");
var templateIds = contractConfigs
.Select(item => item.ContractTemplateId)
.Distinct()
.ToList();
var contractTemplates = await _db.Queryable<ObContractTemplate>()
.Where(item => templateIds.Contains(item.Id))
.ToListAsync();
if (contractTemplates.Count != templateIds.Count
|| contractTemplates.Any(item => item.CompanyId != input.CompanyId
|| item.DepartmentId != input.DepartmentId
|| !string.Equals(
item.Status,
CommonStatusConst.ENABLE,
global::System.StringComparison.OrdinalIgnoreCase)))
{
throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板");
}
var supportedTemplateCount = await _db.Queryable<ObContractTemplatePosition>()
.CountAsync(item => templateIds.Contains(item.ContractTemplateId)
&& item.PositionId == input.PositionId);
if (supportedTemplateCount != templateIds.Count)
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
var versionIds = contractConfigs
.Select(item => item.ContractTemplateVersionId)
.ToList();
var versions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
var now = DateTime.Now;
var versionsValid = versions.Count == versionIds.Count
&& contractConfigs.All(config => versions.Any(version =>
version.Id == config.ContractTemplateVersionId
&& version.TemplateId == config.ContractTemplateId
&& ContractTemplateVersionValidity.IsAvailable(version, now)));
if (!versionsValid)
throw Oops.Bah("入职模板包含不存在、未生效、已失效、未启用或归属错误的签署材料版本");
var inputs = input.ContractVariables
?? new List<OnboardingInitialContractVariableInput>();
OnboardingTaskService.ValidateInitialContractDateRanges(inputs);
var duplicateInput = inputs
.GroupBy(item => new
{
item.ContractTemplateVersionId,
VariableCode = item.VariableCode?.ToUpperInvariant()
})
.FirstOrDefault(group => group.Count() > 1);
if (duplicateInput != null)
throw Oops.Bah($"合同变量“{duplicateInput.Key.VariableCode}”重复提交");
var unknownVersionInput = inputs.FirstOrDefault(item =>
!versionIds.Contains(item.ContractTemplateVersionId));
if (unknownVersionInput != null)
throw Oops.Bah($"合同变量“{unknownVersionInput.VariableCode}”不属于当前入职模板");
return new ContractPreviewContext
{
Company = company,
ContractConfigs = contractConfigs,
ContractTemplates = contractTemplates,
Versions = versions
};
}
/// <summary>
/// 根据一条合同配置和当前人事参数渲染一份 DOCX。
/// </summary>
private async Task<OnboardingContractPreviewOutput> RenderPreview(
OnboardingContractPreviewInput input,
ContractPreviewContext context,
ObOnboardingTemplateContract contractConfig,
int? fileOrder)
{
var contractTemplate = context.ContractTemplates.First(item =>
item.Id == contractConfig.ContractTemplateId);
var version = context.Versions.First(item =>
item.Id == contractConfig.ContractTemplateVersionId);
var definitions = await _db.Queryable<ObTemplateVariable>()
.Where(item => item.TemplateVersionId == version.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var duplicateDefinition = definitions
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateDefinition != null)
throw Oops.Bah($"合同变量“{duplicateDefinition.Key}”存在重复定义");
var inputs = (input.ContractVariables
?? new List<OnboardingInitialContractVariableInput>())
.Where(item => item.ContractTemplateVersionId == version.Id)
.ToList();
var editableDefinitions = definitions
.Where(IsHrEditableDefinition)
.ToList();
foreach (var submitted in inputs)
{
var definitionExists = editableDefinitions.Any(definition =>
string.Equals(
definition.VariableCode,
submitted.VariableCode,
StringComparison.OrdinalIgnoreCase));
if (!definitionExists)
throw Oops.Bah($"合同变量“{submitted.VariableCode}”不存在或不允许人事填写");
}
var missingRequired = editableDefinitions.FirstOrDefault(definition =>
{
if (!definition.Required)
return false;
var submitted = inputs.FirstOrDefault(item =>
string.Equals(
item.VariableCode,
definition.VariableCode,
StringComparison.OrdinalIgnoreCase));
return string.IsNullOrWhiteSpace(
submitted?.VariableValue ?? definition.DefaultValue);
});
if (missingRequired != null)
throw Oops.Bah($"请填写合同变量“{missingRequired.VariableName}”");
var previewTask = new ObOnboardingTask
{
TaskNo = "【发起后自动生成】",
CompanyId = input.CompanyId,
DepartmentId = input.DepartmentId,
PositionId = input.PositionId,
OnboardingTemplateId = input.OnboardingTemplateId
};
var emptyDynamicValues = new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
var values = definitions.ToDictionary(
definition => definition.VariableCode,
definition =>
{
var submitted = inputs.FirstOrDefault(item =>
string.Equals(
item.VariableCode,
definition.VariableCode,
StringComparison.OrdinalIgnoreCase));
if (submitted != null)
return submitted.VariableValue ?? string.Empty;
return OnboardingContractSnapshotService.ResolveValue(
definition,
null,
emptyDynamicValues,
context.Company,
previewTask,
true)
?? string.Empty;
},
StringComparer.OrdinalIgnoreCase);
var source = await _fileService.Download(
new BaseIdInput { Id = version.SourceDocxFileId })
?? throw Oops.Bah("无法读取合同源文件");
await using var sourceStream = source.FileStream;
using var sourceBytes = new MemoryStream();
await sourceStream.CopyToAsync(sourceBytes);
return new OnboardingContractPreviewOutput
{
FileBytes = DocxTemplateProcessor.Render(
sourceBytes.ToArray(),
values),
FileName = BuildPreviewFileName(contractTemplate.TemplateName, fileOrder)
};
}
/// <summary>
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
/// </summary>
public async Task<long> Generate(OnboardingGenerateContractInput input)
{
return await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => GenerateCore(input));
}
private async Task<long> GenerateCore(OnboardingGenerateContractInput input)
{
var contract = await _db.Queryable<ObTaskContract>().InSingleAsync(input.TaskContractId)
?? throw Oops.Bah("任务合同不存在");
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(contract.TaskId)
?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return await GenerateCore(contract, task);
}
/// <inheritdoc />
public async Task<long> GenerateForPublicAccess(long taskContractId, long taskId)
{
return await OnboardingConcurrency.RunTask(_db, taskId, () => GenerateForPublicAccessCore(taskContractId, taskId));
}
private async Task<long> GenerateForPublicAccessCore(long taskContractId, long taskId)
{
var contract = await _db.Queryable<ObTaskContract>()
.FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId)
?? throw Oops.Bah("合同不存在");
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
return await GenerateCore(contract, task);
}
/// <summary>
/// 执行已经完成调用方权限或公开链接归属校验的合同生成逻辑。
/// </summary>
private async Task<long> GenerateCore(ObTaskContract contract, ObOnboardingTask task)
{
var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill
|| task.TaskStatus == OnboardingTaskStatus.Rejected
|| task.TaskStatus == OnboardingTaskStatus.Reviewing
|| task.TaskStatus == OnboardingTaskStatus.Approved;
|| task.TaskStatus == OnboardingTaskStatus.Approved
|| task.TaskStatus == OnboardingTaskStatus.Archived;
if (!statusAllowsGeneration)
throw Oops.Bah("当前任务状态不能生成合同");
if (contract.GeneratedDocxFileId.HasValue)
@@ -56,7 +372,7 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
.FirstOrDefault(definition => string.IsNullOrWhiteSpace(
variables.FirstOrDefault(variable => variable.VariableCode == definition.VariableCode)?.VariableValue));
if (missing != null)
throw Oops.Bah($"合同变量“{missing.VariableName}”尚未填写,暂时不能预览合同");
throw Oops.Bah($"合同“{contract.ContractName}”的变量“{missing.VariableName}”尚未填写,暂时不能预览合同");
var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id })
?? throw Oops.Bah("无法读取合同源文件");
@@ -71,8 +387,19 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
item => item.VariableValue ?? string.Empty,
StringComparer.OrdinalIgnoreCase));
var generatedAt = DateTime.Now;
// 在文件名中加入生成批次,避免手机系统把同名的新合同误认为历史下载文件。
var name = $"{task.TaskNo}_{contract.ContractName}_{generatedAt:yyyyMMddHHmmssfff}.docx";
var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == task.Id);
var employeeName = string.IsNullOrWhiteSpace(employeeInfo?.EmployeeName)
? "新员工"
: employeeInfo.EmployeeName.Trim();
var docType = string.IsNullOrWhiteSpace(contract.ContractType)
? (string.IsNullOrWhiteSpace(contract.ContractName) ? "签署材料" : contract.ContractName.Trim())
: contract.ContractType.Trim();
var invalidChars = global::System.IO.Path.GetInvalidFileNameChars();
var safeEmployeeName = new string(employeeName.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray());
var safeDocType = new string(docType.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray());
// 按照规范:编号_员工姓名_文档类型.docx
var name = $"{task.TaskNo}_{safeEmployeeName}_{safeDocType}.docx";
var fileId = await _fileService.StoreGeneratedFile(
SysDictConst.FILE_ENGINE_ALIYUN_OSS,
name,
@@ -86,4 +413,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; }
}
}
@@ -19,6 +19,14 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
long taskId,
ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false)
{
await OnboardingConcurrency.RunTask(_db, taskId, () => BuildCore(taskId, employeeInfo, invalidateGeneratedPreview), transactional: true);
}
private async Task BuildCore(
long taskId,
ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false)
{
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
@@ -110,12 +118,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 +142,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 +160,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;
}
@@ -212,7 +232,8 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
{
return sourceField.ToUpperInvariant() switch
{
"COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.CompanyCode),
// 内置合同变量 CompanyCode 表示统一社会信用代码,保留旧 DOCX 占位符兼容。
"COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
"COMPANYNAME" or "COMPANY_NAME" => nameof(ObCompany.CompanyName),
"LEGALNAME" or "LEGAL_NAME" or "COMPANYLEGALNAME" => nameof(ObCompany.LegalName),
"UNIFIEDSOCIALCREDITCODE" or "UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
@@ -16,13 +16,18 @@ public static class DocxTemplateProcessor
/// 扫描正文、表格、页眉和页脚,返回去重后的变量编码。
/// </summary>
public static List<string> ExtractVariableCodes(byte[] docx)
{
ValidateDocxHeader(docx);
try
{
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);
if (archive.GetEntry("word/document.xml") == null)
throw Oops.Bah("上传文件不是有效的 DOCX 文档,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
{
using var stream = entry.Open();
@@ -30,6 +35,7 @@ public static class DocxTemplateProcessor
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
{
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
ValidatePlaceholderSyntax(text);
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
codes.Add(match.Groups["code"].Value.Trim());
}
@@ -37,12 +43,88 @@ public static class DocxTemplateProcessor
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>
/// 占位符提取正则只会匹配正确格式,因此必须先主动检查错误格式,
/// 避免缺少花括号或变量编码写错时被静默忽略并继续创建版本。
/// </summary>
private static void ValidatePlaceholderSyntax(string text)
{
if (string.IsNullOrEmpty(text))
return;
if (text.Contains("{{", StringComparison.Ordinal)
|| text.Contains("}}", StringComparison.Ordinal))
{
throw Oops.Bah(
"DOCX 占位符格式错误:检测到中文全角花括号。请使用英文半角双花括号,例如 {{employee.EmployeeName}}。");
}
if (text.Contains("{{{", StringComparison.Ordinal)
|| text.Contains("}}}", StringComparison.Ordinal))
{
throw Oops.Bah(
"DOCX 占位符格式错误:检测到多余的花括号。占位符必须使用成对的英文半角双花括号,例如 {{employee.EmployeeName}}。");
}
var searchStart = 0;
while (searchStart < text.Length)
{
var openIndex = text.IndexOf("{{", searchStart, StringComparison.Ordinal);
var closeIndex = text.IndexOf("}}", searchStart, StringComparison.Ordinal);
if (closeIndex >= 0 && (openIndex < 0 || closeIndex < openIndex))
{
throw Oops.Bah(
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, closeIndex)} 缺少对应的起始符“{{{{”。正确格式示例:{{{{employee.EmployeeName}}}}。");
}
if (openIndex < 0)
return;
closeIndex = text.IndexOf("}}", openIndex + 2, StringComparison.Ordinal);
if (closeIndex < 0)
{
throw Oops.Bah(
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, openIndex)} 缺少结束符“}}}}”。正确格式示例:{{{{employee.EmployeeName}}}}。");
}
var placeholder = text.Substring(openIndex, closeIndex + 2 - openIndex);
var match = PlaceholderRegex.Match(placeholder);
if (!match.Success || match.Length != placeholder.Length)
{
throw Oops.Bah(
$"DOCX 占位符格式错误:“{placeholder}”。变量编码只能包含英文字母、数字、下划线、点和短横线,并且必须使用成对的英文半角双花括号。");
}
searchStart = closeIndex + 2;
}
}
private static string BuildPlaceholderPreview(string text, int markerIndex)
{
const int maxLength = 60;
var preview = text[markerIndex..].Trim();
if (preview.Length > maxLength)
preview = preview[..maxLength] + "…";
return $"“{preview}”";
}
/// <summary>
/// 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。
/// </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 +239,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)”后重新上传");
}
}
@@ -46,15 +46,21 @@ public class OnboardingContractVersionAddInput
public string FileHash { get; set; }
/// <summary>
/// 生效日期。
/// 生效时间。
/// </summary>
public DateTime? EffectiveDate { get; set; }
/// <summary>
/// 失效日期。
/// 失效时间。
/// </summary>
public DateTime? ExpiryDate { get; set; }
/// <summary>本版本是否需要公司盖章;旧客户端未传时沿用原规则。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>本版本是否需要骑缝章;旧客户端未传时沿用系统配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
@@ -78,3 +84,19 @@ public class OnboardingContractVersionStatusInput
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "版本状态不能为空")]
public string VersionStatus { get; set; }
}
/// <summary>
/// 修改合同模板版本的盖章配置;未传的开关保持不变。
/// </summary>
public class OnboardingContractVersionSealInput
{
/// <summary>合同版本 Id。</summary>
[IdNotNull(ErrorMessage = "版本Id不能为空")]
public long Id { get; set; }
/// <summary>是否需要公司盖章。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>是否需要骑缝章。</summary>
public bool? AcrossPageSealEnabled { get; set; }
}
@@ -50,6 +50,11 @@ public interface IOnboardingContractTemplateService : ITransient
/// </summary>
Task SetVersionStatus(OnboardingContractVersionStatusInput input);
/// <summary>
/// 修改合同模板版本的盖章配置,仅影响后续新建任务。
/// </summary>
Task SetVersionSeals(OnboardingContractVersionSealInput input);
/// <summary>
/// 删除未被入职模板或任务使用的合同版本。
/// </summary>
@@ -6,10 +6,14 @@ namespace SimpleAdmin.Application;
public class OnboardingContractTemplateService : DbRepository<ObContractTemplate>, IOnboardingContractTemplateService
{
private readonly IFileService _fileService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingContractTemplateService(IFileService fileService)
public OnboardingContractTemplateService(
IFileService fileService,
IOnboardingDataPermissionService dataPermission)
{
_fileService = fileService;
_dataPermission = dataPermission;
}
/// <summary>
@@ -36,7 +40,8 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
}
var page = await query
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "TemplateCode", "TemplateName", "ContractType"))
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.TemplateName)
.ToPagedListAsync(input.PageNum, input.PageSize);
await PopulateScopeDisplay(page.List.ToList());
@@ -74,7 +79,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (candidates.Count == 0)
return candidates;
var now = DateTime.Now;
var candidateIds = candidates.Select(item => item.Id).ToList();
var availableVersionTemplateIds = (await Context.Queryable<ObContractTemplateVersion>()
.Where(item => candidateIds.Contains(item.TemplateId))
.ToListAsync())
.Where(item => ContractTemplateVersionValidity.IsAvailable(item, now))
.Select(item => item.TemplateId)
.ToHashSet();
candidates = candidates
.Where(item => availableVersionTemplateIds.Contains(item.Id))
.ToList();
if (candidates.Count == 0)
return candidates;
candidateIds = candidates.Select(item => item.Id).ToList();
var relations = await Context.Queryable<ObContractTemplatePosition>()
.Where(item => candidateIds.Contains(item.ContractTemplateId))
.ToListAsync();
@@ -121,10 +140,14 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary>
public async Task Edit(OnboardingContractTemplateEditInput input)
{
await Detail(new BaseIdInput { Id = input.Id });
var template = await Detail(new BaseIdInput { Id = input.Id });
await _dataPermission.EnsureCanManage(
template.CreateUserId,
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
NormalizeInput(input);
await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds);
await ValidateReferencedOnboardingTemplates(input);
await ValidateReferencedOnboardingTemplates(input, template.Status);
await EnsureCodeUnique(input.TemplateCode, input.Id);
var entity = BuildEntity(input.Id, input);
@@ -155,6 +178,16 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (input.Ids.Count == 0)
return;
var templates = await Context.Queryable<ObContractTemplate>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (templates.Count != input.Ids.Distinct().Count())
throw Oops.Bah("合同模板不存在");
await _dataPermission.EnsureCanManage(
templates.Select(item => item.CreateUserId),
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => input.Ids.Contains(it.ContractTemplateId));
if (hasOnboardingTemplates)
@@ -183,10 +216,6 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
.ExecuteCommandAsync();
}
await Context.Deleteable<ObContractScope>()
.Where(it => input.Ids.Contains(it.TemplateId))
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplatePosition>()
.Where(item => input.Ids.Contains(item.ContractTemplateId))
.ExecuteCommandAsync();
@@ -208,6 +237,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
{
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("合同版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
var isUsedInOnboardingTemplate = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => it.ContractTemplateVersionId == version.Id);
@@ -225,9 +255,6 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
await Context.Deleteable<ObTemplateVariable>()
.Where(it => it.TemplateVersionId == version.Id)
.ExecuteCommandAsync();
await Context.Deleteable<ObContractScope>()
.Where(it => it.TemplateVersionId == version.Id)
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplateVersion>()
.In(version.Id)
.ExecuteCommandAsync();
@@ -245,11 +272,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary>
public async Task<SqlSugarPagedList<ObContractTemplateVersion>> VersionPage(OnboardingContractVersionPageInput input)
{
return await Context.Queryable<ObContractTemplateVersion>()
var page = await Context.Queryable<ObContractTemplateVersion>()
.Where(it => it.TemplateId == input.TemplateId)
.WhereIF(!string.IsNullOrWhiteSpace(input.VersionStatus), it => it.VersionStatus == input.VersionStatus)
.OrderByDescending(it => it.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
// 仅解析返回值,不回写历史空值,使列表开关显示真实生效的规则。
var setting = App.GetConfig<QiyuesuoSettingOptions>("QiyuesuoSetting", true)
?? new QiyuesuoSettingOptions();
foreach (var version in page.List)
{
version.CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(version.CompanySealEnabled);
version.AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(version.AcrossPageSealEnabled, setting);
}
return page;
}
/// <summary>
@@ -257,7 +294,12 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary>
public async Task AddVersion(OnboardingContractVersionAddInput input)
{
await Detail(new BaseIdInput { Id = input.TemplateId });
ContractTemplateVersionValidity.ValidateRange(input.EffectiveDate, input.ExpiryDate);
var template = await Detail(new BaseIdInput { Id = input.TemplateId });
await _dataPermission.EnsureCanManage(
template.CreateUserId,
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
var file = await Context.Queryable<SysFile>().InSingleAsync(input.SourceDocxFileId)
?? throw Oops.Bah("DOCX 源文件不存在");
if (!string.Equals(file.Suffix, "docx", StringComparison.OrdinalIgnoreCase))
@@ -336,9 +378,25 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("模板版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
if (version.VersionStatus == ContractTemplateVersionStatus.Enabled
&& input.VersionStatus == ContractTemplateVersionStatus.Disabled)
{
var referencedNames = await GetEnabledOnboardingTemplateNames(
version.TemplateId,
version.Id);
if (referencedNames.Count > 0)
{
throw Oops.Bah(
$"该签署材料版本正在被启用中的入职模板{FormatTemplateNames(referencedNames)}引用,不能停用,请先调整或停用这些入职模板");
}
}
if (input.VersionStatus == ContractTemplateVersionStatus.Enabled)
{
ContractTemplateVersionValidity.ValidateRange(version.EffectiveDate, version.ExpiryDate);
if (version.ExpiryDate.HasValue && version.ExpiryDate.Value <= DateTime.Now)
throw Oops.Bah("该签署材料版本已经失效,不能启用");
var hasUnresolvedVariables = await Context.Queryable<ObTemplateVariable>()
.AnyAsync(item => item.TemplateVersionId == input.Id
&& item.VariableType == ContractVariableType.Unresolved);
@@ -347,7 +405,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
}
version.VersionStatus = input.VersionStatus;
await Context.Updateable(version).ExecuteCommandAsync();
await Context.Updateable(version)
.UpdateColumns(item => new { item.VersionStatus, item.UpdateTime, item.UpdateUserId, item.UpdateUser })
.ExecuteCommandAsync();
}
/// <summary>
/// 修改版本盖章开关;任务和电子签约已经保存独立快照,不随版本变更。
/// </summary>
public async Task SetVersionSeals(OnboardingContractVersionSealInput input)
{
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("模板版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
await ContractTemplateVersionSealConfiguration.BuildUpdate(Context, input).ExecuteCommandAsync();
}
/// <summary>
@@ -373,6 +445,11 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (!string.Equals(variable.VariableCode, input.VariableCode, StringComparison.Ordinal))
throw Oops.Bah("DOCX 占位符编码由系统识别,不能修改");
var version = await Context.Queryable<ObContractTemplateVersion>()
.InSingleAsync(variable.TemplateVersionId)
?? throw Oops.Bah("模板版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
var isUsedInTask = await Context.Queryable<ObTaskContract>()
.AnyAsync(it => it.TemplateVersionId == input.TemplateVersionId);
if (isUsedInTask)
@@ -383,6 +460,16 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
await Context.Updateable(entity).ExecuteCommandAsync();
}
private async Task EnsureTemplateCanManage(long templateId)
{
var template = await Context.Queryable<ObContractTemplate>().InSingleAsync(templateId)
?? throw Oops.Bah("合同模板不存在");
await _dataPermission.EnsureCanManage(
template.CreateUserId,
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
}
private static ObTemplateVariable BuildDetectedVariable(long versionId, string code, ObVariableDefinition def, int index)
{
var separatorIndex = code.IndexOf('.');
@@ -449,13 +536,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("请填写模板编码、模板名称和合同类型");
@@ -500,7 +586,8 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// 防止修改合同模板归属后破坏已经保存的入职模板配置。
/// </summary>
private async Task ValidateReferencedOnboardingTemplates(
OnboardingContractTemplateEditInput input)
OnboardingContractTemplateEditInput input,
string currentStatus)
{
var relations = await Context.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.ContractTemplateId == input.Id)
@@ -515,6 +602,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
var onboardingTemplates = await Context.Queryable<ObOnboardingTemplate>()
.Where(item => onboardingTemplateIds.Contains(item.Id))
.ToListAsync();
if (string.Equals(currentStatus, "enable", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(input.Status, "enable", StringComparison.OrdinalIgnoreCase))
{
var enabledTemplateNames = onboardingTemplates
.Where(item => !item.IsDelete && item.Status == "enable")
.Select(item => item.TemplateName)
.Distinct()
.OrderBy(item => item)
.ToList();
if (enabledTemplateNames.Count > 0)
{
throw Oops.Bah(
$"该签署材料模板正在被启用中的入职模板{FormatTemplateNames(enabledTemplateNames)}引用,不能停用,请先调整或停用这些入职模板");
}
}
var positionRelations = await Context.Queryable<ObOnboardingTemplatePosition>()
.Where(item => onboardingTemplateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
@@ -539,6 +641,39 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
}
}
private async Task<List<string>> GetEnabledOnboardingTemplateNames(
long contractTemplateId,
long? contractTemplateVersionId = null)
{
var relations = await Context.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.ContractTemplateId == contractTemplateId)
.WhereIF(contractTemplateVersionId.HasValue,
item => item.ContractTemplateVersionId == contractTemplateVersionId.Value)
.ToListAsync();
if (relations.Count == 0)
return new List<string>();
var onboardingTemplateIds = relations
.Select(item => item.OnboardingTemplateId)
.Distinct()
.ToList();
var names = await Context.Queryable<ObOnboardingTemplate>()
.Where(item => onboardingTemplateIds.Contains(item.Id)
&& !item.IsDelete
&& item.Status == "enable")
.Select(item => item.TemplateName)
.ToListAsync();
return names
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct()
.OrderBy(item => item)
.ToList();
}
private static string FormatTemplateNames(IEnumerable<string> templateNames)
{
return string.Join("、", templateNames.Select(item => $"“{item}”"));
}
/// <summary>
/// 构建合同模板主档案实体。
/// </summary>
@@ -0,0 +1,12 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 校验任务合同变量的必填状态和员工动态变量数据类型。
/// </summary>
public interface IOnboardingContractVariableValidationService : ITransient
{
/// <summary>
/// 校验指定任务的全部合同变量快照。
/// </summary>
Task Validate(long taskId);
}
@@ -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
};
}
}
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务部门分页查询参数。
@@ -41,13 +41,21 @@ public class OnboardingDepartmentAddInput : ObDepartment
/// 部门业务编码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门编码不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门编码不能超过50个字符")]
public override string DepartmentCode { get; set; }
/// <summary>
/// 部门名称。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门名称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门名称不能超过50个字符")]
public override string DepartmentName { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")]
public override string Remark { get; set; }
}
/// <summary>
@@ -1,10 +1,17 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职业务部门业务能力。
/// </summary>
public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardingDepartmentService
{
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingDepartmentService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询入职业务部门。
/// </summary>
@@ -15,7 +22,7 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
.WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentCode), it => it.DepartmentCode.Contains(input.DepartmentCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentName), it => it.DepartmentName.Contains(input.DepartmentName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentCode", "DepartmentName", "SortCode"))
.OrderBy(it => it.SortCode)
.OrderBy(it => it.DepartmentName)
.ToPagedListAsync(input.PageNum, input.PageSize);
@@ -78,6 +85,10 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
{
NormalizeInput(input);
var department = await Detail(new BaseIdInput { Id = input.Id });
await _dataPermission.EnsureCanManage(
department.CreateUserId,
OnboardingPermissionCodes.SetupManageAll,
"部门");
await EnsureCompanyCanChange(department, input.CompanyId);
await EnsureCompanyExists(input.CompanyId);
await EnsureCodeUnique(input.CompanyId, input.DepartmentCode, input.Id);
@@ -92,6 +103,16 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
if (input.Ids.Count == 0)
return;
var departments = await Context.Queryable<ObDepartment>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (departments.Count != input.Ids.Distinct().Count())
throw Oops.Bah("部门不存在");
await _dataPermission.EnsureCanManage(
departments.Select(item => item.CreateUserId),
OnboardingPermissionCodes.SetupManageAll,
"部门");
var hasPositions = await Context.Queryable<ObPosition>()
.AnyAsync(it => it.DepartmentId.HasValue && input.Ids.Contains(it.DepartmentId.Value));
if (hasPositions)
@@ -133,12 +154,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")
@@ -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; }
}
@@ -0,0 +1,173 @@
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; }
/// <summary>本份文件是否需要公司盖章。</summary>
public bool CompanySealEnabled { get; set; }
/// <summary>本份文件是否需要骑缝章。</summary>
public bool AcrossPageSealEnabled { get; set; }
}
@@ -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);
}
@@ -0,0 +1,23 @@
namespace SimpleAdmin.Application;
/// <summary>同步第三方状态时的单向迁移保护;未知/陈旧结果不能覆盖已确认的签署进度。</summary>
internal static class OnboardingSignStateGuard
{
internal static string Resolve(string current, string incoming)
{
if (current == ElectronicSignStatus.Archived && incoming == ElectronicSignStatus.Completed)
return ElectronicSignStatus.Archived;
if (IsTerminal(current) && current != incoming)
throw Oops.Bah("契约锁返回状态与本地终态不一致,已保留本地状态,请核实后重试");
if (current == ElectronicSignStatus.PendingCompanySeal
&& incoming is not (ElectronicSignStatus.PendingCompanySeal
or ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired))
throw Oops.Bah("契约锁状态暂未同步,已保留员工签署结果,请稍后重试");
return incoming;
}
private static bool IsTerminal(string status)
=> status is ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired;
}
@@ -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);
}
@@ -0,0 +1,667 @@
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)
{
return await PostJson<QiyuesuoContractResult>(
"/v2/contract/draft",
QiyuesuoContractRequestBuilder.BuildDraft(input));
}
/// <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,
// 入职合同的签署位置由系统统一预设,禁止签署人自行增加位置,
// 避免同一份材料出现“预设签名 + 手动新增签名”的重复签名。
stamperRule = "MUST_FORBID",
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);
}
}
}
@@ -0,0 +1,259 @@
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 bool RequiresCompanySign { get; set; } = true;
}
/// <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; }
}
@@ -0,0 +1,206 @@
namespace SimpleAdmin.Application;
/// <summary>发起签约前按文件冻结的签字、盖章要求。</summary>
internal sealed record OnboardingSigningDocument(
string DocumentId,
string EmployeeSignKeyword,
string CompanySealKeyword,
bool? CompanySealEnabled,
bool? AcrossPageSealEnabled);
/// <summary>构造契约锁请求;无数据库和网络访问,员工签字始终覆盖全部文件1。</summary>
internal static class QiyuesuoContractRequestBuilder
{
internal static bool CompanySealEnabled(bool? enabled) => enabled ?? true;
internal static bool AcrossPageSealEnabled(bool? enabled, QiyuesuoSettingOptions setting) =>
enabled ?? setting.EnableAcrossPageSeal;
internal static bool RequiresCompanySign(
bool? companySealEnabled,
bool? acrossPageSealEnabled,
QiyuesuoSettingOptions setting) =>
CompanySealEnabled(companySealEnabled) || AcrossPageSealEnabled(acrossPageSealEnabled, setting);
internal static object BuildDraft(QiyuesuoCreateDraftInput input)
{
long sealId = 0;
if (input.RequiresCompanySign && !long.TryParse(input.SealId, out sealId))
throw Oops.Bah("契约锁 SealId 配置格式不正确");
var signatories = new List<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"
}
}
}
};
if (input.RequiresCompanySign)
{
signatories.Add(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 new
{
bizId = input.BizId,
subject = input.Subject,
tenantName = input.CompanyName,
description = "OnboardX 入职电子签",
send = false,
callbackUrl = string.IsNullOrWhiteSpace(input.CallbackUrl) ? null : input.CallbackUrl.Trim(),
category = new { id = input.CategoryId },
creator = new
{
name = input.CreatorName,
contact = input.CreatorMobile,
contactType = "MOBILE"
},
signatories
};
}
internal static List<QiyuesuoStamperInput> BuildStampers(
IReadOnlyList<OnboardingSigningDocument> documents,
string personalSignatoryId,
string companyActionId,
string sealId,
QiyuesuoSettingOptions setting)
{
if (documents.Count == 0)
throw Oops.Bah("当前电子签流程没有可签署的文件");
if (documents.Any(item => string.IsNullOrWhiteSpace(item.DocumentId)))
throw Oops.Bah("契约锁未返回签署文档 Id");
if (string.IsNullOrWhiteSpace(personalSignatoryId))
throw Oops.Bah("契约锁未返回员工签署方 Id");
if (documents.Any(item => RequiresCompanySign(item.CompanySealEnabled, item.AcrossPageSealEnabled, setting))
&& string.IsNullOrWhiteSpace(companyActionId))
throw Oops.Bah("契约锁草稿未返回企业盖章动作");
var stampers = new List<QiyuesuoStamperInput>();
foreach (var document in documents)
{
if (string.IsNullOrWhiteSpace(document.EmployeeSignKeyword))
throw Oops.Bah("请配置员工签名定位关键字");
stampers.Add(new QiyuesuoStamperInput
{
SignatoryId = personalSignatoryId,
Type = "PERSONAL",
DocumentId = document.DocumentId,
Keyword = document.EmployeeSignKeyword,
KeywordIndex = Math.Max(1, setting.EmployeeSignKeywordIndex)
});
if (CompanySealEnabled(document.CompanySealEnabled))
{
if (string.IsNullOrWhiteSpace(document.CompanySealKeyword))
throw Oops.Bah("需要公司盖章的文件未配置企业盖章定位关键字");
stampers.Add(new QiyuesuoStamperInput
{
ActionId = companyActionId,
Type = "COMPANY",
DocumentId = document.DocumentId,
SealId = sealId,
Keyword = document.CompanySealKeyword,
KeywordIndex = Math.Max(1, setting.CompanySealKeywordIndex),
OffsetX = setting.CompanySealOffsetX,
OffsetY = setting.CompanySealOffsetY
});
}
}
// 合并骑缝章也只能包含已开启该开关的文件,不能把只需个人签字的材料混入。
var acrossDocuments = documents
.Where(item => AcrossPageSealEnabled(item.AcrossPageSealEnabled, setting))
.Select(item => item.DocumentId)
.ToList();
if (acrossDocuments.Count == 0)
return stampers;
var acrossType = setting.AcrossPageSealType?.Trim().ToUpperInvariant();
if (acrossType is not ("ALL_DOCUMENT" or "SINGLE_DOCUMENT"))
throw Oops.Bah("契约锁骑缝章模式只能配置为 ALL_DOCUMENT 或 SINGLE_DOCUMENT");
if (acrossType == "ALL_DOCUMENT")
{
stampers.Add(new QiyuesuoStamperInput
{
ActionId = companyActionId,
Type = "ACROSS_PAGE",
DocumentId = acrossDocuments[0],
DocumentIds = string.Join(",", acrossDocuments),
SealId = sealId,
AcrossType = acrossType
});
}
else
{
foreach (var documentId in acrossDocuments)
{
stampers.Add(new QiyuesuoStamperInput
{
ActionId = companyActionId,
Type = "ACROSS_PAGE",
DocumentId = documentId,
SealId = sealId,
AcrossType = acrossType
});
}
}
return stampers;
}
internal static string ResolvePendingStatus(QiyuesuoContractResult contract)
{
var employeeSigned = contract.Signatories.Any(item =>
string.Equals(item.TenantType, "PERSONAL", StringComparison.OrdinalIgnoreCase)
&& string.Equals(item.Status, "SIGNED", StringComparison.OrdinalIgnoreCase));
var hasCompanyAction = contract.Signatories.Any(item =>
string.Equals(item.TenantType, "COMPANY", StringComparison.OrdinalIgnoreCase)
&& item.Actions.Any(action => string.Equals(action.Type, "COMPANY", StringComparison.OrdinalIgnoreCase)));
// 纯员工签字流程等待平台 COMPLETE 回执,不制造不存在的企业盖章阶段。
return employeeSigned && hasCompanyAction
? ElectronicSignStatus.PendingCompanySeal
: ElectronicSignStatus.PendingEmployeeSign;
}
}
@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace SimpleAdmin.Application;
/// <summary>
@@ -29,6 +31,136 @@ 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>
/// 合同即将到期筛选(30天内到期或已到期)。
/// </summary>
public bool? ContractExpiringSoon { get; set; }
}
/// <summary>
/// 正式员工档案分页数据及当前查询条件下的维度统计。
/// </summary>
public class OnboardingEmployeePageOutput : SqlSugarPagedList<ObEmployee>
{
/// <summary>
/// 不受快捷维度筛选影响的员工维度统计。
/// </summary>
public OnboardingEmployeeStatisticsOutput Statistics { get; set; } = new();
}
/// <summary>
/// 正式员工档案维度统计。
/// </summary>
public class OnboardingEmployeeStatisticsOutput
{
/// <summary>
/// 在册总数。
/// </summary>
public int Total { get; set; }
/// <summary>
/// 正式员工数量。
/// </summary>
public int FullTime { get; set; }
/// <summary>
/// 兼职员工数量。
/// </summary>
public int PartTime { get; set; }
/// <summary>
/// 试用期待转正数量。
/// </summary>
public int Probation { get; set; }
/// <summary>
/// 社保已缴纳数量。
/// </summary>
public int SocialPurchased { get; set; }
/// <summary>
/// 合同即将到期数量(30天内到期或已到期)。
/// </summary>
public int ExpiringContract { 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 +188,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,14 +1,30 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义正式员工档案查询业务能力。
/// 定义员工档案查询和人事维护业务能力。
/// </summary>
public interface IOnboardingEmployeeService : ITransient
{
/// <summary>
/// 分页查询审核通过后生成的正式员工档案。
/// 分页查询审核通过后生成的正式员工档案及维度统计。
/// </summary>
Task<SqlSugarPagedList<ObEmployee>> Page(OnboardingEmployeePageInput input);
Task<OnboardingEmployeePageOutput> 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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义正式员工合同快照归档能力。
/// </summary>
public interface IOnboardingEmployeeContractSnapshotService : ITransient
{
/// <summary>冻结一名员工来源任务中的全部合同和变量。</summary>
Task Build(long employeeId, long taskId);
}
@@ -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;
}
}
@@ -6,10 +6,12 @@ namespace SimpleAdmin.Application;
public class OnboardingEntryConfigService : IOnboardingEntryConfigService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingEntryConfigService()
public OnboardingEntryConfigService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <summary>
@@ -27,6 +29,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)
@@ -50,10 +57,15 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Id.HasValue)
{
var exists = await _db.Queryable<ObFormField>().AnyAsync(x => x.Id == input.Id.Value);
if (!exists)
throw Oops.Bah("字段不存在");
var exists = await _db.Queryable<ObFormField>().InSingleAsync(input.Id.Value)
?? throw Oops.Bah("字段不存在");
await _dataPermission.EnsureCanManage(
exists.CreateUserId,
OnboardingPermissionCodes.EntryConfigManageAll,
"入职字段");
entity.CreateUserId = exists.CreateUserId;
entity.CreateUser = exists.CreateUser;
entity.CreateTime = exists.CreateTime;
await _db.Updateable(entity).ExecuteCommandAsync();
return;
}
@@ -90,12 +102,21 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Ids.Count == 0)
return;
var isReferenced = await _db.Queryable<ObFormRequirement>()
var fields = await _db.Queryable<ObFormField>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (fields.Count != input.Ids.Distinct().Count())
throw Oops.Bah("字段不存在");
await _dataPermission.EnsureCanManage(
fields.Select(item => item.CreateUserId),
OnboardingPermissionCodes.EntryConfigManageAll,
"入职字段");
var isReferencedByTask = await _db.Queryable<ObTaskFormRequirement>()
.AnyAsync(x => input.Ids.Contains(x.FieldId));
var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateFormField>()
.AnyAsync(x => input.Ids.Contains(x.FieldId));
if (isReferenced || isReferencedByTemplate)
throw Oops.Bah("字段已被入职模板引用,不能删除");
if (isReferencedByTask)
throw Oops.Bah("字段已被入职任务引用,不能删除,可改为停用");
await _db.Deleteable<ObFormField>().In(input.Ids).ExecuteCommandAsync();
}
@@ -115,8 +136,30 @@ 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");
if (input.MaxFileSizeMb.HasValue && (input.MaxFileSizeMb.Value <= 0 || input.MaxFileSizeMb.Value > 20))
throw Oops.Bah("单个文件大小必须设置在1至20MB之间");
var supportedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"png", "jpg", "jpeg", "gif", "bmp", "webp", "pdf"
};
var allowedExtensions = (input.AllowedExtensions ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(item => item.TrimStart('.').ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (allowedExtensions.Count == 0)
allowedExtensions.AddRange(new[] { "png", "jpg", "jpeg", "pdf" });
var unsupportedExtensions = allowedExtensions.Where(item => !supportedExtensions.Contains(item)).ToList();
if (unsupportedExtensions.Count > 0)
throw Oops.Bah($"不支持的材料扩展名:{string.Join("、", unsupportedExtensions)},仅允许图片或PDF文件");
input.AllowedExtensions = string.Join(",", allowedExtensions);
var materialCodeExists = await _db.Queryable<ObMaterialType>()
.AnyAsync(x => x.MaterialCode == input.MaterialCode && x.Id != input.Id);
@@ -136,10 +179,15 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Id.HasValue)
{
var exists = await _db.Queryable<ObMaterialType>().AnyAsync(x => x.Id == input.Id.Value);
if (!exists)
throw Oops.Bah("资料类型不存在");
var exists = await _db.Queryable<ObMaterialType>().InSingleAsync(input.Id.Value)
?? throw Oops.Bah("资料类型不存在");
await _dataPermission.EnsureCanManage(
exists.CreateUserId,
OnboardingPermissionCodes.EntryConfigManageAll,
"资料类型");
entity.CreateUserId = exists.CreateUserId;
entity.CreateUser = exists.CreateUser;
entity.CreateTime = exists.CreateTime;
await _db.Updateable(entity).ExecuteCommandAsync();
return;
}
@@ -155,13 +203,26 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Ids.Count == 0)
return;
var isReferenced = await _db.Queryable<ObMaterialRequirement>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
var materialTypes = await _db.Queryable<ObMaterialType>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (materialTypes.Count != input.Ids.Distinct().Count())
throw Oops.Bah("资料类型不存在");
await _dataPermission.EnsureCanManage(
materialTypes.Select(item => item.CreateUserId),
OnboardingPermissionCodes.EntryConfigManageAll,
"资料类型");
var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateMaterial>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (isReferenced || isReferencedByTemplate)
if (isReferencedByTemplate)
throw Oops.Bah("资料类型已被入职模板引用,不能删除");
var isReferencedByTask = await _db.Queryable<ObTaskMaterialRequirement>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (isReferencedByTask)
throw Oops.Bah("资料类型已被入职任务引用,不能删除,可改为停用");
var hasUploads = await _db.Queryable<ObTaskMaterial>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (hasUploads)
@@ -31,6 +31,14 @@ public class OnboardingIdentityValidationService : IOnboardingIdentityValidation
throw Oops.Bah("该姓名和身份证号已完成正式员工建档,请勿重复提交入职资料");
}
// 身份证号码代表唯一自然人。即使姓名填写不同,也不能绕过正式员工重复校验。
var formalEmployeeIdCardExists = await _db.Queryable<ObEmployee>()
.AnyAsync(item => item.IdCardNumber == idCardNumber);
if (formalEmployeeIdCardExists)
{
throw Oops.Bah("该身份证号码已完成正式员工建档,请核对姓名和身份证号码;如需更正,请联系 HR");
}
// 待填写任务不占用身份,以实际先提交的任务为准;撤销或过期任务允许重新发起。
var duplicateTaskExists = await _db.Queryable<ObTaskEmployeeInfo>()
.InnerJoin<ObOnboardingTask>((info, task) => info.TaskId == task.Id)
@@ -42,9 +50,24 @@ public class OnboardingIdentityValidationService : IOnboardingIdentityValidation
|| task.TaskStatus == OnboardingTaskStatus.Approved
|| task.TaskStatus == OnboardingTaskStatus.Archived))
.AnyAsync();
if (!duplicateTaskExists)
return;
if (duplicateTaskExists)
{
throw Oops.Bah("该姓名和身份证号已存在正在办理的入职任务,请勿重复提交;如需补充材料,请联系 HR 继续使用原任务链接");
}
// 再单独按身份证号码校验,防止不同姓名使用同一证件重复提交不同任务。
var duplicateIdCardTaskExists = await _db.Queryable<ObTaskEmployeeInfo>()
.InnerJoin<ObOnboardingTask>((info, task) => info.TaskId == task.Id)
.Where((info, task) => info.TaskId != currentTaskId
&& info.IdCardNumber == idCardNumber
&& (task.TaskStatus == OnboardingTaskStatus.Reviewing
|| task.TaskStatus == OnboardingTaskStatus.Rejected
|| task.TaskStatus == OnboardingTaskStatus.Approved
|| task.TaskStatus == OnboardingTaskStatus.Archived))
.AnyAsync();
if (duplicateIdCardTaskExists)
{
throw Oops.Bah("该身份证号码已存在正在办理的入职任务,请核对姓名和身份证号码;如需补充材料,请联系 HR 继续使用原任务链接");
}
}
}
@@ -6,18 +6,23 @@ namespace SimpleAdmin.Application;
public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _config;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskLifecycleService(IConfigService config)
public OnboardingTaskLifecycleService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_config = config;
_dataPermission = dataPermission;
}
/// <summary>
/// 归档已审核通过的入职任务。
/// </summary>
public async Task Archive(OnboardingAuditActionInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ArchiveCore(input));
}
private async Task ArchiveCore(OnboardingAuditActionInput input)
{
var task = await Authorize(input.TaskId);
if (task.TaskStatus != OnboardingTaskStatus.Approved)
@@ -57,6 +62,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 撤销入职任务并同步撤销仍然有效的员工填写链接。
/// </summary>
public async Task Revoke(OnboardingAuditActionInput input)
{
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => RevokeCore(input));
}
private async Task RevokeCore(OnboardingAuditActionInput input)
{
var task = await Authorize(input.TaskId);
if (task.TaskStatus == OnboardingTaskStatus.Approved
@@ -102,6 +112,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
/// 删除已撤销的入职任务及其全部关联快照数据。
/// </summary>
public async Task Delete(BaseIdInput input)
{
await OnboardingConcurrency.RunTask(_db, input.Id, () => DeleteCore(input));
}
private async Task DeleteCore(BaseIdInput input)
{
var task = await Authorize(input.Id);
if (task.TaskStatus != OnboardingTaskStatus.Revoked)
@@ -117,6 +132,28 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
.Select(x => x.Id)
.ToListAsync();
var signProcessIds = await _db.Queryable<ObElectronicSignProcess>()
.Where(x => x.TaskId == task.Id)
.Select(x => x.Id)
.ToListAsync();
if (signProcessIds.Count > 0)
{
await _db.Deleteable<ObElectronicSignEvent>()
.Where(x => x.SignProcessId.HasValue
&& signProcessIds.Contains(x.SignProcessId.Value))
.ExecuteCommandAsync();
await _db.Deleteable<ObElectronicSignDocument>()
.Where(x => signProcessIds.Contains(x.SignProcessId))
.ExecuteCommandAsync();
await _db.Deleteable<ObElectronicSignProcess>()
.Where(x => signProcessIds.Contains(x.Id))
.ExecuteCommandAsync();
}
await _db.Deleteable<ObEmployeeContractSnapshot>()
.Where(x => x.TaskId == task.Id)
.ExecuteCommandAsync();
if (taskContractIds.Count > 0)
{
await _db.Deleteable<ObTaskContractVariable>()
@@ -130,7 +167,6 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
await _db.Deleteable<ObTaskMaterial>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskEmployeeInfo>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskFormRequirement>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskFormValue>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskOperationLog>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskAudit>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObOnboardingTask>().In(task.Id).ExecuteCommandAsync();
@@ -147,14 +183,10 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
{
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
?? throw Oops.Bah("请配置入职审核人");
var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId);
if (!reviewerIds.Contains(UserManager.UserId))
{
throw Oops.Bah("您不是该入职任务的审核人");
}
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return task;
}
@@ -6,18 +6,24 @@ namespace SimpleAdmin.Application;
public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _config;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingMaterialReviewService(IConfigService config)
public OnboardingMaterialReviewService(
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_config = config;
_dataPermission = dataPermission;
}
/// <summary>
/// 审核单份候选人上传材料并记录审核意见。
/// </summary>
public async Task Review(OnboardingMaterialReviewInput input)
{
await OnboardingConcurrency.RunMaterial(_db, input.TaskMaterialId, () => ReviewCore(input));
}
private async Task ReviewCore(OnboardingMaterialReviewInput input)
{
if (input.ReviewStatus != TaskMaterialReviewStatus.Passed
&& input.ReviewStatus != TaskMaterialReviewStatus.NeedSupplement)
@@ -46,15 +52,11 @@ public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
await _db.Updateable(material).ExecuteCommandAsync();
}
private async Task EnsureReviewer(ObOnboardingTask task)
private Task EnsureReviewer(ObOnboardingTask task)
{
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
?? throw Oops.Bah("请配置入职审核人");
var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId);
if (!reviewerIds.Contains(UserManager.UserId))
{
throw Oops.Bah("您不是该入职任务的审核人");
}
return _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
}
}
@@ -6,10 +6,12 @@ namespace SimpleAdmin.Application;
public class OnboardingTemplateService : IOnboardingTemplateService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTemplateService()
public OnboardingTemplateService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <inheritdoc />
@@ -59,6 +61,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService
return new OnboardingTemplateDetailOutput
{
Id = summary.Id,
CreateUserId = summary.CreateUserId,
CreateUser = summary.CreateUser,
TemplateCode = summary.TemplateCode,
TemplateName = summary.TemplateName,
CompanyId = summary.CompanyId,
@@ -92,12 +96,41 @@ 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 codeOwnerQuery = _db.Queryable<ObOnboardingTemplate>()
.ClearFilter()
.Where(item => item.TemplateCode == input.TemplateCode);
if (input.Id.HasValue)
{
var currentTemplateId = input.Id.Value;
codeOwnerQuery = codeOwnerQuery.Where(item => item.Id != currentTemplateId);
}
var codeOwner = await codeOwnerQuery.FirstAsync();
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();
if (input.Id.HasValue || isRestore)
{
var existing = isRestore
? codeOwner
: await _db.Queryable<ObOnboardingTemplate>().InSingleAsync(templateId);
if (existing == null)
throw Oops.Bah("入职模板不存在");
await _dataPermission.EnsureCanManage(
existing.CreateUserId,
OnboardingPermissionCodes.EntryConfigManageAll,
"入职模板");
}
var entity = new ObOnboardingTemplate
{
Id = templateId,
@@ -106,15 +139,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();
@@ -142,6 +177,16 @@ public class OnboardingTemplateService : IOnboardingTemplateService
if (input.Ids.Count == 0)
return;
var templates = await _db.Queryable<ObOnboardingTemplate>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (templates.Count != input.Ids.Distinct().Count())
throw Oops.Bah("入职模板不存在");
await _dataPermission.EnsureCanManage(
templates.Select(item => item.CreateUserId),
OnboardingPermissionCodes.EntryConfigManageAll,
"入职模板");
var isUsed = await _db.Queryable<ObOnboardingTask>()
.AnyAsync(item => input.Ids.Contains(item.OnboardingTemplateId));
if (isUsed)
@@ -184,12 +229,73 @@ public class OnboardingTemplateService : IOnboardingTemplateService
&& templateIds.Contains(item.Id))
.OrderBy(item => item.TemplateName)
.ToListAsync();
if (templates.Count == 0)
return await BuildOutputs(templates);
var onboardingTemplateIds = templates.Select(item => item.Id).ToList();
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => onboardingTemplateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
if (contractConfigs.Count == 0)
return await BuildOutputs(new List<ObOnboardingTemplate>());
var contractTemplateIds = contractConfigs
.Select(item => item.ContractTemplateId)
.Distinct()
.ToList();
var contractVersionIds = contractConfigs
.Select(item => item.ContractTemplateVersionId)
.Distinct()
.ToList();
var contractTemplates = await _db.Queryable<ObContractTemplate>()
.Where(item => contractTemplateIds.Contains(item.Id))
.ToListAsync();
var contractVersions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => contractVersionIds.Contains(item.Id))
.ToListAsync();
var contractPositions = await _db.Queryable<ObContractTemplatePosition>()
.Where(item => contractTemplateIds.Contains(item.ContractTemplateId)
&& item.PositionId == positionId)
.ToListAsync();
var now = DateTime.Now;
var availableTemplates = templates.Where(template =>
{
var configs = contractConfigs
.Where(item => item.OnboardingTemplateId == template.Id)
.ToList();
if (configs.Count == 0)
return false;
return configs.All(config =>
{
var contractTemplate = contractTemplates
.FirstOrDefault(item => item.Id == config.ContractTemplateId);
var version = contractVersions
.FirstOrDefault(item => item.Id == config.ContractTemplateVersionId);
return contractTemplate != null
&& string.Equals(
contractTemplate.Status,
"enable",
StringComparison.OrdinalIgnoreCase)
&& contractTemplate.CompanyId == template.CompanyId
&& contractTemplate.DepartmentId == template.DepartmentId
&& contractPositions.Any(item =>
item.ContractTemplateId == config.ContractTemplateId)
&& version != null
&& version.TemplateId == config.ContractTemplateId
&& ContractTemplateVersionValidity.IsAvailable(version, now);
});
}).ToList();
return await BuildOutputs(availableTemplates);
}
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 +314,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)
@@ -263,6 +369,9 @@ public class OnboardingTemplateService : IOnboardingTemplateService
{
throw Oops.Bah("合同模板与版本不匹配");
}
var now = DateTime.Now;
if (versions.Any(version => !ContractTemplateVersionValidity.IsAvailable(version, now)))
throw Oops.Bah("入职模板只能选择当前已生效且尚未失效的启用签署材料版本");
var contractTemplateIds = input.Contracts
.Select(item => item.ContractTemplateId)
@@ -299,10 +408,11 @@ 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("启用入职模板前,请先启用全部合同版本");
}
var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList();
@@ -404,6 +514,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService
return new OnboardingTemplateOutput
{
Id = template.Id,
CreateUserId = template.CreateUserId,
CreateUser = template.CreateUser,
TemplateCode = template.TemplateCode,
TemplateName = template.TemplateName,
CompanyId = template.CompanyId,
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务岗位分页查询参数。
@@ -52,13 +52,21 @@ public class OnboardingPositionAddInput : ObPosition
/// 岗位业务编码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位编码不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位编码不能超过50个字符")]
public override string PositionCode { get; set; }
/// <summary>
/// 岗位名称。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位名称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位名称不能超过50个字符")]
public override string PositionName { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")]
public override string Remark { get; set; }
}
/// <summary>
@@ -1,10 +1,17 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职业务岗位业务能力。
/// </summary>
public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPositionService
{
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingPositionService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询入职业务岗位。
/// </summary>
@@ -16,7 +23,8 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionCode), it => it.PositionCode.Contains(input.PositionCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "PositionCode", "PositionName"))
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.PositionName)
.ToPagedListAsync(input.PageNum, input.PageSize);
@@ -106,6 +114,10 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
throw Oops.Bah("请选择所属部门");
var position = await Detail(new BaseIdInput { Id = input.Id });
await _dataPermission.EnsureCanManage(
position.CreateUserId,
OnboardingPermissionCodes.SetupManageAll,
"岗位");
await EnsureScopeCanChange(position, input.CompanyId, input.DepartmentId.Value);
await EnsureCompanyExists(input.CompanyId);
await EnsureDepartmentExists(input.CompanyId, input.DepartmentId.Value);
@@ -121,6 +133,16 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
if (input.Ids.Count == 0)
return;
var positions = await Context.Queryable<ObPosition>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (positions.Count != input.Ids.Distinct().Count())
throw Oops.Bah("岗位不存在");
await _dataPermission.EnsureCanManage(
positions.Select(item => item.CreateUserId),
OnboardingPermissionCodes.SetupManageAll,
"岗位");
var hasTasks = await Context.Queryable<ObOnboardingTask>()
.AnyAsync(it => input.Ids.Contains(it.PositionId));
if (hasTasks)
@@ -167,12 +189,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")
@@ -71,4 +71,27 @@ public class OnboardingSubmitInput
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string Token { get; set; }
/// <summary>
/// 员工是否已在最终提交前逐项确认实名认证信息。
/// </summary>
public bool IdentityInformationConfirmed { get; set; }
/// <summary>
/// 员工最终确认的真实姓名。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string ConfirmedEmployeeName { get; set; }
/// <summary>
/// 员工最终确认的身份证号码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string ConfirmedIdCardNumber { get; set; }
/// <summary>
/// 员工最终确认的手机号码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string ConfirmedPhone { get; set; }
}
@@ -5,23 +5,32 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService
{
private const int PublicMaterialMaxFileSizeMb = 20;
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>
/// 关联已上传 OSS 文件与任务材料要求。
/// </summary>
public async Task SaveMaterial(OnboardingSaveMaterialInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveMaterialCore(input), transactional: true);
}
private async Task SaveMaterialCore(OnboardingSaveMaterialInput input)
{
var task = await GetEditableTask(input.Token);
var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
@@ -81,6 +90,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// </summary>
public async Task DeleteMaterial(
OnboardingDeleteMaterialInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => DeleteMaterialCore(input), transactional: true);
}
private async Task DeleteMaterialCore(
OnboardingDeleteMaterialInput input)
{
var task = await GetEditableTask(input.Token);
var material = await _db.Queryable<ObTaskMaterial>()
@@ -99,6 +114,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 记录候选人已经查看并确认动态生成的合同。
/// </summary>
public async Task ConfirmContract(OnboardingConfirmContractInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => ConfirmContractCore(input), transactional: true);
}
private async Task ConfirmContractCore(OnboardingConfirmContractInput input)
{
var task = await GetEditableTask(input.Token);
var contract = await _db.Queryable<ObTaskContract>()
@@ -116,18 +136,23 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
/// </summary>
public async Task Submit(OnboardingSubmitInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SubmitCore(input), transactional: true, protectIdentity: true);
}
private async Task SubmitCore(OnboardingSubmitInput input)
{
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("请先完整填写基本信息");
// 最终提交必须重新执行全量校验,不能只依赖员工手机端的页面校验。
var formRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
OnboardingEmployeeInfoValidator.ValidateAndNormalize(info, formRequirements);
EnsureIdentityInformationConfirmed(input, info);
await _db.Updateable(info).ExecuteCommandAsync();
await _identityValidationService.EnsureNoDuplicate(
task.Id,
@@ -148,18 +173,28 @@ 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);
if (hasUnconfirmedRequiredContract)
throw Oops.Bah("请查看并确认全部必签合同");
var submittedAt = DateTime.Now;
var identityConfirmationHash = BuildIdentityConfirmationHash(info);
var originalStatus = task.TaskStatus;
var originalSubmitCount = task.SubmitCount;
task.TaskStatus = OnboardingTaskStatus.Reviewing;
task.SubmitCount++;
task.LastSubmittedAt = DateTime.Now;
task.LastSubmittedAt = submittedAt;
task.RejectReason = null;
await _db.Updateable(task).ExecuteCommandAsync();
var changed = await _db.Updateable(task)
.UpdateColumns(item => new { item.TaskStatus, item.SubmitCount, item.LastSubmittedAt, item.RejectReason })
.Where(item => item.Id == task.Id && item.TaskStatus == originalStatus
&& item.SubmitCount == originalSubmitCount)
.ExecuteCommandAsync();
if (changed != 1)
throw Oops.Bah("任务状态已变更,请刷新后重试");
await _db.Insertable(new ObTaskAudit
{
@@ -167,9 +202,18 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
TaskId = task.Id,
AuditRound = task.SubmitCount,
AuditAction = OnboardingAuditAction.Submit,
AuditOpinion = "员工提交入职资料",
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new { info, materials }),
AuditedAt = DateTime.Now
AuditOpinion = "员工提交入职资料,并确认姓名、身份证号码和手机号码用于实名认证",
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new
{
info,
materials,
identityConfirmation = new
{
confirmedAt = submittedAt,
summaryHash = identityConfirmationHash
}
}),
AuditedAt = submittedAt
}).ExecuteCommandAsync();
}
@@ -190,10 +234,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
if (suffixNotAllowed)
throw Oops.Bah("该材料不允许上传此文件类型");
var fileTooLarge = requirement.MaxFileSizeMb.HasValue
&& input.FileLength > requirement.MaxFileSizeMb.Value * 1024L * 1024L;
var maxFileSizeMb = requirement.MaxFileSizeMb.HasValue
? Math.Min(requirement.MaxFileSizeMb.Value, PublicMaterialMaxFileSizeMb)
: PublicMaterialMaxFileSizeMb;
var fileTooLarge = input.FileLength > maxFileSizeMb * 1024L * 1024L;
if (fileTooLarge)
throw Oops.Bah("文件超过允许的大小");
throw Oops.Bah($"单个文件不能超过{maxFileSizeMb}MB,请压缩后重新上传");
var materials = await _db.Queryable<ObTaskMaterial>()
.Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
@@ -214,29 +260,36 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
throw Oops.Bah("该材料已达到最大上传数量");
}
private async Task EnsureRequiredContractVariables(ObOnboardingTask task)
/// <summary>
/// 后端再次比较员工勾选确认时的三项实名信息,防止绕过页面或确认后数据发生变化。
/// </summary>
private static void EnsureIdentityInformationConfirmed(
OnboardingSubmitInput input,
ObTaskEmployeeInfo info)
{
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}”尚未填写");
if (!input.IdentityInformationConfirmed)
throw Oops.Bah("请逐项确认姓名、身份证号码和手机号码无误");
var confirmedEmployeeName = input.ConfirmedEmployeeName?.Trim();
var confirmedIdCardNumber = input.ConfirmedIdCardNumber?.Trim().ToUpperInvariant();
var confirmedPhone = input.ConfirmedPhone?.Trim();
if (!string.Equals(confirmedEmployeeName, info.EmployeeName, StringComparison.Ordinal))
throw Oops.Bah("姓名在确认后发生了变化,请返回基本信息重新核对并确认");
if (!string.Equals(confirmedIdCardNumber, info.IdCardNumber, StringComparison.Ordinal))
throw Oops.Bah("身份证号码在确认后发生了变化,请返回基本信息重新核对并确认");
if (!string.Equals(confirmedPhone, info.Phone, StringComparison.Ordinal))
throw Oops.Bah("手机号码在确认后发生了变化,请返回基本信息重新核对并确认");
}
/// <summary>
/// 审核快照只记录实名信息摘要,完整原始值仍以本轮员工信息快照为准。
/// </summary>
private static string BuildIdentityConfirmationHash(ObTaskEmployeeInfo info)
{
var value = $"{info.EmployeeName}\n{info.IdCardNumber}\n{info.Phone}";
var bytes = global::System.Text.Encoding.UTF8.GetBytes(value);
return Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(bytes));
}
private Task<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId)
@@ -0,0 +1,68 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 生成并读取入职登记表内置员工字段的任务级必填快照。
/// </summary>
internal static class OnboardingEmployeeFieldRequirementSnapshot
{
private const string EmployeeVariablePrefix = "employee.";
/// <summary>
/// 根据当前内置员工变量配置创建一份任务快照。
/// </summary>
public static async Task<List<ObTaskFormRequirement>> BuildFromCurrentDefinitions(
SqlSugarScope db,
long taskId)
{
var definitions = await db.Queryable<ObVariableDefinition>()
.Where(item => item.IsBuiltIn
&& item.VariableCategory == ContractVariableType.Employee)
.OrderBy(item => item.SortCode)
.OrderBy(item => item.Id)
.ToListAsync();
return definitions.Select(definition => new ObTaskFormRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
FieldId = definition.Id,
FieldCode = definition.VariableCode,
FieldName = definition.VariableName,
DataType = definition.DataType,
Required = definition.Required,
SortCode = definition.SortCode
}).ToList();
}
/// <summary>
/// 读取任务冻结配置;历史任务没有员工字段快照时使用当前配置兼容。
/// </summary>
public static async Task<List<ObTaskFormRequirement>> GetForTask(
SqlSugarScope db,
long taskId)
{
var snapshots = await db.Queryable<ObTaskFormRequirement>()
.Where(item => item.TaskId == taskId)
.OrderBy(item => item.SortCode)
.ToListAsync();
if (snapshots.Any(IsEmployeeField))
return snapshots;
var compatibilitySnapshots = await BuildFromCurrentDefinitions(db, taskId);
if (snapshots.Count == 0)
return compatibilitySnapshots;
snapshots.AddRange(compatibilitySnapshots);
return snapshots.OrderBy(item => item.SortCode).ToList();
}
/// <summary>
/// 判断快照是否对应入职登记表内置员工字段。
/// </summary>
public static bool IsEmployeeField(ObTaskFormRequirement requirement)
{
return requirement?.FieldCode?.StartsWith(
EmployeeVariablePrefix,
StringComparison.OrdinalIgnoreCase) == true;
}
}
@@ -0,0 +1,350 @@
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,
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements = null)
{
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;
ValidateRequiredFields(input, fieldRequirements);
// 电子签约实名认证始终依赖以下三项,不能通过后台配置关闭。
if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100)
throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符");
if (!string.IsNullOrWhiteSpace(input.Gender)
&& 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, "公斤");
if (!string.IsNullOrWhiteSpace(input.BankCardNumber))
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, "员工补充变量");
}
/// <summary>
/// 按任务快照校验后台配置为必填的内置员工字段。
/// </summary>
private static void ValidateRequiredFields(
ObTaskEmployeeInfo input,
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements)
{
if (fieldRequirements == null || fieldRequirements.Count == 0)
return;
foreach (var requirement in fieldRequirements.Where(item =>
item.Required
&& OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField(item)))
{
var separatorIndex = requirement.FieldCode.LastIndexOf('.');
var propertyName = separatorIndex >= 0
? requirement.FieldCode[(separatorIndex + 1)..]
: requirement.FieldCode;
var property = typeof(ObTaskEmployeeInfo).GetProperty(
propertyName,
global::System.Reflection.BindingFlags.Instance
| global::System.Reflection.BindingFlags.Public
| global::System.Reflection.BindingFlags.IgnoreCase);
if (property == null)
{
throw Oops.Bah(
$"内置员工变量“{requirement.FieldCode}”没有对应的入职登记表字段,请联系管理员");
}
var value = property.GetValue(input)?.ToString();
if (string.IsNullOrWhiteSpace(value))
throw Oops.Bah($"请填写入职登记表“{requirement.FieldName}”");
}
}
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 (idCardBirthday.HasValue && birthday.Date != idCardBirthday.Value.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, "教育经历", "结果或学位");
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条教育学习经历");
}
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, "工作经历", "离职原因");
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条工作经历(应届生可填写兼职、在校或实习经历)");
}
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} 条家庭成员的联系电话格式不正确");
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一位家庭成员信息");
}
private static void ValidateHistoryRows(
string json,
string fieldName,
Action<global::System.Text.Json.JsonElement, int> validateRow,
bool requireAtLeastOne = false,
string emptyErrorMessage = null)
{
if (string.IsNullOrWhiteSpace(json))
{
if (requireAtLeastOne)
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
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);
}
if (requireAtLeastOne && rowNumber == 0)
{
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
}
}
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}格式不正确,请刷新页面后重新填写");
}
}
}
@@ -25,11 +25,19 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。
/// </summary>
public async Task<OnboardingTaskPublicDetailOutput> Detail(OnboardingTaskPublicAccessInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => DetailCore(input));
}
private async Task<OnboardingTaskPublicDetailOutput> DetailCore(OnboardingTaskPublicAccessInput input)
{
var link = await GetActiveLink(input.Token);
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();
@@ -37,6 +45,8 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
.Where(item => item.TaskId == task.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var formRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id)
@@ -103,7 +113,17 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
Contracts = contracts,
DynamicValues = new List<ObTaskFormValue>(),
Materials = materialOutputs,
FormFields = new List<OnboardingPublicFormFieldOutput>(),
FormFields = formRequirements
.Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField)
.Select(requirement => new OnboardingPublicFormFieldOutput
{
FieldId = requirement.FieldId,
FieldCode = requirement.FieldCode,
FieldName = requirement.FieldName,
DataType = requirement.DataType,
OptionsJson = requirement.OptionsJson,
Required = requirement.Required
}).ToList(),
MaterialRequirements = materialRequirements.Select(requirement => new OnboardingPublicMaterialOutput
{
MaterialTypeId = requirement.MaterialTypeId,
@@ -121,6 +141,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。
/// </summary>
public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveEmployeeInfoCore(input), transactional: true);
}
private async Task SaveEmployeeInfoCore(OnboardingTaskPublicSaveInput input)
{
var link = await GetActiveLink(input.Token);
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
@@ -131,7 +156,9 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
throw Oops.Bah("当前任务不可编辑");
}
ValidateEmployeeInfo(input);
var formRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
OnboardingEmployeeInfoValidator.ValidateAndNormalize(input, formRequirements);
await _identityValidationService.EnsureNoDuplicate(
task.Id,
input.EmployeeName,
@@ -170,51 +197,17 @@ 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>
public async Task<OnboardingPublicContractPrepareOutput> PrepareContract(
OnboardingPublicContractSourceInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => PrepareContractCore(input));
}
private async Task<OnboardingPublicContractPrepareOutput> PrepareContractCore(
OnboardingPublicContractSourceInput input)
{
var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>()
@@ -237,10 +230,9 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
}
await _snapshotService.Build(link.TaskId);
var fileId = await _contractDocumentService.Generate(new OnboardingGenerateContractInput
{
TaskContractId = contract.Id
});
var fileId = await _contractDocumentService.GenerateForPublicAccess(
contract.Id,
link.TaskId);
var generatedAt = await _db.Queryable<ObTaskContract>()
.Where(item => item.Id == contract.Id)
@@ -257,6 +249,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。
/// </summary>
public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => GetContractSourceFileIdCore(input));
}
private async Task<long> GetContractSourceFileIdCore(OnboardingPublicContractSourceInput input)
{
var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>()
@@ -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; }
}
@@ -97,3 +139,13 @@ 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);
}
@@ -8,14 +8,17 @@ public class OnboardingTaskService : IOnboardingTaskService
private readonly SqlSugarScope _db;
private readonly IConfigService _configService;
private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskService(
IConfigService configService,
IOnboardingContractSnapshotService snapshotService)
IOnboardingContractSnapshotService snapshotService,
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_configService = configService;
_snapshotService = snapshotService;
_dataPermission = dataPermission;
}
/// <summary>
@@ -23,6 +26,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 +80,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("入职模板包含未启用或不属于当前公司、部门的合同模板");
}
@@ -84,11 +95,13 @@ public class OnboardingTaskService : IOnboardingTaskService
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
var versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).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 now = DateTime.Now;
var taskVersions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
if (taskVersions.Count != versionIds.Count
|| taskVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now)))
throw Oops.Bah("入职模板包含未生效、已失效或未启用的签署材料版本,请先调整模板配置");
var materialIds = materialConfigs.Select(item => item.MaterialTypeId).Distinct().ToList();
var materialTypes = materialIds.Count == 0
@@ -117,11 +130,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);
@@ -149,9 +163,15 @@ public class OnboardingTaskService : IOnboardingTaskService
CreateTime = DateTime.Now
}).ExecuteCommandAsync();
var employeeFieldRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.BuildFromCurrentDefinitions(_db, taskId);
if (employeeFieldRequirements.Count > 0)
await _db.Insertable(employeeFieldRequirements).ExecuteCommandAsync();
var contracts = contractConfigs.Select(config =>
{
var template = contractTemplates.First(item => item.Id == config.ContractTemplateId);
var version = taskVersions.First(item => item.Id == config.ContractTemplateVersionId);
return new ObTaskContract
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
@@ -161,6 +181,8 @@ public class OnboardingTaskService : IOnboardingTaskService
ContractName = template.TemplateName,
ContractType = template.ContractType,
Required = config.Required,
CompanySealEnabled = version.CompanySealEnabled,
AcrossPageSealEnabled = version.AcrossPageSealEnabled,
PrintCopies = config.PrintCopies
};
}).ToList();
@@ -207,6 +229,265 @@ 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("原入职任务不存在");
await _dataPermission.EnsureCanManage(
sourceTask.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
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 now = DateTime.Now;
var copyVersions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
if (copyVersions.Count != versionIds.Count
|| copyVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now)))
throw Oops.Bah("原任务包含未生效、已失效或未启用的签署材料版本,不能继续复制");
var sourceMaterialRequirements = await _db.Queryable<ObTaskMaterialRequirement>()
.Where(item => item.TaskId == sourceTask.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var sourceEmployeeFieldRequirements =
(await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, sourceTask.Id))
.Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField)
.ToList();
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,
sourceEmployeeFieldRequirements,
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<ObTaskFormRequirement> sourceEmployeeFieldRequirements,
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();
if (sourceEmployeeFieldRequirements.Count > 0)
{
var copiedEmployeeFieldRequirements = sourceEmployeeFieldRequirements.Select(source =>
new ObTaskFormRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
FieldId = source.FieldId,
FieldCode = source.FieldCode,
FieldName = source.FieldName,
DataType = source.DataType,
OptionsJson = source.OptionsJson,
Required = source.Required,
SortCode = source.SortCode
}).ToList();
await _db.Insertable(copiedEmployeeFieldRequirements).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,
CompanySealEnabled = source.CompanySealEnabled,
AcrossPageSealEnabled = source.AcrossPageSealEnabled,
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 +570,7 @@ public class OnboardingTaskService : IOnboardingTaskService
/// <summary>
/// 校验人事创建任务时填写的合同日期先后关系。
/// </summary>
private static void ValidateInitialContractDateRanges(
internal static void ValidateInitialContractDateRanges(
List<OnboardingInitialContractVariableInput> inputs)
{
inputs ??= new List<OnboardingInitialContractVariableInput>();
@@ -323,4 +604,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("用工类型不正确,请选择正式员工或兼职员工");
}
}
}
@@ -6,9 +6,13 @@ namespace SimpleAdmin.Application;
public class OnboardingTaskContractVariableService : IOnboardingTaskContractVariableService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskContractVariableService()
=> _db = DbContext.DB;
public OnboardingTaskContractVariableService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <summary>
/// 查询任务下全部合同及其变量当前值。
@@ -77,6 +81,11 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
/// 保存允许人事编辑的任务合同变量值。
/// </summary>
public async Task Save(OnboardingTaskContractVariableSaveInput input)
{
await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => SaveCore(input), transactional: true);
}
private async Task SaveCore(OnboardingTaskContractVariableSaveInput input)
{
var contract = await _db.Queryable<ObTaskContract>()
.InSingleAsync(input.TaskContractId)
@@ -85,6 +94,10 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
var task = await _db.Queryable<ObOnboardingTask>()
.InSingleAsync(contract.TaskId)
?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
if (task.TaskStatus != OnboardingTaskStatus.PendingFill &&
task.TaskStatus != OnboardingTaskStatus.Rejected)
@@ -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; }
}
@@ -8,7 +8,7 @@ public interface IOnboardingTaskQueryService : ITransient
/// <summary>
/// 分页查询后台入职任务。
/// </summary>
Task<SqlSugarPagedList<ObOnboardingTask>> Page(OnboardingTaskPageInput input);
Task<OnboardingTaskPageOutput> Page(OnboardingTaskPageInput input);
/// <summary>
/// 查询任务基础信息、人员资料、合同、材料、表单值、链接和审核记录。
@@ -7,32 +7,92 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _configService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskQueryService(IConfigService configService)
public OnboardingTaskQueryService(
IConfigService configService,
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_configService = configService;
_dataPermission = dataPermission;
}
/// <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 +128,7 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
{
output.FileName = file.Name;
output.FileSuffix = file.Suffix;
output.FileSizeInfo = file.SizeInfo;
}
return output;
@@ -96,9 +157,18 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
/// 获取或重新生成任务有效填写链接。
/// </summary>
public async Task<OnboardingTaskCreatedOutput> GetTaskLink(BaseIdInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.Id, () => GetTaskLinkCore(input));
}
private async Task<OnboardingTaskCreatedOutput> GetTaskLinkCore(BaseIdInput input)
{
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(input.Id)
?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
var canShareLink = task.TaskStatus == OnboardingTaskStatus.PendingFill
|| task.TaskStatus == OnboardingTaskStatus.Rejected;
if (!canShareLink)
@@ -116,6 +186,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,
@@ -6,13 +6,15 @@
public class OnboardingVariableDefinitionService : IOnboardingVariableDefinitionService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
/// <summary>
/// 初始化合同变量字典服务。
/// </summary>
public OnboardingVariableDefinitionService()
public OnboardingVariableDefinitionService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <inheritdoc />
@@ -62,6 +64,10 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition
var entity = await _db.Queryable<ObVariableDefinition>().InSingleAsync(input.Id)
?? throw Oops.Bah("变量不存在");
await _dataPermission.EnsureCanManage(
entity.CreateUserId,
OnboardingPermissionCodes.EntryConfigManageAll,
"合同变量");
if (!string.Equals(entity.VariableCode, input.VariableCode, StringComparison.Ordinal))
throw Oops.Bah("变量编码创建后不能修改");
@@ -86,6 +92,16 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition
if (input.Ids.Count == 0)
return;
var variables = await _db.Queryable<ObVariableDefinition>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (variables.Count != input.Ids.Distinct().Count())
throw Oops.Bah("变量不存在");
await _dataPermission.EnsureCanManage(
variables.Select(item => item.CreateUserId),
OnboardingPermissionCodes.EntryConfigManageAll,
"合同变量");
var hasBuiltIn = await _db.Queryable<ObVariableDefinition>()
.Where(it => input.Ids.Contains(it.Id) && it.IsBuiltIn)
.AnyAsync();
+30 -19
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
//
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。
@@ -45,26 +45,37 @@ public class BasePageInput : IValidatableObject
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
//配合小诺排序参数
if (SortOrder == "descend")
SortOrder = (string.IsNullOrWhiteSpace(SortOrder) ? "desc" : SortOrder.Trim().ToLowerInvariant()) switch
{
SortOrder = "desc";
"ascend" => "asc",
"descend" => "desc",
var value => value
};
if (SortOrder != "asc" && SortOrder != "desc")
yield return new ValidationResult("排序方向仅支持 asc/desc", new[] { nameof(SortOrder) });
if (!string.IsNullOrEmpty(SortField)
&& !global::System.Text.RegularExpressions.Regex.IsMatch(
SortField, @"\A[A-Za-z_][A-Za-z0-9_]{0,63}\z"))
yield return new ValidationResult("排序字段格式不正确", new[] { nameof(SortField) });
}
else if (SortOrder == "ascend")
/// <summary>
/// 将客户端字段映射为服务端白名单列,禁止把排序表达式传入 ORM。
/// 服务层再次校验,避免非 HTTP 调用绕过 MVC 参数校验。
/// </summary>
public string GetSafeOrderBy(params string[] allowedFields)
{
SortOrder = "asc";
}
if (!string.IsNullOrEmpty(SortField))
{
//分割排序字段
var fields = SortField.Split(" ");
if (fields.Length > 1)
{
yield return new ValidationResult("排序字段错误", new[]
{
nameof(SortField)
});
}
}
var error = Validate(new ValidationContext(this)).FirstOrDefault();
if (error != null)
throw Oops.Bah(error.ErrorMessage);
if (string.IsNullOrEmpty(SortField))
return string.Empty;
var field = allowedFields.FirstOrDefault(item =>
string.Equals(item, SortField, StringComparison.OrdinalIgnoreCase));
if (field == null)
throw Oops.Bah("不支持按此字段排序");
return $"{field} {SortOrder}";
}
}
@@ -5,7 +5,7 @@
"ConnectionStrings": [
{
"ConfigId": "SimpleAdmin", //租户ID
"ConnectionString": "AllowLoadLocalInfile=true;Server=116.168.197.2;User ID=admin;Password=so^867z$4S;Port=16306;Database=OnboardX;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串
"ConnectionString": "AllowLoadLocalInfile=true;Server=onboardx-mysql.cnzhyx.cn;User ID=onboardx;Password=@gFYj78eNaTm3BS!JU)aUQ;Port=3306;Database=onboardx;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串
"DbType": "MySql", //数据库类型
"IsAutoCloseConnection": true //是否自动释放
}
@@ -66,6 +66,11 @@ public class LoginOutPut
/// </summary>
public class LoginUserOutput
{
/// <summary>
/// 用户Id
/// </summary>
public long Id { get; set; }
/// <summary>
/// 头像
///</summary>
@@ -34,7 +34,7 @@ public class FileService : DbRepository<SysFile>, IFileService
{
var query = Context.Queryable<SysFile>().WhereIF(!string.IsNullOrEmpty(input.Engine), it => it.Engine == input.Engine)//根据关键字查询
.WhereIF(!string.IsNullOrEmpty(input.SearchKey), it => it.Name.Contains(input.SearchKey))//根据关键字查询
.OrderByIF(!string.IsNullOrEmpty(input.SortField), $"{input.SortField} {input.SortOrder}")//排序
.OrderByIF(!string.IsNullOrEmpty(input.SortField), input.GetSafeOrderBy("Id", "Name", "Engine", "Bucket", "Suffix", "SizeKb", "SizeInfo", "CreateTime", "UpdateTime", "CreateUser"))//排序
.OrderBy(it => it.Id);
var pageInfo = await query.ToPagedListAsync(input.PageNum, input.PageSize);//分页
return pageInfo;
@@ -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>
@@ -25,6 +25,10 @@ public class ConfigService : DbRepository<SysConfig>, IConfigService
/// <inheritdoc/>
public async Task<List<SysConfig>> GetConfigsByCategory(string category)
{
// 业务自定义配置需立即生效,始终以数据库中的最新数据为准。
if (category == CateGoryConst.CONFIG_BIZ_DEFINE)
return await GetListAsync(it => it.Category == category);
var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key
//先从redis拿配置
var configList = _simpleCacheService.HashGetAll<SysConfig>(key).Values.ToList();
@@ -51,6 +55,10 @@ public class ConfigService : DbRepository<SysConfig>, IConfigService
/// <inheritdoc/>
public async Task<SysConfig> GetByConfigKey(string category, string configKey)
{
// 业务自定义配置不走缓存,避免修改后仍读取到本机旧值。
if (category == CateGoryConst.CONFIG_BIZ_DEFINE)
return await GetFirstAsync(it => it.Category == category && it.ConfigKey == configKey);
var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key
//先从redis拿配置
var config = _simpleCacheService.HashGetOne<SysConfig>(key, configKey);
@@ -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>
@@ -37,6 +37,11 @@ public class OnboardingContractTemplateController
if (!string.Equals(suffix, ".docx", StringComparison.OrdinalIgnoreCase))
throw Oops.Bah("合同模板仅支持 DOCX 文件");
await using var input = file.OpenReadStream();
using var bytes = new MemoryStream();
await input.CopyToAsync(bytes);
DocxTemplateProcessor.ExtractVariableCodes(bytes.ToArray());
return await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
}
@@ -64,6 +69,7 @@ public class OnboardingContractTemplateController
/// 查询入职模板当前公司、部门及岗位可选择的合同模板。
/// </summary>
[HttpPost("list-available")]
[DisplayName("查询可选合同模板")]
public async Task<dynamic> ListAvailable([FromBody] OnboardingContractTemplateAvailableInput input)
{
return await _service.ListAvailable(input);
@@ -113,6 +119,7 @@ public class OnboardingContractTemplateController
/// 新增合同模板版本。
/// </summary>
[HttpPost("version/add")]
[DisplayName("新增合同模板版本")]
public async Task AddVersion([FromBody] OnboardingContractVersionAddInput input)
{
await _service.AddVersion(input);
@@ -122,15 +129,27 @@ public class OnboardingContractTemplateController
/// 设置合同模板版本状态。
/// </summary>
[HttpPost("version/set-status")]
[DisplayName("设置合同模板版本状态")]
public async Task SetVersionStatus([FromBody] OnboardingContractVersionStatusInput input)
{
await _service.SetVersionStatus(input);
}
/// <summary>
/// 修改合同版本的公司盖章及骑缝章开关。
/// </summary>
[HttpPost("version/set-seals")]
[DisplayName("设置合同版本盖章配置")]
public async Task SetVersionSeals([FromBody] OnboardingContractVersionSealInput input)
{
await _service.SetVersionSeals(input);
}
/// <summary>
/// 删除未被入职模板或任务使用的合同模板版本。
/// </summary>
[HttpPost("version/delete")]
[DisplayName("删除合同模板版本")]
public async Task DeleteVersion([FromBody] BaseIdInput input)
{
await _service.DeleteVersion(input);
@@ -150,6 +169,7 @@ public class OnboardingContractTemplateController
/// 编辑模板变量。
/// </summary>
[HttpPost("variable/edit")]
[DisplayName("编辑合同模板变量")]
public async Task EditVariable([FromBody] OnboardingTemplateVariableEditInput input)
{
await _service.EditVariable(input);
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -50,7 +50,7 @@ public class OnboardingPublicSubmissionController
/// </summary>
[HttpPost("upload-material")]
[Consumes("multipart/form-data")]
[RequestSizeLimit(20 * 1024 * 1024)]
[RequestSizeLimit(21 * 1024 * 1024)]
public async Task UploadMaterial(
[FromQuery] string token,
[FromQuery] long materialTypeId,
@@ -70,6 +70,7 @@ public class OnboardingPublicSubmissionController
FileLength = file.Length,
TaskMaterialId = taskMaterialId
});
await ValidateMaterialFileContent(file);
var fileId = await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
await _service.SaveMaterial(new OnboardingSaveMaterialInput
@@ -80,4 +81,66 @@ public class OnboardingPublicSubmissionController
TaskMaterialId = taskMaterialId
});
}
private static async Task ValidateMaterialFileContent(IFormFile file)
{
var suffix = global::System.IO.Path.GetExtension(file.FileName).TrimStart('.').ToLowerInvariant();
var supported = suffix is "png" or "jpg" or "jpeg" or "gif" or "bmp" or "webp" or "pdf";
if (!supported)
throw Oops.Bah("仅允许上传图片或PDF文件");
var header = new byte[12];
var bytesRead = 0;
await using (var stream = file.OpenReadStream())
{
while (bytesRead < header.Length)
{
var read = await stream.ReadAsync(header.AsMemory(bytesRead, header.Length - bytesRead));
if (read == 0) break;
bytesRead += read;
}
}
var contentMatchesSuffix = suffix switch
{
"jpg" or "jpeg" => bytesRead >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF,
"png" => bytesRead >= 8
&& header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47
&& header[4] == 0x0D && header[5] == 0x0A && header[6] == 0x1A && header[7] == 0x0A,
"gif" => bytesRead >= 6
&& header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46
&& header[3] == 0x38 && (header[4] == 0x37 || header[4] == 0x39) && header[5] == 0x61,
"bmp" => bytesRead >= 2 && header[0] == 0x42 && header[1] == 0x4D,
"webp" => bytesRead >= 12
&& header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46
&& header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50,
"pdf" => bytesRead >= 5
&& header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44
&& header[3] == 0x46 && header[4] == 0x2D,
_ => false
};
if (!contentMatchesSuffix)
throw Oops.Bah("文件真实格式与扩展名不一致,请上传有效的图片或PDF文件");
if (suffix == "pdf") return;
await using var imageStream = file.OpenReadStream();
SkiaSharp.SKCodec codec;
try
{
codec = SkiaSharp.SKCodec.Create(imageStream);
}
catch
{
throw Oops.Bah("图片文件已损坏或格式不受支持");
}
using (codec)
{
if (codec == null || codec.Info.Width <= 0 || codec.Info.Height <= 0)
throw Oops.Bah("图片文件已损坏或格式不受支持");
if ((long)codec.Info.Width * codec.Info.Height > 40_000_000L)
throw Oops.Bah("图片分辨率过大,请压缩后重新上传");
}
}
}
@@ -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();
Binary file not shown.
@@ -8,6 +8,11 @@
<None Update="ip2region.db">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<Content Include="..\SimpleAdmin.Web.Core\Upload\员工导出模版.xlsx">
<Link>Upload\员工导出模版.xlsx</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SimpleAdmin.Web.Core\SimpleAdmin.Web.Core.csproj" />
+2 -2
View File
@@ -1,6 +1,6 @@
{
"AppSettings": {
"InjectSpecificationDocument": true, //是否开启swagger
"InjectSpecificationDocument": false, //是否开启swagger
"Urls": "http://0.0.0.0:5566" //监听地址
},
"AllowedHosts": "*",
@@ -18,7 +18,7 @@
//swagger设置
"SpecificationDocumentSettings": {
"DocumentTitle": "未病局后台管理系统",
"DocumentTitle": "员工后台管理系统",
"DocExpansionState": "None",
"GroupOpenApiInfos": []
},
@@ -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 '试用薪资标准变量数';
@@ -0,0 +1,28 @@
-- 入职登记表内置员工字段必填规则的任务快照表。
-- 可在本地、测试和正式环境重复执行。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `ob_task_form_requirement` (
`Id` BIGINT NOT NULL COMMENT '主键',
`TaskId` BIGINT NOT NULL COMMENT '入职任务Id',
`FieldId` BIGINT NOT NULL COMMENT '原字段或变量定义Id',
`FieldCode` VARCHAR(100) NOT NULL COMMENT '冻结字段编码',
`FieldName` VARCHAR(200) NOT NULL COMMENT '冻结字段名称',
`DataType` VARCHAR(50) NOT NULL COMMENT '冻结数据类型',
`OptionsJson` JSON NULL COMMENT '冻结选项JSON',
`Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必填',
`SortCode` INT NULL COMMENT '显示顺序',
`Status` VARCHAR(20) NULL DEFAULT 'enable' 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 '软删除',
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
PRIMARY KEY (`Id`),
UNIQUE KEY `uk_ob_task_form_requirement` (`TaskId`, `FieldId`),
KEY `idx_ob_task_form_requirement_task_sort` (`TaskId`, `SortCode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务入职登记字段必填快照';
@@ -0,0 +1,75 @@
-- 签署材料版本的公司盖章/骑缝章开关及任务、电子签快照。MySQL 8.0+
-- 上线顺序:先在目标数据库执行本文件,再部署后端及后台前端。
-- 可重复执行;只新增可空列,不回填或覆盖历史业务数据。
-- NULL 表示历史规则:公司盖章需要,骑缝章沿用 QiyuesuoSetting.EnableAcrossPageSeal。
-- 新上传版本显式保存 true/false;任务及电子签文档分别保存快照。
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS ob_upgrade_contract_version_seal_switches;
DELIMITER $$
CREATE PROCEDURE ob_upgrade_contract_version_seal_switches()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ob_contract_template_version'
AND COLUMN_NAME = 'CompanySealEnabled'
) THEN
ALTER TABLE `ob_contract_template_version`
ADD COLUMN `CompanySealEnabled` TINYINT(1) NULL DEFAULT NULL
COMMENT '公司盖章开关;NULL沿用历史需要盖章';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ob_contract_template_version'
AND COLUMN_NAME = 'AcrossPageSealEnabled'
) THEN
ALTER TABLE `ob_contract_template_version`
ADD COLUMN `AcrossPageSealEnabled` TINYINT(1) NULL DEFAULT NULL
COMMENT '骑缝章开关;NULL沿用历史系统配置';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ob_task_contract'
AND COLUMN_NAME = 'CompanySealEnabled'
) THEN
ALTER TABLE `ob_task_contract`
ADD COLUMN `CompanySealEnabled` TINYINT(1) NULL DEFAULT NULL
COMMENT '公司盖章开关;NULL沿用历史需要盖章';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ob_task_contract'
AND COLUMN_NAME = 'AcrossPageSealEnabled'
) THEN
ALTER TABLE `ob_task_contract`
ADD COLUMN `AcrossPageSealEnabled` TINYINT(1) NULL DEFAULT NULL
COMMENT '骑缝章开关;NULL沿用历史系统配置';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ob_electronic_sign_document'
AND COLUMN_NAME = 'CompanySealEnabled'
) THEN
ALTER TABLE `ob_electronic_sign_document`
ADD COLUMN `CompanySealEnabled` TINYINT(1) NULL DEFAULT NULL
COMMENT '公司盖章开关;NULL沿用历史需要盖章';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'ob_electronic_sign_document'
AND COLUMN_NAME = 'AcrossPageSealEnabled'
) THEN
ALTER TABLE `ob_electronic_sign_document`
ADD COLUMN `AcrossPageSealEnabled` TINYINT(1) NULL DEFAULT NULL
COMMENT '骑缝章开关;NULL沿用历史系统配置';
END IF;
END$$
DELIMITER ;
CALL ob_upgrade_contract_version_seal_switches();
DROP PROCEDURE IF EXISTS ob_upgrade_contract_version_seal_switches;

Some files were not shown because too many files have changed in this diff Show More