Compare commits

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