Compare commits
28
Commits
c925c8ba32
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83854ce901 | ||
|
|
0bb8263431 | ||
|
|
14cfd4146e | ||
|
|
3eae5f8e5f | ||
|
|
ff7e9c1496 | ||
|
|
c42d82d100 | ||
|
|
fa48e73b84 | ||
|
|
ab0248d73d | ||
|
|
bd20ceac4e | ||
|
|
aaec170c4f | ||
|
|
9de05b44b8 | ||
|
|
e9894538e8 | ||
|
|
20ea1bc70c | ||
|
|
43a498162e | ||
|
|
f004aec10f | ||
|
|
20409635d6 | ||
|
|
1d925bd10b | ||
|
|
378bf7fe51 | ||
|
|
571db6fc55 | ||
|
|
f858b04701 | ||
|
|
1fd557a19e | ||
|
|
95b14b198a | ||
|
|
c015bc356c | ||
|
|
da777327f5 | ||
|
|
384eaff444 | ||
|
|
6151872b6b | ||
|
|
1d42c52d53 | ||
|
|
b5ae0a44c7 |
@@ -6,8 +6,30 @@
|
||||
"Title": "业务模块" //配置分组标题,string 类型
|
||||
},
|
||||
//系统层设置
|
||||
"ApplicationSettings": {
|
||||
"InitTable": false, //是否初始化表结构
|
||||
"InitSeedData": false //是否初始化种子数据
|
||||
"ApplicationSettings": {
|
||||
"InitTable": false, //是否初始化表结构
|
||||
"InitSeedData": false //是否初始化种子数据
|
||||
},
|
||||
// 契约锁测试环境配置。AppToken、AppSecret 由部署人员填写,禁止提交真实密钥。
|
||||
"QiyuesuoSetting": {
|
||||
"Enabled": true,
|
||||
"ServerUrl": "https://openapi.qiyuesuo.cn",
|
||||
"AppToken": "rMKU99gkts",
|
||||
"AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O",
|
||||
"SignatureVersion": "",
|
||||
"CreatorName": "袁林",
|
||||
"CreatorMobile": "15123135384",
|
||||
"CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev/api/open/onboarding/qiyuesuo/callback",
|
||||
"SignPageCallbackUrl": "",
|
||||
"SignUrlExpireMinutes": 10,
|
||||
"EmployeeSignKeyword": "乙方(劳动者)签字:",
|
||||
"EmployeeSignKeywordIndex": 1,
|
||||
"CompanySealKeyword": "甲方(盖章):",
|
||||
"CompanySealKeywordIndex": 1,
|
||||
"CompanySealOffsetX": 0,
|
||||
"CompanySealOffsetY": -0.05,
|
||||
"EnableAcrossPageSeal": true,
|
||||
"AcrossPageSealType": "ALL_DOCUMENT",
|
||||
"TimeoutSeconds": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,28 @@
|
||||
//系统层设置
|
||||
"ApplicationSettings": {
|
||||
"InitTable": false, //是否初始化表结构
|
||||
"InitSeedData": false, //是否初始化种子数据0
|
||||
}
|
||||
}
|
||||
"InitSeedData": false //是否初始化种子数据
|
||||
},
|
||||
// 契约锁正式环境配置。上线前填写正式 AppToken/AppSecret,并把 ServerUrl 切换为 .com。
|
||||
"QiyuesuoSetting": {
|
||||
"Enabled": true,
|
||||
"ServerUrl": "https://openapi.qiyuesuo.com",
|
||||
"AppToken": "mOCjDn0gMR",
|
||||
"AppSecret": "5lPmSSRd7n8IhO3BvtWZJkBP1QNC59",
|
||||
"SignatureVersion": "",
|
||||
"CreatorName": "李珍",
|
||||
"CreatorMobile": "13629784055",
|
||||
"CallbackUrl": "https://onboardx-api.cnzhyx.cn/api/open/onboarding/qiyuesuo/callback",
|
||||
"SignPageCallbackUrl": "",
|
||||
"SignUrlExpireMinutes": 10,
|
||||
"EmployeeSignKeyword": "乙方(劳动者)签字:",
|
||||
"EmployeeSignKeywordIndex": 1,
|
||||
"CompanySealKeyword": "甲方(盖章):",
|
||||
"CompanySealKeywordIndex": 1,
|
||||
"CompanySealOffsetX": 0,
|
||||
"CompanySealOffsetY": -0.05,
|
||||
"EnableAcrossPageSeal": true,
|
||||
"AcrossPageSealType": "ALL_DOCUMENT",
|
||||
"TimeoutSeconds": 90
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,25 +35,35 @@ public class ObCompany : BaseEntity
|
||||
/// <summary>
|
||||
/// 统一社会信用代码。
|
||||
/// </summary>
|
||||
public string UnifiedSocialCreditCode { get; set; }
|
||||
public virtual string UnifiedSocialCreditCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 法定代表人姓名。
|
||||
/// </summary>
|
||||
public string LegalRepresentative { get; set; }
|
||||
public virtual string LegalRepresentative { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 工商注册地址。
|
||||
/// </summary>
|
||||
public string RegisteredAddress { get; set; }
|
||||
public virtual string RegisteredAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公司联系电话。
|
||||
/// </summary>
|
||||
public string ContactPhone { get; set; }
|
||||
public virtual string ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
|
||||
/// </summary>
|
||||
public virtual string QiyuesuoCategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业印章 Id;每个签约公司独立配置。
|
||||
/// </summary>
|
||||
public virtual string QiyuesuoSealId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; }
|
||||
public virtual string Remark { get; set; }
|
||||
}
|
||||
|
||||
@@ -52,6 +52,12 @@ public class ObContractTemplateVersion : BaseEntity
|
||||
/// </summary>
|
||||
public string VersionStatus { get; set; } = ContractTemplateVersionStatus.Draft;
|
||||
|
||||
/// <summary>本版本是否需要公司盖章;历史空值沿用需要盖章。</summary>
|
||||
public bool? CompanySealEnabled { get; set; }
|
||||
|
||||
/// <summary>本版本是否需要骑缝章;历史空值沿用系统骑缝章配置。</summary>
|
||||
public bool? AcrossPageSealEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务部门档案,属于某家入职公司。
|
||||
@@ -30,7 +30,7 @@ public class ObDepartment : BaseEntity
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; }
|
||||
public virtual string Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属公司名称 (展示用)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 电子签流程中的合同文件映射,一条任务合同对应一个契约锁文档。
|
||||
/// </summary>
|
||||
[SugarTable("ob_electronic_sign_document", TableDescription = "入职电子签文档")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObElectronicSignDocument : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属电子签流程 Id。
|
||||
/// </summary>
|
||||
public long SignProcessId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对应的入职任务合同 Id。
|
||||
/// </summary>
|
||||
public long TaskContractId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁文档 Id。
|
||||
/// </summary>
|
||||
public string ProviderDocumentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 发起签署时冻结的文档名称。
|
||||
/// </summary>
|
||||
public string DocumentName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 文档在同一签署流程中的顺序。
|
||||
/// </summary>
|
||||
public int DocumentSort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传契约锁的原始 DOCX 系统文件 Id。
|
||||
/// </summary>
|
||||
public long OriginalDocxFileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签署完成后归档的 PDF 系统文件 Id。
|
||||
/// </summary>
|
||||
public long? SignedPdfFileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工签名关键字快照。
|
||||
/// </summary>
|
||||
public string EmployeeSignKeyword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 企业盖章关键字快照。
|
||||
/// </summary>
|
||||
public string CompanySealKeyword { get; set; }
|
||||
|
||||
/// <summary>发起本次签约时冻结的公司盖章要求。</summary>
|
||||
public bool? CompanySealEnabled { get; set; }
|
||||
|
||||
/// <summary>发起本次签约时冻结的骑缝章要求。</summary>
|
||||
public bool? AcrossPageSealEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁员工签署动作 Id。
|
||||
/// </summary>
|
||||
public string EmployeeActionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业盖章动作 Id。
|
||||
/// </summary>
|
||||
public string CompanyActionId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁回调与主动同步事件,用于幂等处理和完整审计。
|
||||
/// </summary>
|
||||
[SugarTable("ob_electronic_sign_event", TableDescription = "入职电子签事件")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObElectronicSignEvent : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属电子签流程 Id;无法识别流程的异常回调可为空。
|
||||
/// </summary>
|
||||
public long? SignProcessId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 回调内容 SHA-256,用于识别完全重复的通知。
|
||||
/// </summary>
|
||||
public string PayloadHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件来源,例如 callback 或 manual_sync。
|
||||
/// </summary>
|
||||
public string EventSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁回调事件类型。
|
||||
/// </summary>
|
||||
public string EventType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 回调携带或主动查询得到的契约锁状态。
|
||||
/// </summary>
|
||||
public string ProviderStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁原始请求内容,保留用于审计排查。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string PayloadJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次事件处理结果。
|
||||
/// </summary>
|
||||
public string ProcessResult { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 接收到事件的时间。
|
||||
/// </summary>
|
||||
public DateTime ReceivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件处理完成时间。
|
||||
/// </summary>
|
||||
public DateTime? ProcessedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务的一次电子签流程,一次流程可以包含多份合同文件。
|
||||
/// </summary>
|
||||
[SugarTable("ob_electronic_sign_process", TableDescription = "入职电子签流程")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObElectronicSignProcess : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 所属入职任务 Id。
|
||||
/// </summary>
|
||||
public long TaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过后生成的正式员工 Id。
|
||||
/// </summary>
|
||||
public long EmployeeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 电子签服务商编码。
|
||||
/// </summary>
|
||||
public string Provider { get; set; } = ElectronicSignProvider.Qiyuesuo;
|
||||
|
||||
/// <summary>
|
||||
/// 本系统生成的幂等业务编号。
|
||||
/// </summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否为该入职任务当前使用的电子签流程。
|
||||
/// 历史流程保存为空,当前流程保存为 true,并通过数据库唯一索引防止重复发起。
|
||||
/// </summary>
|
||||
public bool? IsCurrent { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同流程 Id。
|
||||
/// </summary>
|
||||
public string ProviderContractId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁业务分类 Id 快照。
|
||||
/// </summary>
|
||||
public string CategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业印章 Id 快照。
|
||||
/// </summary>
|
||||
public string SealId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 电子签流程标题。
|
||||
/// </summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工姓名快照。
|
||||
/// </summary>
|
||||
public string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工手机号码快照。
|
||||
/// </summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工身份证号码快照,仅用于锁定本次电子签署人的实名信息。
|
||||
/// </summary>
|
||||
public string EmployeeIdCardNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同创建人姓名快照。
|
||||
/// </summary>
|
||||
public string CreatorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同创建人手机号快照。
|
||||
/// </summary>
|
||||
public string CreatorMobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本系统电子签状态。
|
||||
/// </summary>
|
||||
public string SignStatus { get; set; } = ElectronicSignStatus.Creating;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁返回的原始合同状态。
|
||||
/// </summary>
|
||||
public string ProviderStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工签署页面地址。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string SignUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工签署页面地址到期时间。
|
||||
/// </summary>
|
||||
public DateTime? SignUrlExpireAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 管理员点击发起电子签的时间。
|
||||
/// </summary>
|
||||
public DateTime StartedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 合同流程正式发送的时间。
|
||||
/// </summary>
|
||||
public DateTime? SentAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所有签署方完成签署的时间。
|
||||
/// </summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签署文件与签署日志保存到 OSS 的时间。
|
||||
/// </summary>
|
||||
public DateTime? ArchivedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁完整归档 ZIP 在系统文件表中的 Id。
|
||||
/// </summary>
|
||||
public long? ArchiveFileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后一次主动查询契约锁状态的时间。
|
||||
/// </summary>
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最后一次收到契约锁回调的时间。
|
||||
/// </summary>
|
||||
public DateTime? LastCallbackAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近一次失败原因,供管理员排查和重试。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string LastError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建或同步失败后的重试次数。
|
||||
/// </summary>
|
||||
public int RetryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 管理员重新发起本任务电子签时填写的原因。
|
||||
/// </summary>
|
||||
public string ReissueReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前流程被新流程替代的时间。
|
||||
/// </summary>
|
||||
public DateTime? ReissuedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 替代当前流程的新电子签流程 Id。
|
||||
/// </summary>
|
||||
public long? ReissuedByProcessId { get; set; }
|
||||
}
|
||||
@@ -11,9 +11,9 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过后生成的正式员工档案,是入职流程的最终业务数据。
|
||||
/// 审核通过后生成的员工档案,是入职流程的最终业务数据。
|
||||
/// </summary>
|
||||
[SugarTable("ob_employee", TableDescription = "正式员工档案")]
|
||||
[SugarTable("ob_employee", TableDescription = "员工档案")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObEmployee : BaseEntity
|
||||
{
|
||||
@@ -59,6 +59,21 @@ public class ObEmployee : BaseEntity
|
||||
/// </summary>
|
||||
public long PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用工类型:full_time 正式用工,part_time 兼职用工。
|
||||
/// </summary>
|
||||
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
|
||||
|
||||
/// <summary>
|
||||
/// 转正状态:probation 试用期、regular 已转正、not_applicable 不适用。
|
||||
/// </summary>
|
||||
public string RegularizationStatus { get; set; } = EmployeeRegularizationStatus.Probation;
|
||||
|
||||
/// <summary>
|
||||
/// 社保状态:unknown 未维护、not_purchased 未购买、purchased 已购买、stopped 已停保、not_applicable 不适用。
|
||||
/// </summary>
|
||||
public string SocialInsuranceStatus { get; set; } = EmployeeSocialInsuranceStatus.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// 所属岗位名称,仅用于接口展示,不映射数据库字段。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 员工人事材料附件,可关联转正记录及后续扩展的人事业务。
|
||||
/// </summary>
|
||||
[SugarTable("ob_employee_attachment", TableDescription = "员工人事材料附件")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObEmployeeAttachment : BaseEntity
|
||||
{
|
||||
public long EmployeeId { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public long? ChangeRecordId { get; set; }
|
||||
|
||||
public string BusinessType { get; set; }
|
||||
|
||||
public long FileId { get; set; }
|
||||
|
||||
public string FileName { get; set; }
|
||||
|
||||
public DateTime UploadedAt { get; set; }
|
||||
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public string FileSuffix { get; set; }
|
||||
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public string FileSizeInfo { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 员工人事信息变更记录,保留转正及后续扩展业务的前后快照。
|
||||
/// </summary>
|
||||
[SugarTable("ob_employee_change_record", TableDescription = "员工人事信息变更记录")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObEmployeeChangeRecord : BaseEntity
|
||||
{
|
||||
public long EmployeeId { get; set; }
|
||||
|
||||
public string ChangeType { get; set; }
|
||||
|
||||
[SugarColumn(IsNullable = true)]
|
||||
public DateTime? EffectiveDate { get; set; }
|
||||
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string BeforeJson { get; set; }
|
||||
|
||||
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
|
||||
public string AfterJson { get; set; }
|
||||
|
||||
public string Remark { get; set; }
|
||||
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public List<ObEmployeeAttachment> Attachments { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 正式员工的一份合同在审核建档时冻结的业务快照。
|
||||
/// 常用日期和薪资使用独立列,全部标准及自定义变量同时保存在 VariablesJson 中。
|
||||
/// </summary>
|
||||
[SugarTable("ob_employee_contract_snapshot", TableDescription = "员工合同快照")]
|
||||
[Tenant(SqlSugarConst.DB_DEFAULT)]
|
||||
public class ObEmployeeContractSnapshot : BaseEntity
|
||||
{
|
||||
/// <summary>正式员工 Id。</summary>
|
||||
public long EmployeeId { get; set; }
|
||||
|
||||
/// <summary>来源入职任务 Id。</summary>
|
||||
public long TaskId { get; set; }
|
||||
|
||||
/// <summary>来源任务合同 Id,同一任务合同只允许生成一份员工快照。</summary>
|
||||
public long TaskContractId { get; set; }
|
||||
|
||||
/// <summary>合同模板 Id。</summary>
|
||||
public long TemplateId { get; set; }
|
||||
|
||||
/// <summary>合同模板版本 Id。</summary>
|
||||
public long TemplateVersionId { get; set; }
|
||||
|
||||
/// <summary>合同名称快照。</summary>
|
||||
public string ContractName { get; set; }
|
||||
|
||||
/// <summary>合同类型快照。</summary>
|
||||
public string ContractType { get; set; }
|
||||
|
||||
/// <summary>合同开始日期。</summary>
|
||||
public DateTime? ContractStartDate { get; set; }
|
||||
|
||||
/// <summary>合同结束日期。</summary>
|
||||
public DateTime? ContractEndDate { get; set; }
|
||||
|
||||
/// <summary>合同期限原始文本。</summary>
|
||||
public string ContractTerm { get; set; }
|
||||
|
||||
/// <summary>试用期开始日期。</summary>
|
||||
public DateTime? ProbationStartDate { get; set; }
|
||||
|
||||
/// <summary>试用期结束日期。</summary>
|
||||
public DateTime? ProbationEndDate { get; set; }
|
||||
|
||||
/// <summary>试用期期限原始文本。</summary>
|
||||
public string ProbationTerm { get; set; }
|
||||
|
||||
/// <summary>明确使用 hr.ProbationSalary 保存的试用薪资;不从其他工资项目推算。</summary>
|
||||
public decimal? ProbationSalary { get; set; }
|
||||
|
||||
/// <summary>基本工资。</summary>
|
||||
public decimal? BaseSalary { get; set; }
|
||||
|
||||
/// <summary>岗位工资。</summary>
|
||||
public decimal? PositionSalary { get; set; }
|
||||
|
||||
/// <summary>绩效津贴。</summary>
|
||||
public decimal? PerformanceAllowance { get; set; }
|
||||
|
||||
/// <summary>全勤奖。</summary>
|
||||
public decimal? AttendanceBonus { get; set; }
|
||||
|
||||
/// <summary>转正后薪资。</summary>
|
||||
public decimal? RegularSalary { get; set; }
|
||||
|
||||
/// <summary>工作地点或城市。</summary>
|
||||
public string WorkCity { get; set; }
|
||||
|
||||
/// <summary>合同中的岗位名称。</summary>
|
||||
public string PositionName { get; set; }
|
||||
|
||||
/// <summary>合同中的员工工号。</summary>
|
||||
public string EmployeeNo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 以 VariableCode 为键保存该合同当时的全部变量元数据和值。
|
||||
/// 导出时通过稳定编码判断变量是否存在,不依赖可修改的中文名称。
|
||||
/// </summary>
|
||||
[SugarColumn(ColumnDataType = "json", IsNullable = false)]
|
||||
public string VariablesJson { get; set; }
|
||||
|
||||
/// <summary>快照结构版本。</summary>
|
||||
public int SnapshotVersion { get; set; } = 1;
|
||||
|
||||
/// <summary>快照生成时间。</summary>
|
||||
public DateTime SnapshotAt { get; set; }
|
||||
}
|
||||
@@ -33,6 +33,11 @@ public class ObOnboardingTask : BaseEntity
|
||||
/// </summary>
|
||||
public long PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用工类型:full_time 正式用工,part_time 兼职用工。
|
||||
/// </summary>
|
||||
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
|
||||
|
||||
/// <summary>
|
||||
/// 创建任务时锁定的入职模板 Id。
|
||||
/// </summary>
|
||||
@@ -106,4 +111,4 @@ public class ObOnboardingTask : BaseEntity
|
||||
/// </summary>
|
||||
[SugarColumn(IsIgnore = true)]
|
||||
public string OnboardingTemplateName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ public class ObPosition : BaseEntity
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
public string Remark { get; set; }
|
||||
public virtual string Remark { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属公司名称 (展示用)
|
||||
|
||||
@@ -52,6 +52,12 @@ public class ObTaskContract : BaseEntity
|
||||
/// </summary>
|
||||
public int PrintCopies { get; set; } = 1;
|
||||
|
||||
/// <summary>任务创建时冻结的公司盖章要求;历史空值沿用需要盖章。</summary>
|
||||
public bool? CompanySealEnabled { get; set; }
|
||||
|
||||
/// <summary>任务创建时冻结的骑缝章要求;历史空值沿用系统配置。</summary>
|
||||
public bool? AcrossPageSealEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 任务合同当前状态。
|
||||
/// </summary>
|
||||
|
||||
@@ -36,7 +36,7 @@ public static class OnboardingTaskStatus
|
||||
public const string Rejected = "rejected";
|
||||
|
||||
/// <summary>
|
||||
/// 已通过,系统已经生成正式员工档案。
|
||||
/// 已通过,系统已经生成员工档案。
|
||||
/// </summary>
|
||||
public const string Approved = "approved";
|
||||
|
||||
@@ -128,12 +128,138 @@ public static class TaskContractStatus
|
||||
/// </summary>
|
||||
public const string OfflinePendingSign = "offline_pending_sign";
|
||||
|
||||
/// <summary>
|
||||
/// 已发起第三方电子签,等待员工签署或企业盖章。
|
||||
/// </summary>
|
||||
public const string ElectronicSigning = "electronic_signing";
|
||||
|
||||
/// <summary>
|
||||
/// 第三方电子签已完成,签署文件正在或已经归档。
|
||||
/// </summary>
|
||||
public const string ElectronicSigned = "electronic_signed";
|
||||
|
||||
/// <summary>
|
||||
/// 已归档,任务流程结束且历史数据只读。
|
||||
/// </summary>
|
||||
public const string Archived = "archived";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工用工类型。
|
||||
/// </summary>
|
||||
public static class EmployeeEmploymentType
|
||||
{
|
||||
public const string FullTime = "full_time";
|
||||
public const string PartTime = "part_time";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工转正状态,仅由后台人事操作改变,不根据合同日期自动推算。
|
||||
/// </summary>
|
||||
public static class EmployeeRegularizationStatus
|
||||
{
|
||||
public const string Probation = "probation";
|
||||
public const string Regular = "regular";
|
||||
public const string NotApplicable = "not_applicable";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工社保状态。
|
||||
/// </summary>
|
||||
public static class EmployeeSocialInsuranceStatus
|
||||
{
|
||||
public const string Unknown = "unknown";
|
||||
public const string NotPurchased = "not_purchased";
|
||||
public const string Purchased = "purchased";
|
||||
public const string Stopped = "stopped";
|
||||
public const string NotApplicable = "not_applicable";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工人事变更类型。
|
||||
/// </summary>
|
||||
public static class EmployeeChangeType
|
||||
{
|
||||
public const string HrInfoUpdate = "hr_info_update";
|
||||
public const string Regularization = "regularization";
|
||||
public const string RegularizationRevoke = "regularization_revoke";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工附件业务类型。
|
||||
/// </summary>
|
||||
public static class EmployeeAttachmentBusinessType
|
||||
{
|
||||
public const string Regularization = "regularization";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 电子签服务商。
|
||||
/// </summary>
|
||||
public static class ElectronicSignProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// 契约锁开放平台。
|
||||
/// </summary>
|
||||
public const string Qiyuesuo = "qiyuesuo";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入职电子签流程状态。
|
||||
/// </summary>
|
||||
public static class ElectronicSignStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 正在调用第三方创建流程。
|
||||
/// </summary>
|
||||
public const string Creating = "creating";
|
||||
|
||||
/// <summary>
|
||||
/// 已创建草稿,尚未成功发送。
|
||||
/// </summary>
|
||||
public const string Draft = "draft";
|
||||
|
||||
/// <summary>
|
||||
/// 已发送,等待员工签署。
|
||||
/// </summary>
|
||||
public const string PendingEmployeeSign = "pending_employee_sign";
|
||||
|
||||
/// <summary>
|
||||
/// 员工已签,等待后台管理员核对文件并确认企业盖章。
|
||||
/// </summary>
|
||||
public const string PendingCompanySeal = "pending_company_seal";
|
||||
|
||||
/// <summary>
|
||||
/// 所有签署已完成,等待文件归档。
|
||||
/// </summary>
|
||||
public const string Completed = "completed";
|
||||
|
||||
/// <summary>
|
||||
/// 签署文件和签署日志已经归档。
|
||||
/// </summary>
|
||||
public const string Archived = "archived";
|
||||
|
||||
/// <summary>
|
||||
/// 流程被拒签。
|
||||
/// </summary>
|
||||
public const string Rejected = "rejected";
|
||||
|
||||
/// <summary>
|
||||
/// 流程已作废。
|
||||
/// </summary>
|
||||
public const string Invalid = "invalid";
|
||||
|
||||
/// <summary>
|
||||
/// 流程已过期。
|
||||
/// </summary>
|
||||
public const string Expired = "expired";
|
||||
|
||||
/// <summary>
|
||||
/// 创建、发送、同步或归档失败,可由管理员重试。
|
||||
/// </summary>
|
||||
public const string Failed = "failed";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 合同变量类型。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁开放平台连接及入职电子签业务配置。
|
||||
/// </summary>
|
||||
public class QiyuesuoSettingOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用契约锁电子签功能。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁开放平台地址;测试环境为 https://openapi.qiyuesuo.cn。
|
||||
/// </summary>
|
||||
public string ServerUrl { get; set; } = "https://openapi.qiyuesuo.cn";
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁应用 App Token。
|
||||
/// </summary>
|
||||
public string AppToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁应用 App Secret。
|
||||
/// </summary>
|
||||
public string AppSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 签名版本;留空使用兼容性最好的 MD5,设置 HMACSHA256 使用新版签名。
|
||||
/// </summary>
|
||||
public string SignatureVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业成员中的合同创建人姓名。
|
||||
/// </summary>
|
||||
public string CreatorName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业成员中的合同创建人手机号。
|
||||
/// </summary>
|
||||
public string CreatorMobile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同状态回调地址。
|
||||
/// </summary>
|
||||
public string CallbackUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工从契约锁签署页面返回时跳转的前端地址;可为空。
|
||||
/// </summary>
|
||||
public string SignPageCallbackUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工签署页链接有效分钟数。
|
||||
/// </summary>
|
||||
public int SignUrlExpireMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// DOCX 中定位员工签名位置的关键字。
|
||||
/// </summary>
|
||||
public string EmployeeSignKeyword { get; set; } = "乙方(劳动者)签字:";
|
||||
|
||||
/// <summary>
|
||||
/// 员工签名关键字在单份文档中重复时使用第几个,从 1 开始。
|
||||
/// </summary>
|
||||
public int EmployeeSignKeywordIndex { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// DOCX 中定位企业盖章位置的关键字。
|
||||
/// </summary>
|
||||
public string CompanySealKeyword { get; set; } = "甲方(盖章):";
|
||||
|
||||
/// <summary>
|
||||
/// 企业盖章关键字在单份文档中重复时使用第几个,从 1 开始。
|
||||
/// </summary>
|
||||
public int CompanySealKeywordIndex { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 企业盖章相对关键字的横向偏移量,范围 (-1, 1),正数向右、负数向左。
|
||||
/// </summary>
|
||||
public decimal CompanySealOffsetX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 企业盖章相对关键字的纵向偏移量,范围 (-1, 1),正数向上、负数向下。
|
||||
/// </summary>
|
||||
public decimal CompanySealOffsetY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 历史版本未单独设置开关时,是否在企业盖章时加盖骑缝章;新版本显式开关优先。
|
||||
/// </summary>
|
||||
public bool EnableAcrossPageSeal { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 骑缝章方式:ALL_DOCUMENT(所选文档合并)或 SINGLE_DOCUMENT(所选文档各自独立)。
|
||||
/// </summary>
|
||||
public string AcrossPageSealType { get; set; } = "ALL_DOCUMENT";
|
||||
|
||||
/// <summary>
|
||||
/// 单次 HTTP 请求超时秒数。
|
||||
/// </summary>
|
||||
public int TimeoutSeconds { get; set; } = 90;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 实现入职任务审核与退回业务能力。
|
||||
@@ -6,22 +6,30 @@
|
||||
public class OnboardingAuditService : IOnboardingAuditService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IConfigService _config;
|
||||
private readonly IOnboardingContractSnapshotService _snapshotService;
|
||||
private readonly IOnboardingEmployeeContractSnapshotService _employeeContractSnapshotService;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingAuditService(
|
||||
IConfigService config,
|
||||
IOnboardingContractSnapshotService snapshotService)
|
||||
IOnboardingContractSnapshotService snapshotService,
|
||||
IOnboardingEmployeeContractSnapshotService employeeContractSnapshotService,
|
||||
IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_config = config;
|
||||
_snapshotService = snapshotService;
|
||||
_employeeContractSnapshotService = employeeContractSnapshotService;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过入职任务,并在同一事务中创建正式员工档案。
|
||||
/// </summary>
|
||||
public async Task Approve(OnboardingAuditActionInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ApproveCore(input), protectIdentity: true);
|
||||
}
|
||||
|
||||
private async Task ApproveCore(OnboardingAuditActionInput input)
|
||||
{
|
||||
var task = await GetAuditableTask(input.TaskId);
|
||||
var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
|
||||
@@ -47,10 +55,33 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
var contractStartDate = await ResolveContractStartDate(task.Id);
|
||||
|
||||
var employee = employeeInfo.Adapt<ObEmployee>();
|
||||
if (string.IsNullOrWhiteSpace(employee.EmployeeNo))
|
||||
{
|
||||
var taskContractIds = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == task.Id)
|
||||
.Select(item => item.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (taskContractIds.Count > 0)
|
||||
{
|
||||
employee.EmployeeNo = await _db.Queryable<ObTaskContractVariable>()
|
||||
.Where(item => taskContractIds.Contains(item.TaskContractId) && item.VariableCode == "hr.EmployeeNo" && item.VariableValue != null && item.VariableValue != "")
|
||||
.OrderBy(item => item.SortCode)
|
||||
.Select(item => item.VariableValue)
|
||||
.FirstAsync();
|
||||
}
|
||||
}
|
||||
employee.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
|
||||
employee.CompanyId = task.CompanyId;
|
||||
employee.DepartmentId = task.DepartmentId;
|
||||
employee.PositionId = task.PositionId;
|
||||
employee.EmploymentType = string.IsNullOrWhiteSpace(task.EmploymentType)
|
||||
? EmployeeEmploymentType.FullTime
|
||||
: task.EmploymentType;
|
||||
employee.RegularizationStatus = employee.EmploymentType == EmployeeEmploymentType.PartTime
|
||||
? EmployeeRegularizationStatus.NotApplicable
|
||||
: EmployeeRegularizationStatus.Probation;
|
||||
employee.SocialInsuranceStatus = EmployeeSocialInsuranceStatus.Unknown;
|
||||
if (task.DepartmentId.HasValue)
|
||||
{
|
||||
var dept = await _db.Queryable<ObDepartment>().InSingleAsync(task.DepartmentId.Value);
|
||||
@@ -69,8 +100,9 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
employee.ExtraInfoJson = global::System.Text.Json.JsonSerializer.Serialize(employeeInfo);
|
||||
employee.Status = CommonStatusConst.ENABLE;
|
||||
employee.CreateTime = DateTime.Now;
|
||||
employee.CreateUserId = UserManager.UserId;
|
||||
employee.CreateUser = UserManager.Name;
|
||||
// 员工档案归属于原任务创建人,而不是执行审核的人。
|
||||
employee.CreateUserId = task.CreateUserId;
|
||||
employee.CreateUser = task.CreateUser;
|
||||
employee.UpdateTime = null;
|
||||
employee.UpdateUserId = null;
|
||||
employee.UpdateUser = null;
|
||||
@@ -97,6 +129,25 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
await _snapshotService.Build(task.Id, employeeInfo);
|
||||
await _db.Insertable(employee).ExecuteCommandAsync();
|
||||
|
||||
// 全局插入审计会把 CreateUserId 设置为当前审核人。管理员代审核他人任务时,
|
||||
// 员工档案仍应归属于原任务创建人,否则普通人事将无法继续维护自己发起的员工。
|
||||
if (task.CreateUserId.HasValue && task.CreateUserId != UserManager.UserId)
|
||||
{
|
||||
await _db.Updateable<ObEmployee>()
|
||||
.SetColumns(item => item.CreateUserId == task.CreateUserId)
|
||||
.SetColumns(item => item.CreateUser == task.CreateUser)
|
||||
.Where(item => item.Id == employee.Id)
|
||||
.ExecuteCommandAsync();
|
||||
|
||||
var ownerPersisted = await _db.Queryable<ObEmployee>()
|
||||
.AnyAsync(item => item.Id == employee.Id
|
||||
&& item.CreateUserId == task.CreateUserId);
|
||||
if (!ownerPersisted)
|
||||
throw Oops.Bah("员工档案创建人归属保存失败,请稍后重试");
|
||||
}
|
||||
|
||||
await _employeeContractSnapshotService.Build(employee.Id, task.Id);
|
||||
|
||||
task.EmployeeId = employee.Id;
|
||||
task.TaskStatus = OnboardingTaskStatus.Approved;
|
||||
task.AuditedAt = DateTime.Now;
|
||||
@@ -114,6 +165,11 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
/// 退回入职任务,保存审核意见并允许候选人补充资料。
|
||||
/// </summary>
|
||||
public async Task Reject(OnboardingAuditActionInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => RejectCore(input));
|
||||
}
|
||||
|
||||
private async Task RejectCore(OnboardingAuditActionInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.AuditOpinion))
|
||||
{
|
||||
@@ -160,15 +216,10 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
throw Oops.Bah("当前任务不在审核中");
|
||||
}
|
||||
|
||||
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
|
||||
?? throw Oops.Bah("请配置入职审核人");
|
||||
// 审核人由全局配置与公司专属配置合并得出,不依赖独立业务表。
|
||||
var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId);
|
||||
|
||||
if (!reviewerIds.Contains(UserManager.UserId))
|
||||
{
|
||||
throw Oops.Bah("您不是该入职任务的审核人");
|
||||
}
|
||||
await _dataPermission.EnsureCanManage(
|
||||
task.CreateUserId,
|
||||
OnboardingPermissionCodes.TaskManageAll,
|
||||
"入职任务");
|
||||
|
||||
return task;
|
||||
}
|
||||
@@ -197,7 +248,7 @@ public class OnboardingAuditService : IOnboardingAuditService
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (normalizedValues.Count == 0)
|
||||
throw Oops.Bah("签署材料参数“合同开始日期”尚未填写,无法生成正式员工档案");
|
||||
throw Oops.Bah("签署材料参数“合同开始日期”尚未填写,无法生成员工档案");
|
||||
|
||||
var parsedDates = new List<DateTime>();
|
||||
foreach (var value in normalizedValues)
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using SqlSugar;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>版本盖章配置局部更新,避免覆盖另一个开关、版本状态或历史任务快照。</summary>
|
||||
internal static class ContractTemplateVersionSealConfiguration
|
||||
{
|
||||
internal static IUpdateable<ObContractTemplateVersion> BuildUpdate(
|
||||
ISqlSugarClient db,
|
||||
OnboardingContractVersionSealInput input)
|
||||
{
|
||||
if (input.Id <= 0)
|
||||
throw Oops.Bah("版本Id不能为空");
|
||||
if (!input.CompanySealEnabled.HasValue && !input.AcrossPageSealEnabled.HasValue)
|
||||
throw Oops.Bah("请指定需要修改的盖章开关");
|
||||
|
||||
var update = db.Updateable<ObContractTemplateVersion>()
|
||||
.Where(item => item.Id == input.Id);
|
||||
if (input.CompanySealEnabled.HasValue)
|
||||
update = update.SetColumns(item => item.CompanySealEnabled == input.CompanySealEnabled);
|
||||
if (input.AcrossPageSealEnabled.HasValue)
|
||||
update = update.SetColumns(item => item.AcrossPageSealEnabled == input.AcrossPageSealEnabled);
|
||||
return update;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 签署材料模板版本的时间有效性规则。
|
||||
/// </summary>
|
||||
internal static class ContractTemplateVersionValidity
|
||||
{
|
||||
internal static void ValidateRange(DateTime? effectiveAt, DateTime? expiresAt)
|
||||
{
|
||||
if (effectiveAt.HasValue && expiresAt.HasValue && expiresAt.Value <= effectiveAt.Value)
|
||||
throw Oops.Bah("失效时间必须晚于生效时间");
|
||||
}
|
||||
|
||||
internal static bool IsAvailable(ObContractTemplateVersion version, DateTime now)
|
||||
{
|
||||
return version != null
|
||||
&& version.VersionStatus == ContractTemplateVersionStatus.Enabled
|
||||
&& (!version.EffectiveDate.HasValue || version.EffectiveDate.Value <= now)
|
||||
&& (!version.ExpiryDate.HasValue || version.ExpiryDate.Value > now);
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务数据操作权限校验。
|
||||
/// </summary>
|
||||
public interface IOnboardingDataPermissionService : ITransient
|
||||
{
|
||||
Task<bool> CanManage(long? createUserId, string manageAllPermissionCode);
|
||||
|
||||
Task EnsureCanManage(long? createUserId, string manageAllPermissionCode, string dataName);
|
||||
|
||||
Task EnsureCanManage(IEnumerable<long?> createUserIds, string manageAllPermissionCode, string dataName);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 统一校验入职业务中的可编辑编码,避免非法字符或超长内容落到数据库后才报 500。
|
||||
/// </summary>
|
||||
internal static class OnboardingCodeValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// 规范化并校验业务编码。编码必须以字母开头,可包含字母、数字、点、下划线和短横线。
|
||||
/// </summary>
|
||||
public static string Normalize(string code, string fieldName, int maxLength)
|
||||
{
|
||||
var normalized = code?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
throw Oops.Bah($"{fieldName}不能为空");
|
||||
|
||||
if (normalized.Length > maxLength)
|
||||
throw Oops.Bah($"{fieldName}长度不能超过 {maxLength} 个字符");
|
||||
|
||||
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
normalized,
|
||||
@"^[A-Za-z][A-Za-z0-9._-]*$"))
|
||||
{
|
||||
throw Oops.Bah($"{fieldName}必须以字母开头,只能包含字母、数字、点、下划线和短横线");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Data.Common;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 所有修改同一任务的入口共用 MySQL 命名锁,适用于同一主库上的多个后端实例。
|
||||
/// 锁使用独立、非池化连接,不在第三方请求期间持有业务数据库事务。
|
||||
/// </summary>
|
||||
internal static class OnboardingConcurrency
|
||||
{
|
||||
public static Task RunTask(SqlSugarScope db, long taskId, Func<Task> action,
|
||||
bool transactional = false, bool protectIdentity = false)
|
||||
=> RunTask(db, taskId, async () => { await action(); return true; },
|
||||
transactional, protectIdentity);
|
||||
|
||||
public static Task<T> RunTask<T>(SqlSugarScope db, long taskId, Func<Task<T>> action,
|
||||
bool transactional = false, bool protectIdentity = false)
|
||||
=> Run(db, $"task:{taskId}", () => protectIdentity
|
||||
? RunPerson(db, taskId, () => InTransaction(db, action, transactional))
|
||||
: InTransaction(db, action, transactional));
|
||||
|
||||
public static Task RunEmployee(SqlSugarScope db, long employeeId, Func<Task> action)
|
||||
=> Run(db, $"employee:{employeeId}", async () => { await action(); return true; });
|
||||
|
||||
public static Task RunPublic(SqlSugarScope db, string token, Func<Task> action,
|
||||
bool transactional = false, bool protectIdentity = false)
|
||||
=> RunPublic(db, token, async () => { await action(); return true; },
|
||||
transactional, protectIdentity);
|
||||
|
||||
public static async Task<T> RunPublic<T>(SqlSugarScope db, string token, Func<Task<T>> action,
|
||||
bool transactional = false, bool protectIdentity = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw Oops.Bah("入职链接 Token 不能为空");
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
var link = await db.Queryable<ObTaskLink>().FirstAsync(item => item.TokenHash == hash)
|
||||
?? throw Oops.Bah("链接不存在");
|
||||
// 此处只定位锁;被保护的方法必须在锁内重新检查链接、任务状态和归属。
|
||||
return await RunTask(db, link.TaskId, action, transactional, protectIdentity);
|
||||
}
|
||||
|
||||
public static Task RunContract(SqlSugarScope db, long contractId, Func<Task> action,
|
||||
bool transactional = false)
|
||||
=> RunContract(db, contractId, async () => { await action(); return true; }, transactional);
|
||||
|
||||
public static async Task<T> RunContract<T>(SqlSugarScope db, long contractId, Func<Task<T>> action,
|
||||
bool transactional = false)
|
||||
{
|
||||
var contract = await db.Queryable<ObTaskContract>().InSingleAsync(contractId)
|
||||
?? throw Oops.Bah("任务合同不存在");
|
||||
return await RunTask(db, contract.TaskId, action, transactional);
|
||||
}
|
||||
|
||||
public static async Task RunMaterial(SqlSugarScope db, long materialId, Func<Task> action)
|
||||
{
|
||||
var material = await db.Queryable<ObTaskMaterial>().InSingleAsync(materialId)
|
||||
?? throw Oops.Bah("任务资料不存在");
|
||||
await RunTask(db, material.TaskId, action, transactional: true);
|
||||
}
|
||||
|
||||
private static async Task<T> RunPerson<T>(SqlSugarScope db, long taskId, Func<Task<T>> action)
|
||||
{
|
||||
var info = await db.Queryable<ObTaskEmployeeInfo>().FirstAsync(item => item.TaskId == taskId)
|
||||
?? throw Oops.Bah("请先完整填写基本信息");
|
||||
// 统一顺序:任务 -> 身份证 -> 手机;跨任务提交/建档的查重与写入也串行。
|
||||
return await Run(db, "identity:" + info.IdCardNumber?.Trim().ToUpperInvariant(),
|
||||
() => Run(db, "phone:" + info.Phone?.Trim(), action));
|
||||
}
|
||||
|
||||
private static async Task<T> InTransaction<T>(SqlSugarScope db, Func<Task<T>> action, bool enabled)
|
||||
{
|
||||
if (!enabled || db.Ado.Transaction != null)
|
||||
return await action();
|
||||
await db.Ado.BeginTranAsync();
|
||||
try
|
||||
{
|
||||
var result = await action();
|
||||
await db.Ado.CommitTranAsync();
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await db.Ado.RollbackTranAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<T> Run<T>(SqlSugarScope db, string resource, Func<Task<T>> action)
|
||||
{
|
||||
if (db.CurrentConnectionConfig.DbType != global::SqlSugar.DbType.MySql)
|
||||
throw Oops.Bah("入职并发保护需要 MySQL 数据库");
|
||||
// 使用已选择的业务主库,不能在只读副本/不同主库上分别申请锁。
|
||||
var database = db.Ado.Connection.Database;
|
||||
var key = Convert.ToHexString(SHA256.HashData(
|
||||
Encoding.UTF8.GetBytes($"onboardx:{database}:{resource}")));
|
||||
return OnboardingOperationGate.Run(key, () => Acquire(db, key), action);
|
||||
}
|
||||
|
||||
private static async Task<IAsyncDisposable> Acquire(SqlSugarScope db, string key)
|
||||
{
|
||||
var connection = (DbConnection)Activator.CreateInstance(db.Ado.Connection.GetType());
|
||||
var builder = new DbConnectionStringBuilder
|
||||
{
|
||||
ConnectionString = db.CurrentConnectionConfig.ConnectionString
|
||||
};
|
||||
// 确保异常关闭时会真正关闭持锁会话,不把命名锁留在连接池里。
|
||||
builder["Pooling"] = false;
|
||||
connection.ConnectionString = builder.ConnectionString;
|
||||
try
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT GET_LOCK(@lockName, 0)";
|
||||
command.CommandTimeout = 5;
|
||||
AddKey(command, key);
|
||||
var result = await command.ExecuteScalarAsync();
|
||||
if (result == null || result == DBNull.Value || Convert.ToInt32(result) != 1)
|
||||
throw Oops.Bah("当前数据正在处理,请稍后刷新重试");
|
||||
return new NamedLock(connection, key);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await connection.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddKey(DbCommand command, string key)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = "@lockName";
|
||||
parameter.Value = key;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
private sealed class NamedLock : IAsyncDisposable
|
||||
{
|
||||
private readonly DbConnection _connection;
|
||||
private readonly string _key;
|
||||
|
||||
public NamedLock(DbConnection connection, string key)
|
||||
{
|
||||
_connection = connection;
|
||||
_key = key;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var command = _connection.CreateCommand();
|
||||
command.CommandText = "SELECT RELEASE_LOCK(@lockName)";
|
||||
command.CommandTimeout = 5;
|
||||
AddKey(command, _key);
|
||||
await command.ExecuteScalarAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 非池化物理连接关闭会释放会话锁;释放错误不覆盖业务原异常。
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务数据操作权限校验。
|
||||
/// 普通人事可查看全部业务数据,但只能操作本人创建的数据;拥有模块“管理全部”按钮权限的人员可操作全部数据。
|
||||
/// </summary>
|
||||
public class OnboardingDataPermissionService : IOnboardingDataPermissionService
|
||||
{
|
||||
private readonly ISysUserService _sysUserService;
|
||||
|
||||
public OnboardingDataPermissionService(ISysUserService sysUserService)
|
||||
{
|
||||
_sysUserService = sysUserService;
|
||||
}
|
||||
|
||||
public async Task<bool> CanManage(long? createUserId, string manageAllPermissionCode)
|
||||
{
|
||||
if (UserManager.SuperAdmin)
|
||||
return true;
|
||||
|
||||
if (createUserId.HasValue && createUserId.Value == UserManager.UserId)
|
||||
return true;
|
||||
|
||||
var buttonCodes = await _sysUserService.GetButtonCodeList(UserManager.UserId);
|
||||
return buttonCodes.Contains(manageAllPermissionCode);
|
||||
}
|
||||
|
||||
public async Task EnsureCanManage(long? createUserId, string manageAllPermissionCode, string dataName)
|
||||
{
|
||||
if (!await CanManage(createUserId, manageAllPermissionCode))
|
||||
throw Oops.Bah($"只能操作本人创建的{dataName}");
|
||||
}
|
||||
|
||||
public async Task EnsureCanManage(
|
||||
IEnumerable<long?> createUserIds,
|
||||
string manageAllPermissionCode,
|
||||
string dataName)
|
||||
{
|
||||
var owners = createUserIds.Distinct().ToList();
|
||||
if (owners.Count == 0)
|
||||
return;
|
||||
|
||||
if (UserManager.SuperAdmin || owners.All(id => id.HasValue && id.Value == UserManager.UserId))
|
||||
return;
|
||||
|
||||
var buttonCodes = await _sysUserService.GetButtonCodeList(UserManager.UserId);
|
||||
if (!buttonCodes.Contains(manageAllPermissionCode))
|
||||
throw Oops.Bah($"所选数据中包含他人创建的{dataName},不能操作");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 使同一异步调用链可重入,独立请求仍须取得外部互斥锁。
|
||||
/// 不在 Acquire 中设置 AsyncLocal,避免值不能流回调用方的问题。
|
||||
/// </summary>
|
||||
internal static class OnboardingOperationGate
|
||||
{
|
||||
private sealed record Frame(string Key, Frame Parent);
|
||||
private static readonly AsyncLocal<Frame> Held = new();
|
||||
|
||||
internal static async Task<T> Run<T>(
|
||||
string key,
|
||||
Func<Task<IAsyncDisposable>> acquire,
|
||||
Func<Task<T>> action)
|
||||
{
|
||||
for (var frame = Held.Value; frame != null; frame = frame.Parent)
|
||||
if (frame.Key == key)
|
||||
return await action();
|
||||
|
||||
await using var lease = await acquire();
|
||||
var previous = Held.Value;
|
||||
Held.Value = new Frame(key, previous);
|
||||
try
|
||||
{
|
||||
return await action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Held.Value = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务按钮权限编码。
|
||||
/// </summary>
|
||||
public static class OnboardingPermissionCodes
|
||||
{
|
||||
public const string SetupManageAll = "onboardingSetupManageAll";
|
||||
public const string EntryConfigManageAll = "onboardingEntryConfigManageAll";
|
||||
public const string ContractManageAll = "onboardingContractManageAll";
|
||||
public const string TaskManageAll = "onboardingTaskManageAll";
|
||||
public const string EmployeeManageAll = "onboardingEmployeeManageAll";
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 解析入职审核人系统配置,统一支持全局审核人和公司专属审核人。
|
||||
/// </summary>
|
||||
internal static class OnboardingReviewerConfigParser
|
||||
{
|
||||
private const string ConfigKey = "onboard.reviewer.userIds";
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定公司的全部有效审核人用户 Id。
|
||||
/// </summary>
|
||||
public static HashSet<long> Parse(string configValue, long companyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configValue))
|
||||
throw Oops.Bah($"系统配置 {ConfigKey} 不能为空");
|
||||
|
||||
try
|
||||
{
|
||||
using var document = global::System.Text.Json.JsonDocument.Parse(configValue);
|
||||
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Object)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
var reviewerIds = new HashSet<long>();
|
||||
if (document.RootElement.TryGetProperty("all", out var allReviewers))
|
||||
AddReviewers(allReviewers, reviewerIds);
|
||||
|
||||
if (document.RootElement.TryGetProperty("company", out var companyReviewers))
|
||||
{
|
||||
if (companyReviewers.ValueKind != global::System.Text.Json.JsonValueKind.Object)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
if (companyReviewers.TryGetProperty(companyId.ToString(), out var reviewers))
|
||||
AddReviewers(reviewers, reviewerIds);
|
||||
}
|
||||
|
||||
return reviewerIds;
|
||||
}
|
||||
catch (global::System.Text.Json.JsonException)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"系统配置 {ConfigKey} 格式不正确,应为包含 all 数组和 company 对象的 JSON");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 JSON 数组中的审核人 Id 加入集合,并拒绝非整数配置。
|
||||
/// </summary>
|
||||
private static void AddReviewers(
|
||||
global::System.Text.Json.JsonElement element,
|
||||
HashSet<long> reviewerIds)
|
||||
{
|
||||
if (element.ValueKind != global::System.Text.Json.JsonValueKind.Array)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
foreach (var reviewer in element.EnumerateArray())
|
||||
{
|
||||
if (!reviewer.TryGetInt64(out var reviewerId) || reviewerId <= 0)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
reviewerIds.Add(reviewerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,19 +30,64 @@ public class OnboardingCompanyAddInput : ObCompany
|
||||
/// 公司业务编码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司编码不能为空")]
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司编码不能超过50个字符")]
|
||||
public override string CompanyCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公司名称。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司名称不能为空")]
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司名称不能超过50个字符")]
|
||||
public override string CompanyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 合同甲方完整法定名称。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "合同甲方全称不能为空")]
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(100, ErrorMessage = "合同甲方全称不能超过100个字符")]
|
||||
public override string LegalName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 统一社会信用代码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(18, ErrorMessage = "统一社会信用代码不能超过18位")]
|
||||
public override string UnifiedSocialCreditCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 法定代表人姓名。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "法定代表人不能超过20个字符")]
|
||||
public override string LegalRepresentative { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 工商注册地址。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(150, ErrorMessage = "注册地址不能超过150个字符")]
|
||||
public override string RegisteredAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 公司联系电话。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(20, ErrorMessage = "联系电话不能超过20个字符")]
|
||||
public override string ContactPhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁业务分类Id不能超过19位数字")]
|
||||
public override string QiyuesuoCategoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业印章 Id;每个签约公司独立配置。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(19, ErrorMessage = "契约锁印章Id不能超过19位数字")]
|
||||
public override string QiyuesuoSealId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(300, ErrorMessage = "备注不能超过300个字符")]
|
||||
public override string Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,6 +5,13 @@ namespace SimpleAdmin.Application;
|
||||
/// </summary>
|
||||
public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingCompanyService
|
||||
{
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingCompanyService(IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询入职业务公司。
|
||||
/// </summary>
|
||||
@@ -14,7 +21,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyCode), it => it.CompanyCode.Contains(input.CompanyCode))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyName), it => it.CompanyName.Contains(input.CompanyName))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyCode", "CompanyName", "LegalName", "ContactPhone"))
|
||||
.OrderBy(it => it.CreateTime)
|
||||
.ToPagedListAsync(input.PageNum, input.PageSize);
|
||||
}
|
||||
@@ -43,6 +50,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
|
||||
/// </summary>
|
||||
public async Task Add(OnboardingCompanyAddInput input)
|
||||
{
|
||||
NormalizeAndValidateQiyuesuoConfig(input);
|
||||
await EnsureCodeUnique(input.CompanyCode, 0);
|
||||
await InsertAsync(input.Adapt<ObCompany>());
|
||||
}
|
||||
@@ -52,7 +60,12 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
|
||||
/// </summary>
|
||||
public async Task Edit(OnboardingCompanyEditInput input)
|
||||
{
|
||||
await Detail(new BaseIdInput { Id = input.Id });
|
||||
NormalizeAndValidateQiyuesuoConfig(input);
|
||||
var company = await Detail(new BaseIdInput { Id = input.Id });
|
||||
await _dataPermission.EnsureCanManage(
|
||||
company.CreateUserId,
|
||||
OnboardingPermissionCodes.SetupManageAll,
|
||||
"公司");
|
||||
await EnsureCodeUnique(input.CompanyCode, input.Id);
|
||||
await UpdateAsync(input.Adapt<ObCompany>());
|
||||
}
|
||||
@@ -65,6 +78,16 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
|
||||
if (input.Ids.Count == 0)
|
||||
return;
|
||||
|
||||
var companies = await Context.Queryable<ObCompany>()
|
||||
.Where(item => input.Ids.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (companies.Count != input.Ids.Distinct().Count())
|
||||
throw Oops.Bah("公司不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
companies.Select(item => item.CreateUserId),
|
||||
OnboardingPermissionCodes.SetupManageAll,
|
||||
"公司");
|
||||
|
||||
var hasPositions = await Context.Queryable<ObPosition>()
|
||||
.AnyAsync(it => input.Ids.Contains(it.CompanyId));
|
||||
if (hasPositions)
|
||||
@@ -98,6 +121,36 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
|
||||
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
|
||||
}
|
||||
|
||||
private static void NormalizeAndValidateQiyuesuoConfig(ObCompany company)
|
||||
{
|
||||
company.CompanyCode = OnboardingCodeValidator.Normalize(company.CompanyCode, "公司编码", 50);
|
||||
company.CompanyName = company.CompanyName?.Trim();
|
||||
company.LegalName = company.LegalName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(company.CompanyName))
|
||||
throw Oops.Bah("公司名称不能为空");
|
||||
|
||||
company.QiyuesuoCategoryId = company.QiyuesuoCategoryId?.Trim();
|
||||
company.QiyuesuoSealId = company.QiyuesuoSealId?.Trim();
|
||||
|
||||
var hasCategoryId = !string.IsNullOrWhiteSpace(company.QiyuesuoCategoryId);
|
||||
var hasSealId = !string.IsNullOrWhiteSpace(company.QiyuesuoSealId);
|
||||
if (hasCategoryId != hasSealId)
|
||||
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写");
|
||||
if (!hasCategoryId)
|
||||
return;
|
||||
if (!company.QiyuesuoCategoryId.All(char.IsDigit)
|
||||
|| !company.QiyuesuoSealId.All(char.IsDigit))
|
||||
{
|
||||
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写纯数字");
|
||||
}
|
||||
|
||||
if (!long.TryParse(company.QiyuesuoCategoryId, out _)
|
||||
|| !long.TryParse(company.QiyuesuoSealId, out _))
|
||||
{
|
||||
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 超出有效数字范围(最大支持19位长整数)");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureCodeUnique(string code, long id)
|
||||
{
|
||||
var exists = await Context.Queryable<ObCompany>()
|
||||
|
||||
+60
@@ -11,3 +11,63 @@ public class OnboardingGenerateContractInput
|
||||
[IdNotNull(ErrorMessage = "任务合同Id不能为空")]
|
||||
public long TaskContractId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 人事在创建入职任务前下载合同预览的参数。
|
||||
/// </summary>
|
||||
public class OnboardingContractPreviewInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 入职业务公司 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "公司Id不能为空")]
|
||||
public long CompanyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务部门 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "部门Id不能为空")]
|
||||
public long DepartmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入职岗位 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "岗位Id不能为空")]
|
||||
public long PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前选择的入职模板 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "入职模板Id不能为空")]
|
||||
public long OnboardingTemplateId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 本次需要预览的合同模板版本 Id;批量预览时无需传入。
|
||||
/// </summary>
|
||||
public long? ContractTemplateVersionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 人事当前在创建表单中填写的合同变量。
|
||||
/// </summary>
|
||||
public List<OnboardingInitialContractVariableInput> ContractVariables
|
||||
{
|
||||
get;
|
||||
set;
|
||||
} = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建任务前生成的临时 DOCX 预览文件。
|
||||
/// </summary>
|
||||
public class OnboardingContractPreviewOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// DOCX 文件内容,仅通过当前请求返回,不保存到文件表或 OSS。
|
||||
/// </summary>
|
||||
public byte[] FileBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 浏览器下载时使用的文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
}
|
||||
|
||||
+17
@@ -5,8 +5,25 @@ namespace SimpleAdmin.Application;
|
||||
/// </summary>
|
||||
public interface IOnboardingContractDocumentService : ITransient
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建入职任务前,根据人事当前填写的参数生成临时 DOCX 预览。
|
||||
/// </summary>
|
||||
Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
|
||||
OnboardingContractPreviewInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 创建入职任务前,按模板顺序生成全部合同预览并打包为 ZIP。
|
||||
/// </summary>
|
||||
Task<OnboardingContractPreviewOutput> PreviewAllBeforeCreate(
|
||||
OnboardingContractPreviewInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 根据已审核通过的任务合同生成并保存 DOCX 文件。
|
||||
/// </summary>
|
||||
Task<long> Generate(OnboardingGenerateContractInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 员工公开入口已验证一次性任务链接后,生成该链接所属的合同文件。
|
||||
/// </summary>
|
||||
Task<long> GenerateForPublicAccess(long taskContractId, long taskId);
|
||||
}
|
||||
|
||||
+373
-5
@@ -7,26 +7,342 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IFileService _fileService;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingContractDocumentService(IFileService fileService)
|
||||
public OnboardingContractDocumentService(
|
||||
IFileService fileService,
|
||||
IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_fileService = fileService;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在任务创建前生成指定合同的临时 DOCX,仅通过响应返回,不写文件表和 OSS。
|
||||
/// </summary>
|
||||
public async Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
|
||||
OnboardingContractPreviewInput input)
|
||||
{
|
||||
var context = await LoadPreviewContext(input);
|
||||
var contractConfig = input.ContractTemplateVersionId.HasValue
|
||||
? context.ContractConfigs.FirstOrDefault(item =>
|
||||
item.ContractTemplateVersionId == input.ContractTemplateVersionId.Value)
|
||||
: context.ContractConfigs.FirstOrDefault();
|
||||
if (contractConfig == null)
|
||||
throw Oops.Bah("所选合同不属于当前入职模板或已被移除");
|
||||
|
||||
return await RenderPreview(input, context, contractConfig, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在任务创建前生成全部合同预览,并按模板顺序打包为 ZIP。
|
||||
/// </summary>
|
||||
public async Task<OnboardingContractPreviewOutput> PreviewAllBeforeCreate(
|
||||
OnboardingContractPreviewInput input)
|
||||
{
|
||||
var context = await LoadPreviewContext(input);
|
||||
using var zipBytes = new MemoryStream();
|
||||
using (var archive = new global::System.IO.Compression.ZipArchive(
|
||||
zipBytes,
|
||||
global::System.IO.Compression.ZipArchiveMode.Create,
|
||||
true))
|
||||
{
|
||||
for (var index = 0; index < context.ContractConfigs.Count; index++)
|
||||
{
|
||||
var preview = await RenderPreview(
|
||||
input,
|
||||
context,
|
||||
context.ContractConfigs[index],
|
||||
index + 1);
|
||||
var entry = archive.CreateEntry(
|
||||
preview.FileName,
|
||||
global::System.IO.Compression.CompressionLevel.Fastest);
|
||||
await using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(preview.FileBytes);
|
||||
}
|
||||
}
|
||||
|
||||
return new OnboardingContractPreviewOutput
|
||||
{
|
||||
FileBytes = zipBytes.ToArray(),
|
||||
FileName = $"全部合同_人事预览_{DateTime.Now:yyyyMMddHHmmss}.zip"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验预览上下文,并读取入职模板锁定的全部合同配置。
|
||||
/// </summary>
|
||||
private async Task<ContractPreviewContext> LoadPreviewContext(
|
||||
OnboardingContractPreviewInput input)
|
||||
{
|
||||
var company = await _db.Queryable<ObCompany>()
|
||||
.FirstAsync(item => item.Id == input.CompanyId
|
||||
&& item.Status == CommonStatusConst.ENABLE)
|
||||
?? throw Oops.Bah("公司不存在或未启用");
|
||||
|
||||
var departmentExists = await _db.Queryable<ObDepartment>()
|
||||
.AnyAsync(item => item.Id == input.DepartmentId
|
||||
&& item.CompanyId == input.CompanyId
|
||||
&& item.Status == CommonStatusConst.ENABLE);
|
||||
if (!departmentExists)
|
||||
throw Oops.Bah("部门不存在、未启用或不属于该公司");
|
||||
|
||||
var positionExists = await _db.Queryable<ObPosition>()
|
||||
.AnyAsync(item => item.Id == input.PositionId
|
||||
&& item.CompanyId == input.CompanyId
|
||||
&& item.DepartmentId == input.DepartmentId
|
||||
&& item.Status == CommonStatusConst.ENABLE);
|
||||
if (!positionExists)
|
||||
throw Oops.Bah("岗位不存在、未启用或不属于该部门/公司");
|
||||
|
||||
var onboardingTemplate = await _db.Queryable<ObOnboardingTemplate>()
|
||||
.FirstAsync(item => item.Id == input.OnboardingTemplateId
|
||||
&& item.CompanyId == input.CompanyId
|
||||
&& item.DepartmentId == input.DepartmentId
|
||||
&& item.Status == CommonStatusConst.ENABLE)
|
||||
?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司、部门");
|
||||
|
||||
var supportsPosition = await _db.Queryable<ObOnboardingTemplatePosition>()
|
||||
.AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id
|
||||
&& item.PositionId == input.PositionId);
|
||||
if (!supportsPosition)
|
||||
throw Oops.Bah("所选入职模板不适用于该岗位");
|
||||
|
||||
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
|
||||
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync();
|
||||
if (contractConfigs.Count == 0)
|
||||
throw Oops.Bah("入职模板尚未配置合同文件");
|
||||
|
||||
var duplicateVersion = contractConfigs
|
||||
.GroupBy(item => item.ContractTemplateVersionId)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicateVersion != null)
|
||||
throw Oops.Bah("入职模板中存在重复的合同版本,请先调整模板配置");
|
||||
|
||||
var templateIds = contractConfigs
|
||||
.Select(item => item.ContractTemplateId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var contractTemplates = await _db.Queryable<ObContractTemplate>()
|
||||
.Where(item => templateIds.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (contractTemplates.Count != templateIds.Count
|
||||
|| contractTemplates.Any(item => item.CompanyId != input.CompanyId
|
||||
|| item.DepartmentId != input.DepartmentId
|
||||
|| !string.Equals(
|
||||
item.Status,
|
||||
CommonStatusConst.ENABLE,
|
||||
global::System.StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板");
|
||||
}
|
||||
|
||||
var supportedTemplateCount = await _db.Queryable<ObContractTemplatePosition>()
|
||||
.CountAsync(item => templateIds.Contains(item.ContractTemplateId)
|
||||
&& item.PositionId == input.PositionId);
|
||||
if (supportedTemplateCount != templateIds.Count)
|
||||
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
|
||||
|
||||
var versionIds = contractConfigs
|
||||
.Select(item => item.ContractTemplateVersionId)
|
||||
.ToList();
|
||||
var versions = await _db.Queryable<ObContractTemplateVersion>()
|
||||
.Where(item => versionIds.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
var now = DateTime.Now;
|
||||
var versionsValid = versions.Count == versionIds.Count
|
||||
&& contractConfigs.All(config => versions.Any(version =>
|
||||
version.Id == config.ContractTemplateVersionId
|
||||
&& version.TemplateId == config.ContractTemplateId
|
||||
&& ContractTemplateVersionValidity.IsAvailable(version, now)));
|
||||
if (!versionsValid)
|
||||
throw Oops.Bah("入职模板包含不存在、未生效、已失效、未启用或归属错误的签署材料版本");
|
||||
|
||||
var inputs = input.ContractVariables
|
||||
?? new List<OnboardingInitialContractVariableInput>();
|
||||
OnboardingTaskService.ValidateInitialContractDateRanges(inputs);
|
||||
var duplicateInput = inputs
|
||||
.GroupBy(item => new
|
||||
{
|
||||
item.ContractTemplateVersionId,
|
||||
VariableCode = item.VariableCode?.ToUpperInvariant()
|
||||
})
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicateInput != null)
|
||||
throw Oops.Bah($"合同变量“{duplicateInput.Key.VariableCode}”重复提交");
|
||||
var unknownVersionInput = inputs.FirstOrDefault(item =>
|
||||
!versionIds.Contains(item.ContractTemplateVersionId));
|
||||
if (unknownVersionInput != null)
|
||||
throw Oops.Bah($"合同变量“{unknownVersionInput.VariableCode}”不属于当前入职模板");
|
||||
|
||||
return new ContractPreviewContext
|
||||
{
|
||||
Company = company,
|
||||
ContractConfigs = contractConfigs,
|
||||
ContractTemplates = contractTemplates,
|
||||
Versions = versions
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据一条合同配置和当前人事参数渲染一份 DOCX。
|
||||
/// </summary>
|
||||
private async Task<OnboardingContractPreviewOutput> RenderPreview(
|
||||
OnboardingContractPreviewInput input,
|
||||
ContractPreviewContext context,
|
||||
ObOnboardingTemplateContract contractConfig,
|
||||
int? fileOrder)
|
||||
{
|
||||
var contractTemplate = context.ContractTemplates.First(item =>
|
||||
item.Id == contractConfig.ContractTemplateId);
|
||||
var version = context.Versions.First(item =>
|
||||
item.Id == contractConfig.ContractTemplateVersionId);
|
||||
var definitions = await _db.Queryable<ObTemplateVariable>()
|
||||
.Where(item => item.TemplateVersionId == version.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.ToListAsync();
|
||||
var duplicateDefinition = definitions
|
||||
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicateDefinition != null)
|
||||
throw Oops.Bah($"合同变量“{duplicateDefinition.Key}”存在重复定义");
|
||||
|
||||
var inputs = (input.ContractVariables
|
||||
?? new List<OnboardingInitialContractVariableInput>())
|
||||
.Where(item => item.ContractTemplateVersionId == version.Id)
|
||||
.ToList();
|
||||
var editableDefinitions = definitions
|
||||
.Where(IsHrEditableDefinition)
|
||||
.ToList();
|
||||
foreach (var submitted in inputs)
|
||||
{
|
||||
var definitionExists = editableDefinitions.Any(definition =>
|
||||
string.Equals(
|
||||
definition.VariableCode,
|
||||
submitted.VariableCode,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (!definitionExists)
|
||||
throw Oops.Bah($"合同变量“{submitted.VariableCode}”不存在或不允许人事填写");
|
||||
}
|
||||
|
||||
var missingRequired = editableDefinitions.FirstOrDefault(definition =>
|
||||
{
|
||||
if (!definition.Required)
|
||||
return false;
|
||||
|
||||
var submitted = inputs.FirstOrDefault(item =>
|
||||
string.Equals(
|
||||
item.VariableCode,
|
||||
definition.VariableCode,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
return string.IsNullOrWhiteSpace(
|
||||
submitted?.VariableValue ?? definition.DefaultValue);
|
||||
});
|
||||
if (missingRequired != null)
|
||||
throw Oops.Bah($"请填写合同变量“{missingRequired.VariableName}”");
|
||||
|
||||
var previewTask = new ObOnboardingTask
|
||||
{
|
||||
TaskNo = "【发起后自动生成】",
|
||||
CompanyId = input.CompanyId,
|
||||
DepartmentId = input.DepartmentId,
|
||||
PositionId = input.PositionId,
|
||||
OnboardingTemplateId = input.OnboardingTemplateId
|
||||
};
|
||||
var emptyDynamicValues = new Dictionary<string, string>(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var values = definitions.ToDictionary(
|
||||
definition => definition.VariableCode,
|
||||
definition =>
|
||||
{
|
||||
var submitted = inputs.FirstOrDefault(item =>
|
||||
string.Equals(
|
||||
item.VariableCode,
|
||||
definition.VariableCode,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
if (submitted != null)
|
||||
return submitted.VariableValue ?? string.Empty;
|
||||
|
||||
return OnboardingContractSnapshotService.ResolveValue(
|
||||
definition,
|
||||
null,
|
||||
emptyDynamicValues,
|
||||
context.Company,
|
||||
previewTask,
|
||||
true)
|
||||
?? string.Empty;
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var source = await _fileService.Download(
|
||||
new BaseIdInput { Id = version.SourceDocxFileId })
|
||||
?? throw Oops.Bah("无法读取合同源文件");
|
||||
await using var sourceStream = source.FileStream;
|
||||
using var sourceBytes = new MemoryStream();
|
||||
await sourceStream.CopyToAsync(sourceBytes);
|
||||
|
||||
return new OnboardingContractPreviewOutput
|
||||
{
|
||||
FileBytes = DocxTemplateProcessor.Render(
|
||||
sourceBytes.ToArray(),
|
||||
values),
|
||||
FileName = BuildPreviewFileName(contractTemplate.TemplateName, fileOrder)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
|
||||
/// </summary>
|
||||
public async Task<long> Generate(OnboardingGenerateContractInput input)
|
||||
{
|
||||
return await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => GenerateCore(input));
|
||||
}
|
||||
|
||||
private async Task<long> GenerateCore(OnboardingGenerateContractInput input)
|
||||
{
|
||||
var contract = await _db.Queryable<ObTaskContract>().InSingleAsync(input.TaskContractId)
|
||||
?? throw Oops.Bah("任务合同不存在");
|
||||
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(contract.TaskId)
|
||||
?? throw Oops.Bah("入职任务不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
task.CreateUserId,
|
||||
OnboardingPermissionCodes.TaskManageAll,
|
||||
"入职任务");
|
||||
|
||||
return await GenerateCore(contract, task);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long> GenerateForPublicAccess(long taskContractId, long taskId)
|
||||
{
|
||||
return await OnboardingConcurrency.RunTask(_db, taskId, () => GenerateForPublicAccessCore(taskContractId, taskId));
|
||||
}
|
||||
|
||||
private async Task<long> GenerateForPublicAccessCore(long taskContractId, long taskId)
|
||||
{
|
||||
var contract = await _db.Queryable<ObTaskContract>()
|
||||
.FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId)
|
||||
?? throw Oops.Bah("合同不存在");
|
||||
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
|
||||
?? throw Oops.Bah("入职任务不存在");
|
||||
|
||||
return await GenerateCore(contract, task);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行已经完成调用方权限或公开链接归属校验的合同生成逻辑。
|
||||
/// </summary>
|
||||
private async Task<long> GenerateCore(ObTaskContract contract, ObOnboardingTask task)
|
||||
{
|
||||
var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Rejected
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Reviewing
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Approved;
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Approved
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Archived;
|
||||
if (!statusAllowsGeneration)
|
||||
throw Oops.Bah("当前任务状态不能生成合同");
|
||||
if (contract.GeneratedDocxFileId.HasValue)
|
||||
@@ -56,7 +372,7 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
|
||||
.FirstOrDefault(definition => string.IsNullOrWhiteSpace(
|
||||
variables.FirstOrDefault(variable => variable.VariableCode == definition.VariableCode)?.VariableValue));
|
||||
if (missing != null)
|
||||
throw Oops.Bah($"合同变量“{missing.VariableName}”尚未填写,暂时不能预览合同");
|
||||
throw Oops.Bah($"合同“{contract.ContractName}”的变量“{missing.VariableName}”尚未填写,暂时不能预览合同");
|
||||
|
||||
var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id })
|
||||
?? throw Oops.Bah("无法读取合同源文件");
|
||||
@@ -71,8 +387,19 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
|
||||
item => item.VariableValue ?? string.Empty,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
var generatedAt = DateTime.Now;
|
||||
// 在文件名中加入生成批次,避免手机系统把同名的新合同误认为历史下载文件。
|
||||
var name = $"{task.TaskNo}_{contract.ContractName}_{generatedAt:yyyyMMddHHmmssfff}.docx";
|
||||
var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
|
||||
.FirstAsync(item => item.TaskId == task.Id);
|
||||
var employeeName = string.IsNullOrWhiteSpace(employeeInfo?.EmployeeName)
|
||||
? "新员工"
|
||||
: employeeInfo.EmployeeName.Trim();
|
||||
var docType = string.IsNullOrWhiteSpace(contract.ContractType)
|
||||
? (string.IsNullOrWhiteSpace(contract.ContractName) ? "签署材料" : contract.ContractName.Trim())
|
||||
: contract.ContractType.Trim();
|
||||
var invalidChars = global::System.IO.Path.GetInvalidFileNameChars();
|
||||
var safeEmployeeName = new string(employeeName.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray());
|
||||
var safeDocType = new string(docType.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray());
|
||||
// 按照规范:编号_员工姓名_文档类型.docx
|
||||
var name = $"{task.TaskNo}_{safeEmployeeName}_{safeDocType}.docx";
|
||||
var fileId = await _fileService.StoreGeneratedFile(
|
||||
SysDictConst.FILE_ENGINE_ALIYUN_OSS,
|
||||
name,
|
||||
@@ -86,4 +413,45 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
|
||||
await _db.Updateable(contract).ExecuteCommandAsync();
|
||||
return fileId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断变量是否允许人事在发起任务前填写。
|
||||
/// </summary>
|
||||
private static bool IsHrEditableDefinition(ObTemplateVariable definition)
|
||||
{
|
||||
return definition.HrEditable
|
||||
&& (definition.VariableType == "hr"
|
||||
|| definition.VariableType == ContractVariableType.Contract
|
||||
|| definition.VariableType == ContractVariableType.Company
|
||||
|| definition.VariableCode.StartsWith(
|
||||
"hr.",
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成兼容 Windows 和 Linux 的合同预览下载文件名。
|
||||
/// </summary>
|
||||
private static string BuildPreviewFileName(string contractName, int? fileOrder = null)
|
||||
{
|
||||
var invalidChars = global::System.IO.Path.GetInvalidFileNameChars();
|
||||
var safeName = new string((contractName ?? "合同")
|
||||
.Select(character => invalidChars.Contains(character) ? '_' : character)
|
||||
.ToArray());
|
||||
var orderPrefix = fileOrder.HasValue ? $"{fileOrder.Value:D2}_" : string.Empty;
|
||||
return $"{orderPrefix}{safeName}_人事预览_{DateTime.Now:yyyyMMddHHmmss}.docx";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建任务前预览合同所需的已校验数据。
|
||||
/// </summary>
|
||||
private sealed class ContractPreviewContext
|
||||
{
|
||||
public ObCompany Company { get; init; }
|
||||
|
||||
public List<ObOnboardingTemplateContract> ContractConfigs { get; init; }
|
||||
|
||||
public List<ObContractTemplate> ContractTemplates { get; init; }
|
||||
|
||||
public List<ObContractTemplateVersion> Versions { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
+27
-6
@@ -19,6 +19,14 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
|
||||
long taskId,
|
||||
ObTaskEmployeeInfo employeeInfo = null,
|
||||
bool invalidateGeneratedPreview = false)
|
||||
{
|
||||
await OnboardingConcurrency.RunTask(_db, taskId, () => BuildCore(taskId, employeeInfo, invalidateGeneratedPreview), transactional: true);
|
||||
}
|
||||
|
||||
private async Task BuildCore(
|
||||
long taskId,
|
||||
ObTaskEmployeeInfo employeeInfo = null,
|
||||
bool invalidateGeneratedPreview = false)
|
||||
{
|
||||
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
|
||||
?? throw Oops.Bah("入职任务不存在");
|
||||
@@ -110,12 +118,13 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveValue(
|
||||
internal static string ResolveValue(
|
||||
ObTemplateVariable definition,
|
||||
ObTaskEmployeeInfo employeeInfo,
|
||||
IReadOnlyDictionary<string, string> dynamicValues,
|
||||
ObCompany company,
|
||||
ObOnboardingTask task)
|
||||
ObOnboardingTask task,
|
||||
bool useEmployeePlaceholder = false)
|
||||
{
|
||||
var sourceField = GetSourceField(definition);
|
||||
if (definition.VariableType == ContractVariableType.Employee)
|
||||
@@ -133,7 +142,15 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
|
||||
if (doc.RootElement.TryGetProperty(sourceField, out var prop)
|
||||
|| doc.RootElement.TryGetProperty(definition.VariableCode, out prop))
|
||||
{
|
||||
var str = prop.GetString();
|
||||
var str = prop.ValueKind switch
|
||||
{
|
||||
global::System.Text.Json.JsonValueKind.String => prop.GetString(),
|
||||
global::System.Text.Json.JsonValueKind.Number => prop.ToString(),
|
||||
global::System.Text.Json.JsonValueKind.True => bool.TrueString,
|
||||
global::System.Text.Json.JsonValueKind.False => bool.FalseString,
|
||||
global::System.Text.Json.JsonValueKind.Null => null,
|
||||
_ => throw Oops.Bah($"员工补充变量“{definition.VariableName}”格式不正确")
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(str)) return str;
|
||||
}
|
||||
}
|
||||
@@ -143,8 +160,11 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
|
||||
}
|
||||
}
|
||||
|
||||
return dynamicValues.TryGetValue(sourceField, out var dynamicValue)
|
||||
? dynamicValue
|
||||
if (dynamicValues.TryGetValue(sourceField, out var dynamicValue))
|
||||
return dynamicValue;
|
||||
|
||||
return useEmployeePlaceholder
|
||||
? $"【待员工填写:{definition.VariableName ?? definition.VariableCode}】"
|
||||
: definition.DefaultValue;
|
||||
}
|
||||
|
||||
@@ -212,7 +232,8 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
|
||||
{
|
||||
return sourceField.ToUpperInvariant() switch
|
||||
{
|
||||
"COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.CompanyCode),
|
||||
// 内置合同变量 CompanyCode 表示统一社会信用代码,保留旧 DOCX 占位符兼容。
|
||||
"COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
|
||||
"COMPANYNAME" or "COMPANY_NAME" => nameof(ObCompany.CompanyName),
|
||||
"LEGALNAME" or "LEGAL_NAME" or "COMPANYLEGALNAME" => nameof(ObCompany.LegalName),
|
||||
"UNIFIEDSOCIALCREDITCODE" or "UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
|
||||
|
||||
+119
-15
@@ -17,25 +17,106 @@ public static class DocxTemplateProcessor
|
||||
/// </summary>
|
||||
public static List<string> ExtractVariableCodes(byte[] docx)
|
||||
{
|
||||
using var input = new MemoryStream(docx);
|
||||
using var archive = new global::System.IO.Compression.ZipArchive(
|
||||
input,
|
||||
global::System.IO.Compression.ZipArchiveMode.Read);
|
||||
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
|
||||
ValidateDocxHeader(docx);
|
||||
try
|
||||
{
|
||||
using var stream = entry.Open();
|
||||
var document = global::System.Xml.Linq.XDocument.Load(stream);
|
||||
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
|
||||
using var input = new MemoryStream(docx);
|
||||
using var archive = new global::System.IO.Compression.ZipArchive(
|
||||
input,
|
||||
global::System.IO.Compression.ZipArchiveMode.Read);
|
||||
if (archive.GetEntry("word/document.xml") == null)
|
||||
throw Oops.Bah("上传文件不是有效的 DOCX 文档,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
|
||||
|
||||
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
|
||||
{
|
||||
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
|
||||
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
|
||||
codes.Add(match.Groups["code"].Value.Trim());
|
||||
using var stream = entry.Open();
|
||||
var document = global::System.Xml.Linq.XDocument.Load(stream);
|
||||
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
|
||||
{
|
||||
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
|
||||
ValidatePlaceholderSyntax(text);
|
||||
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
|
||||
codes.Add(match.Groups["code"].Value.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return codes.OrderBy(code => code).ToList();
|
||||
}
|
||||
catch (global::System.IO.InvalidDataException)
|
||||
{
|
||||
throw Oops.Bah("DOCX 文件结构已经损坏或上传不完整,请重新另存为 DOCX 后上传");
|
||||
}
|
||||
catch (global::System.Xml.XmlException)
|
||||
{
|
||||
throw Oops.Bah("DOCX 文件内部内容损坏,Word 可以尝试修复该文件;请修复并重新另存为 DOCX 后上传");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 占位符提取正则只会匹配正确格式,因此必须先主动检查错误格式,
|
||||
/// 避免缺少花括号或变量编码写错时被静默忽略并继续创建版本。
|
||||
/// </summary>
|
||||
private static void ValidatePlaceholderSyntax(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return;
|
||||
|
||||
if (text.Contains("{{", StringComparison.Ordinal)
|
||||
|| text.Contains("}}", StringComparison.Ordinal))
|
||||
{
|
||||
throw Oops.Bah(
|
||||
"DOCX 占位符格式错误:检测到中文全角花括号。请使用英文半角双花括号,例如 {{employee.EmployeeName}}。");
|
||||
}
|
||||
|
||||
return codes.OrderBy(code => code).ToList();
|
||||
if (text.Contains("{{{", StringComparison.Ordinal)
|
||||
|| text.Contains("}}}", StringComparison.Ordinal))
|
||||
{
|
||||
throw Oops.Bah(
|
||||
"DOCX 占位符格式错误:检测到多余的花括号。占位符必须使用成对的英文半角双花括号,例如 {{employee.EmployeeName}}。");
|
||||
}
|
||||
|
||||
var searchStart = 0;
|
||||
while (searchStart < text.Length)
|
||||
{
|
||||
var openIndex = text.IndexOf("{{", searchStart, StringComparison.Ordinal);
|
||||
var closeIndex = text.IndexOf("}}", searchStart, StringComparison.Ordinal);
|
||||
|
||||
if (closeIndex >= 0 && (openIndex < 0 || closeIndex < openIndex))
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, closeIndex)} 缺少对应的起始符“{{{{”。正确格式示例:{{{{employee.EmployeeName}}}}。");
|
||||
}
|
||||
|
||||
if (openIndex < 0)
|
||||
return;
|
||||
|
||||
closeIndex = text.IndexOf("}}", openIndex + 2, StringComparison.Ordinal);
|
||||
if (closeIndex < 0)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, openIndex)} 缺少结束符“}}}}”。正确格式示例:{{{{employee.EmployeeName}}}}。");
|
||||
}
|
||||
|
||||
var placeholder = text.Substring(openIndex, closeIndex + 2 - openIndex);
|
||||
var match = PlaceholderRegex.Match(placeholder);
|
||||
if (!match.Success || match.Length != placeholder.Length)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"DOCX 占位符格式错误:“{placeholder}”。变量编码只能包含英文字母、数字、下划线、点和短横线,并且必须使用成对的英文半角双花括号。");
|
||||
}
|
||||
|
||||
searchStart = closeIndex + 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildPlaceholderPreview(string text, int markerIndex)
|
||||
{
|
||||
const int maxLength = 60;
|
||||
var preview = text[markerIndex..].Trim();
|
||||
if (preview.Length > maxLength)
|
||||
preview = preview[..maxLength] + "…";
|
||||
return $"“{preview}”";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -43,6 +124,7 @@ public static class DocxTemplateProcessor
|
||||
/// </summary>
|
||||
public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
ValidateDocxHeader(docx);
|
||||
using var input = new MemoryStream(docx);
|
||||
using var archive = new global::System.IO.Compression.ZipArchive(
|
||||
input,
|
||||
@@ -157,4 +239,26 @@ public static class DocxTemplateProcessor
|
||||
|| entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase))
|
||||
&& entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据文件头识别真实格式,避免只修改扩展名的旧版 DOC 在解压时变成系统异常。
|
||||
/// </summary>
|
||||
private static void ValidateDocxHeader(byte[] docx)
|
||||
{
|
||||
if (docx == null || docx.Length < 4)
|
||||
throw Oops.Bah("上传的 DOCX 文件为空或上传不完整,请重新选择文件上传");
|
||||
|
||||
// 旧版 Word 97-2003 二进制 DOC 使用 OLE Compound File 文件头:D0 CF 11 E0。
|
||||
if (docx[0] == 0xD0
|
||||
&& docx[1] == 0xCF
|
||||
&& docx[2] == 0x11
|
||||
&& docx[3] == 0xE0)
|
||||
{
|
||||
throw Oops.Bah("上传文件实际是旧版 DOC 格式,不能直接修改后缀名;请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
|
||||
}
|
||||
|
||||
// DOCX 是 Open XML ZIP 包,正常文件以 PK 开头。
|
||||
if (docx[0] != 0x50 || docx[1] != 0x4B)
|
||||
throw Oops.Bah("上传文件的真实格式不是 DOCX,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
|
||||
}
|
||||
}
|
||||
|
||||
+24
-2
@@ -46,15 +46,21 @@ public class OnboardingContractVersionAddInput
|
||||
public string FileHash { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 生效日期。
|
||||
/// 生效时间。
|
||||
/// </summary>
|
||||
public DateTime? EffectiveDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 失效日期。
|
||||
/// 失效时间。
|
||||
/// </summary>
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
|
||||
/// <summary>本版本是否需要公司盖章;旧客户端未传时沿用原规则。</summary>
|
||||
public bool? CompanySealEnabled { get; set; }
|
||||
|
||||
/// <summary>本版本是否需要骑缝章;旧客户端未传时沿用系统配置。</summary>
|
||||
public bool? AcrossPageSealEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
@@ -78,3 +84,19 @@ public class OnboardingContractVersionStatusInput
|
||||
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "版本状态不能为空")]
|
||||
public string VersionStatus { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改合同模板版本的盖章配置;未传的开关保持不变。
|
||||
/// </summary>
|
||||
public class OnboardingContractVersionSealInput
|
||||
{
|
||||
/// <summary>合同版本 Id。</summary>
|
||||
[IdNotNull(ErrorMessage = "版本Id不能为空")]
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>是否需要公司盖章。</summary>
|
||||
public bool? CompanySealEnabled { get; set; }
|
||||
|
||||
/// <summary>是否需要骑缝章。</summary>
|
||||
public bool? AcrossPageSealEnabled { get; set; }
|
||||
}
|
||||
|
||||
+5
@@ -50,6 +50,11 @@ public interface IOnboardingContractTemplateService : ITransient
|
||||
/// </summary>
|
||||
Task SetVersionStatus(OnboardingContractVersionStatusInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 修改合同模板版本的盖章配置,仅影响后续新建任务。
|
||||
/// </summary>
|
||||
Task SetVersionSeals(OnboardingContractVersionSealInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 删除未被入职模板或任务使用的合同版本。
|
||||
/// </summary>
|
||||
|
||||
+153
-18
@@ -6,10 +6,14 @@ namespace SimpleAdmin.Application;
|
||||
public class OnboardingContractTemplateService : DbRepository<ObContractTemplate>, IOnboardingContractTemplateService
|
||||
{
|
||||
private readonly IFileService _fileService;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingContractTemplateService(IFileService fileService)
|
||||
public OnboardingContractTemplateService(
|
||||
IFileService fileService,
|
||||
IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_fileService = fileService;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -36,7 +40,8 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
}
|
||||
|
||||
var page = await query
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "TemplateCode", "TemplateName", "ContractType"))
|
||||
.OrderByDescending(it => it.CreateTime)
|
||||
.OrderBy(it => it.TemplateName)
|
||||
.ToPagedListAsync(input.PageNum, input.PageSize);
|
||||
await PopulateScopeDisplay(page.List.ToList());
|
||||
@@ -74,7 +79,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
if (candidates.Count == 0)
|
||||
return candidates;
|
||||
|
||||
var now = DateTime.Now;
|
||||
var candidateIds = candidates.Select(item => item.Id).ToList();
|
||||
var availableVersionTemplateIds = (await Context.Queryable<ObContractTemplateVersion>()
|
||||
.Where(item => candidateIds.Contains(item.TemplateId))
|
||||
.ToListAsync())
|
||||
.Where(item => ContractTemplateVersionValidity.IsAvailable(item, now))
|
||||
.Select(item => item.TemplateId)
|
||||
.ToHashSet();
|
||||
candidates = candidates
|
||||
.Where(item => availableVersionTemplateIds.Contains(item.Id))
|
||||
.ToList();
|
||||
if (candidates.Count == 0)
|
||||
return candidates;
|
||||
|
||||
candidateIds = candidates.Select(item => item.Id).ToList();
|
||||
var relations = await Context.Queryable<ObContractTemplatePosition>()
|
||||
.Where(item => candidateIds.Contains(item.ContractTemplateId))
|
||||
.ToListAsync();
|
||||
@@ -121,10 +140,14 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
/// </summary>
|
||||
public async Task Edit(OnboardingContractTemplateEditInput input)
|
||||
{
|
||||
await Detail(new BaseIdInput { Id = input.Id });
|
||||
var template = await Detail(new BaseIdInput { Id = input.Id });
|
||||
await _dataPermission.EnsureCanManage(
|
||||
template.CreateUserId,
|
||||
OnboardingPermissionCodes.ContractManageAll,
|
||||
"合同模板");
|
||||
NormalizeInput(input);
|
||||
await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds);
|
||||
await ValidateReferencedOnboardingTemplates(input);
|
||||
await ValidateReferencedOnboardingTemplates(input, template.Status);
|
||||
await EnsureCodeUnique(input.TemplateCode, input.Id);
|
||||
|
||||
var entity = BuildEntity(input.Id, input);
|
||||
@@ -155,6 +178,16 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
if (input.Ids.Count == 0)
|
||||
return;
|
||||
|
||||
var templates = await Context.Queryable<ObContractTemplate>()
|
||||
.Where(item => input.Ids.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (templates.Count != input.Ids.Distinct().Count())
|
||||
throw Oops.Bah("合同模板不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
templates.Select(item => item.CreateUserId),
|
||||
OnboardingPermissionCodes.ContractManageAll,
|
||||
"合同模板");
|
||||
|
||||
var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplateContract>()
|
||||
.AnyAsync(it => input.Ids.Contains(it.ContractTemplateId));
|
||||
if (hasOnboardingTemplates)
|
||||
@@ -183,10 +216,6 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
await Context.Deleteable<ObContractScope>()
|
||||
.Where(it => input.Ids.Contains(it.TemplateId))
|
||||
.ExecuteCommandAsync();
|
||||
|
||||
await Context.Deleteable<ObContractTemplatePosition>()
|
||||
.Where(item => input.Ids.Contains(item.ContractTemplateId))
|
||||
.ExecuteCommandAsync();
|
||||
@@ -208,6 +237,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
{
|
||||
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
|
||||
?? throw Oops.Bah("合同版本不存在");
|
||||
await EnsureTemplateCanManage(version.TemplateId);
|
||||
|
||||
var isUsedInOnboardingTemplate = await Context.Queryable<ObOnboardingTemplateContract>()
|
||||
.AnyAsync(it => it.ContractTemplateVersionId == version.Id);
|
||||
@@ -225,9 +255,6 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
await Context.Deleteable<ObTemplateVariable>()
|
||||
.Where(it => it.TemplateVersionId == version.Id)
|
||||
.ExecuteCommandAsync();
|
||||
await Context.Deleteable<ObContractScope>()
|
||||
.Where(it => it.TemplateVersionId == version.Id)
|
||||
.ExecuteCommandAsync();
|
||||
await Context.Deleteable<ObContractTemplateVersion>()
|
||||
.In(version.Id)
|
||||
.ExecuteCommandAsync();
|
||||
@@ -245,11 +272,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
/// </summary>
|
||||
public async Task<SqlSugarPagedList<ObContractTemplateVersion>> VersionPage(OnboardingContractVersionPageInput input)
|
||||
{
|
||||
return await Context.Queryable<ObContractTemplateVersion>()
|
||||
var page = await Context.Queryable<ObContractTemplateVersion>()
|
||||
.Where(it => it.TemplateId == input.TemplateId)
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.VersionStatus), it => it.VersionStatus == input.VersionStatus)
|
||||
.OrderByDescending(it => it.CreateTime)
|
||||
.ToPagedListAsync(input.PageNum, input.PageSize);
|
||||
|
||||
// 仅解析返回值,不回写历史空值,使列表开关显示真实生效的规则。
|
||||
var setting = App.GetConfig<QiyuesuoSettingOptions>("QiyuesuoSetting", true)
|
||||
?? new QiyuesuoSettingOptions();
|
||||
foreach (var version in page.List)
|
||||
{
|
||||
version.CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(version.CompanySealEnabled);
|
||||
version.AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(version.AcrossPageSealEnabled, setting);
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -257,7 +294,12 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
/// </summary>
|
||||
public async Task AddVersion(OnboardingContractVersionAddInput input)
|
||||
{
|
||||
await Detail(new BaseIdInput { Id = input.TemplateId });
|
||||
ContractTemplateVersionValidity.ValidateRange(input.EffectiveDate, input.ExpiryDate);
|
||||
var template = await Detail(new BaseIdInput { Id = input.TemplateId });
|
||||
await _dataPermission.EnsureCanManage(
|
||||
template.CreateUserId,
|
||||
OnboardingPermissionCodes.ContractManageAll,
|
||||
"合同模板");
|
||||
var file = await Context.Queryable<SysFile>().InSingleAsync(input.SourceDocxFileId)
|
||||
?? throw Oops.Bah("DOCX 源文件不存在");
|
||||
if (!string.Equals(file.Suffix, "docx", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -336,9 +378,25 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
|
||||
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
|
||||
?? throw Oops.Bah("模板版本不存在");
|
||||
await EnsureTemplateCanManage(version.TemplateId);
|
||||
|
||||
if (version.VersionStatus == ContractTemplateVersionStatus.Enabled
|
||||
&& input.VersionStatus == ContractTemplateVersionStatus.Disabled)
|
||||
{
|
||||
var referencedNames = await GetEnabledOnboardingTemplateNames(
|
||||
version.TemplateId,
|
||||
version.Id);
|
||||
if (referencedNames.Count > 0)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"该签署材料版本正在被启用中的入职模板{FormatTemplateNames(referencedNames)}引用,不能停用,请先调整或停用这些入职模板");
|
||||
}
|
||||
}
|
||||
if (input.VersionStatus == ContractTemplateVersionStatus.Enabled)
|
||||
{
|
||||
ContractTemplateVersionValidity.ValidateRange(version.EffectiveDate, version.ExpiryDate);
|
||||
if (version.ExpiryDate.HasValue && version.ExpiryDate.Value <= DateTime.Now)
|
||||
throw Oops.Bah("该签署材料版本已经失效,不能启用");
|
||||
var hasUnresolvedVariables = await Context.Queryable<ObTemplateVariable>()
|
||||
.AnyAsync(item => item.TemplateVersionId == input.Id
|
||||
&& item.VariableType == ContractVariableType.Unresolved);
|
||||
@@ -347,7 +405,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
}
|
||||
|
||||
version.VersionStatus = input.VersionStatus;
|
||||
await Context.Updateable(version).ExecuteCommandAsync();
|
||||
await Context.Updateable(version)
|
||||
.UpdateColumns(item => new { item.VersionStatus, item.UpdateTime, item.UpdateUserId, item.UpdateUser })
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改版本盖章开关;任务和电子签约已经保存独立快照,不随版本变更。
|
||||
/// </summary>
|
||||
public async Task SetVersionSeals(OnboardingContractVersionSealInput input)
|
||||
{
|
||||
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
|
||||
?? throw Oops.Bah("模板版本不存在");
|
||||
await EnsureTemplateCanManage(version.TemplateId);
|
||||
|
||||
await ContractTemplateVersionSealConfiguration.BuildUpdate(Context, input).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -373,6 +445,11 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
if (!string.Equals(variable.VariableCode, input.VariableCode, StringComparison.Ordinal))
|
||||
throw Oops.Bah("DOCX 占位符编码由系统识别,不能修改");
|
||||
|
||||
var version = await Context.Queryable<ObContractTemplateVersion>()
|
||||
.InSingleAsync(variable.TemplateVersionId)
|
||||
?? throw Oops.Bah("模板版本不存在");
|
||||
await EnsureTemplateCanManage(version.TemplateId);
|
||||
|
||||
var isUsedInTask = await Context.Queryable<ObTaskContract>()
|
||||
.AnyAsync(it => it.TemplateVersionId == input.TemplateVersionId);
|
||||
if (isUsedInTask)
|
||||
@@ -383,6 +460,16 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
await Context.Updateable(entity).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
private async Task EnsureTemplateCanManage(long templateId)
|
||||
{
|
||||
var template = await Context.Queryable<ObContractTemplate>().InSingleAsync(templateId)
|
||||
?? throw Oops.Bah("合同模板不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
template.CreateUserId,
|
||||
OnboardingPermissionCodes.ContractManageAll,
|
||||
"合同模板");
|
||||
}
|
||||
|
||||
private static ObTemplateVariable BuildDetectedVariable(long versionId, string code, ObVariableDefinition def, int index)
|
||||
{
|
||||
var separatorIndex = code.IndexOf('.');
|
||||
@@ -449,13 +536,12 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
/// </summary>
|
||||
private static void NormalizeInput(OnboardingContractTemplateAddInput input)
|
||||
{
|
||||
input.TemplateCode = input.TemplateCode?.Trim();
|
||||
input.TemplateCode = OnboardingCodeValidator.Normalize(input.TemplateCode, "签署材料模板编码", 50);
|
||||
input.TemplateName = input.TemplateName?.Trim();
|
||||
input.ContractType = input.ContractType?.Trim();
|
||||
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.TemplateCode)
|
||||
|| string.IsNullOrWhiteSpace(input.TemplateName)
|
||||
if (string.IsNullOrWhiteSpace(input.TemplateName)
|
||||
|| string.IsNullOrWhiteSpace(input.ContractType))
|
||||
{
|
||||
throw Oops.Bah("请填写模板编码、模板名称和合同类型");
|
||||
@@ -500,7 +586,8 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
/// 防止修改合同模板归属后破坏已经保存的入职模板配置。
|
||||
/// </summary>
|
||||
private async Task ValidateReferencedOnboardingTemplates(
|
||||
OnboardingContractTemplateEditInput input)
|
||||
OnboardingContractTemplateEditInput input,
|
||||
string currentStatus)
|
||||
{
|
||||
var relations = await Context.Queryable<ObOnboardingTemplateContract>()
|
||||
.Where(item => item.ContractTemplateId == input.Id)
|
||||
@@ -515,6 +602,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
var onboardingTemplates = await Context.Queryable<ObOnboardingTemplate>()
|
||||
.Where(item => onboardingTemplateIds.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (string.Equals(currentStatus, "enable", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(input.Status, "enable", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var enabledTemplateNames = onboardingTemplates
|
||||
.Where(item => !item.IsDelete && item.Status == "enable")
|
||||
.Select(item => item.TemplateName)
|
||||
.Distinct()
|
||||
.OrderBy(item => item)
|
||||
.ToList();
|
||||
if (enabledTemplateNames.Count > 0)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"该签署材料模板正在被启用中的入职模板{FormatTemplateNames(enabledTemplateNames)}引用,不能停用,请先调整或停用这些入职模板");
|
||||
}
|
||||
}
|
||||
var positionRelations = await Context.Queryable<ObOnboardingTemplatePosition>()
|
||||
.Where(item => onboardingTemplateIds.Contains(item.OnboardingTemplateId))
|
||||
.ToListAsync();
|
||||
@@ -539,6 +641,39 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<string>> GetEnabledOnboardingTemplateNames(
|
||||
long contractTemplateId,
|
||||
long? contractTemplateVersionId = null)
|
||||
{
|
||||
var relations = await Context.Queryable<ObOnboardingTemplateContract>()
|
||||
.Where(item => item.ContractTemplateId == contractTemplateId)
|
||||
.WhereIF(contractTemplateVersionId.HasValue,
|
||||
item => item.ContractTemplateVersionId == contractTemplateVersionId.Value)
|
||||
.ToListAsync();
|
||||
if (relations.Count == 0)
|
||||
return new List<string>();
|
||||
|
||||
var onboardingTemplateIds = relations
|
||||
.Select(item => item.OnboardingTemplateId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var names = await Context.Queryable<ObOnboardingTemplate>()
|
||||
.Where(item => onboardingTemplateIds.Contains(item.Id)
|
||||
&& !item.IsDelete
|
||||
&& item.Status == "enable")
|
||||
.Select(item => item.TemplateName)
|
||||
.ToListAsync();
|
||||
return names
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Distinct()
|
||||
.OrderBy(item => item)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string FormatTemplateNames(IEnumerable<string> templateNames)
|
||||
{
|
||||
return string.Join("、", templateNames.Select(item => $"“{item}”"));
|
||||
}
|
||||
/// <summary>
|
||||
/// 构建合同模板主档案实体。
|
||||
/// </summary>
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 校验任务合同变量的必填状态和员工动态变量数据类型。
|
||||
/// </summary>
|
||||
public interface IOnboardingContractVariableValidationService : ITransient
|
||||
{
|
||||
/// <summary>
|
||||
/// 校验指定任务的全部合同变量快照。
|
||||
/// </summary>
|
||||
Task Validate(long taskId);
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 统一校验任务合同变量,确保最终提交的数据可以安全用于合同生成。
|
||||
/// </summary>
|
||||
public class OnboardingContractVariableValidationService
|
||||
: IOnboardingContractVariableValidationService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
|
||||
public OnboardingContractVariableValidationService()
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Validate(long taskId)
|
||||
{
|
||||
var contracts = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == taskId)
|
||||
.ToListAsync();
|
||||
if (contracts.Count == 0)
|
||||
return;
|
||||
|
||||
var contractIds = contracts
|
||||
.Select(item => item.Id)
|
||||
.ToList();
|
||||
var templateVersionIds = contracts
|
||||
.Select(item => item.TemplateVersionId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var variables = await _db.Queryable<ObTaskContractVariable>()
|
||||
.Where(item => contractIds.Contains(item.TaskContractId))
|
||||
.ToListAsync();
|
||||
var definitions = await _db.Queryable<ObTemplateVariable>()
|
||||
.Where(item => templateVersionIds.Contains(item.TemplateVersionId))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var contract in contracts)
|
||||
{
|
||||
var contractDefinitions = definitions
|
||||
.Where(item => item.TemplateVersionId == contract.TemplateVersionId)
|
||||
.OrderBy(item => item.SortCode);
|
||||
|
||||
foreach (var definition in contractDefinitions)
|
||||
{
|
||||
var variable = variables.FirstOrDefault(item =>
|
||||
item.TaskContractId == contract.Id
|
||||
&& item.VariableCode == definition.VariableCode);
|
||||
var value = variable?.VariableValue?.Trim();
|
||||
|
||||
if (definition.Required && string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"合同“{contract.ContractName}”的必填变量“{definition.VariableName}”尚未填写");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value)
|
||||
|| definition.VariableType != ContractVariableType.Employee)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ValidateDataType(contract.ContractName, definition, value);
|
||||
ValidateOptions(contract.ContractName, definition, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDataType(
|
||||
string contractName,
|
||||
ObTemplateVariable definition,
|
||||
string value)
|
||||
{
|
||||
var dataType = definition.DataType?.Trim().ToLowerInvariant() ?? "string";
|
||||
switch (dataType)
|
||||
{
|
||||
case "string":
|
||||
return;
|
||||
case "number":
|
||||
var numberIsValid = decimal.TryParse(
|
||||
value,
|
||||
global::System.Globalization.NumberStyles.Number,
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
out _);
|
||||
if (!numberIsValid)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”必须填写数字");
|
||||
}
|
||||
|
||||
return;
|
||||
case "date":
|
||||
var dateIsValid = DateTime.TryParseExact(
|
||||
value,
|
||||
"yyyy-MM-dd",
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
global::System.Globalization.DateTimeStyles.None,
|
||||
out _);
|
||||
if (!dateIsValid)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”必须填写正确日期");
|
||||
}
|
||||
|
||||
return;
|
||||
default:
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”数据类型配置不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateOptions(
|
||||
string contractName,
|
||||
ObTemplateVariable definition,
|
||||
string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(definition.OptionsJson))
|
||||
return;
|
||||
|
||||
var options = ParseOptions(contractName, definition);
|
||||
if (options.Contains(value))
|
||||
return;
|
||||
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”必须从配置选项中选择");
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseOptions(
|
||||
string contractName,
|
||||
ObTemplateVariable definition)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = global::System.Text.Json.JsonDocument.Parse(
|
||||
definition.OptionsJson);
|
||||
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Array)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
var options = document.RootElement
|
||||
.EnumerateArray()
|
||||
.Select(GetOptionValue)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (options.Count == 0)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
return options;
|
||||
}
|
||||
catch (global::System.Text.Json.JsonException)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"合同“{contractName}”的变量“{definition.VariableName}”可选项配置不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetOptionValue(
|
||||
global::System.Text.Json.JsonElement option)
|
||||
{
|
||||
if (option.ValueKind == global::System.Text.Json.JsonValueKind.String)
|
||||
return option.GetString()?.Trim();
|
||||
|
||||
if (option.ValueKind == global::System.Text.Json.JsonValueKind.Object)
|
||||
{
|
||||
if (option.TryGetProperty("value", out var value))
|
||||
return GetJsonText(value);
|
||||
if (option.TryGetProperty("label", out var label))
|
||||
return GetJsonText(label);
|
||||
}
|
||||
|
||||
return GetJsonText(option);
|
||||
}
|
||||
|
||||
private static string GetJsonText(
|
||||
global::System.Text.Json.JsonElement value)
|
||||
{
|
||||
return value.ValueKind switch
|
||||
{
|
||||
global::System.Text.Json.JsonValueKind.String => value.GetString()?.Trim(),
|
||||
global::System.Text.Json.JsonValueKind.Number => value.ToString(),
|
||||
global::System.Text.Json.JsonValueKind.True => bool.TrueString,
|
||||
global::System.Text.Json.JsonValueKind.False => bool.FalseString,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -1,4 +1,4 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务部门分页查询参数。
|
||||
@@ -41,13 +41,21 @@ public class OnboardingDepartmentAddInput : ObDepartment
|
||||
/// 部门业务编码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门编码不能为空")]
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门编码不能超过50个字符")]
|
||||
public override string DepartmentCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 部门名称。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "部门名称不能为空")]
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "部门名称不能超过50个字符")]
|
||||
public override string DepartmentName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")]
|
||||
public override string Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+26
-6
@@ -1,10 +1,17 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 实现入职业务部门业务能力。
|
||||
/// </summary>
|
||||
public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardingDepartmentService
|
||||
{
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingDepartmentService(IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询入职业务部门。
|
||||
/// </summary>
|
||||
@@ -15,7 +22,7 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentCode), it => it.DepartmentCode.Contains(input.DepartmentCode))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.DepartmentName), it => it.DepartmentName.Contains(input.DepartmentName))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentCode", "DepartmentName", "SortCode"))
|
||||
.OrderBy(it => it.SortCode)
|
||||
.OrderBy(it => it.DepartmentName)
|
||||
.ToPagedListAsync(input.PageNum, input.PageSize);
|
||||
@@ -78,6 +85,10 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
|
||||
{
|
||||
NormalizeInput(input);
|
||||
var department = await Detail(new BaseIdInput { Id = input.Id });
|
||||
await _dataPermission.EnsureCanManage(
|
||||
department.CreateUserId,
|
||||
OnboardingPermissionCodes.SetupManageAll,
|
||||
"部门");
|
||||
await EnsureCompanyCanChange(department, input.CompanyId);
|
||||
await EnsureCompanyExists(input.CompanyId);
|
||||
await EnsureCodeUnique(input.CompanyId, input.DepartmentCode, input.Id);
|
||||
@@ -92,6 +103,16 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
|
||||
if (input.Ids.Count == 0)
|
||||
return;
|
||||
|
||||
var departments = await Context.Queryable<ObDepartment>()
|
||||
.Where(item => input.Ids.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (departments.Count != input.Ids.Distinct().Count())
|
||||
throw Oops.Bah("部门不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
departments.Select(item => item.CreateUserId),
|
||||
OnboardingPermissionCodes.SetupManageAll,
|
||||
"部门");
|
||||
|
||||
var hasPositions = await Context.Queryable<ObPosition>()
|
||||
.AnyAsync(it => it.DepartmentId.HasValue && input.Ids.Contains(it.DepartmentId.Value));
|
||||
if (hasPositions)
|
||||
@@ -133,12 +154,11 @@ public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardi
|
||||
/// </summary>
|
||||
private static void NormalizeInput(OnboardingDepartmentAddInput input)
|
||||
{
|
||||
input.DepartmentCode = input.DepartmentCode?.Trim();
|
||||
input.DepartmentCode = OnboardingCodeValidator.Normalize(input.DepartmentCode, "部门编码", 50);
|
||||
input.DepartmentName = input.DepartmentName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input.DepartmentCode)
|
||||
|| string.IsNullOrWhiteSpace(input.DepartmentName))
|
||||
if (string.IsNullOrWhiteSpace(input.DepartmentName))
|
||||
{
|
||||
throw Oops.Bah("请填写部门编码和部门名称");
|
||||
throw Oops.Bah("请填写部门名称");
|
||||
}
|
||||
|
||||
if (input.Status != "enable" && input.Status != "disable")
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 对指定入职任务执行电子签操作的参数。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignTaskInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 入职任务 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "入职任务Id不能为空")]
|
||||
public long TaskId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管理员查看一份员工已签合同的参数。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignPreviewInput : OnboardingElectronicSignTaskInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 本地电子签文档 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "电子签文档Id不能为空")]
|
||||
public long SignDocumentId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 作废入职电子签流程的参数。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignInvalidInput : OnboardingElectronicSignTaskInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 作废原因。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "请填写作废原因")]
|
||||
public string Reason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重新发起入职电子签流程的参数。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignReinitiateInput : OnboardingElectronicSignTaskInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 管理员确认重新发起的原因,随旧流程保留用于审计。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "请填写重新发起原因")]
|
||||
public string Reason { get; set; }
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 管理端查看的入职电子签流程详情。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignDetailOutput
|
||||
{
|
||||
/// <summary>入职任务 Id。</summary>
|
||||
public long TaskId { get; set; }
|
||||
|
||||
/// <summary>入职任务编号。</summary>
|
||||
public string TaskNo { get; set; }
|
||||
|
||||
/// <summary>当前是否允许发起或重试电子签。</summary>
|
||||
public bool CanInitiate { get; set; }
|
||||
|
||||
/// <summary>不允许发起时的原因。</summary>
|
||||
public string CannotInitiateReason { get; set; }
|
||||
|
||||
/// <summary>当前流程是否允许作为一次新签约重新发起。</summary>
|
||||
public bool CanReinitiate { get; set; }
|
||||
|
||||
/// <summary>不允许重新发起时的原因。</summary>
|
||||
public string CannotReinitiateReason { get; set; }
|
||||
|
||||
/// <summary>是否已经存在电子签流程。</summary>
|
||||
public bool HasProcess { get; set; }
|
||||
|
||||
/// <summary>本地电子签流程 Id。</summary>
|
||||
public long? ProcessId { get; set; }
|
||||
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ProviderContractId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>电子签流程标题。</summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>本系统电子签状态。</summary>
|
||||
public string SignStatus { get; set; }
|
||||
|
||||
/// <summary>契约锁原始状态。</summary>
|
||||
public string ProviderStatus { get; set; }
|
||||
|
||||
/// <summary>员工姓名。</summary>
|
||||
public string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>员工手机号。</summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>员工签署页面地址。</summary>
|
||||
public string SignUrl { get; set; }
|
||||
|
||||
/// <summary>员工签署页面到期时间。</summary>
|
||||
public DateTime? SignUrlExpireAt { get; set; }
|
||||
|
||||
/// <summary>员工签署页面当前是否有效。</summary>
|
||||
public bool SignUrlValid { get; set; }
|
||||
|
||||
/// <summary>发起时间。</summary>
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
/// <summary>发送时间。</summary>
|
||||
public DateTime? SentAt { get; set; }
|
||||
|
||||
/// <summary>签署完成时间。</summary>
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
|
||||
/// <summary>归档完成时间。</summary>
|
||||
public DateTime? ArchivedAt { get; set; }
|
||||
|
||||
/// <summary>完整归档 ZIP 的系统文件 Id。</summary>
|
||||
public long? ArchiveFileId { get; set; }
|
||||
|
||||
|
||||
/// <summary>最近一次失败原因。</summary>
|
||||
public string LastError { get; set; }
|
||||
|
||||
/// <summary>签约流程包含的文档。</summary>
|
||||
public List<OnboardingElectronicSignDocumentOutput> Documents { get; set; } = new();
|
||||
|
||||
/// <summary>该入职任务已经结束的历史电子签流程。</summary>
|
||||
public List<OnboardingElectronicSignHistoryOutput> HistoryProcesses { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁员工已签文件只读预览结果。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignPreviewOutput
|
||||
{
|
||||
/// <summary>本地电子签文档 Id。</summary>
|
||||
public long SignDocumentId { get; set; }
|
||||
|
||||
/// <summary>契约锁生成的只读浏览页面地址。</summary>
|
||||
public string PreviewUrl { get; set; }
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 已被新流程替代的历史电子签流程摘要。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignHistoryOutput
|
||||
{
|
||||
/// <summary>本地电子签流程 Id。</summary>
|
||||
public long ProcessId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ProviderContractId { get; set; }
|
||||
|
||||
/// <summary>本系统电子签状态。</summary>
|
||||
public string SignStatus { get; set; }
|
||||
|
||||
/// <summary>契约锁原始状态。</summary>
|
||||
public string ProviderStatus { get; set; }
|
||||
|
||||
/// <summary>员工姓名快照。</summary>
|
||||
public string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>员工手机号快照。</summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>被新流程替代的时间。</summary>
|
||||
public DateTime? ReissuedAt { get; set; }
|
||||
|
||||
/// <summary>管理员填写的重新发起原因。</summary>
|
||||
public string ReissueReason { get; set; }
|
||||
|
||||
/// <summary>流程发起时间。</summary>
|
||||
public DateTime? StartedAt { get; set; }
|
||||
|
||||
/// <summary>流程结束或失败原因。</summary>
|
||||
public string LastError { get; set; }
|
||||
}
|
||||
/// <summary>
|
||||
/// 电子签流程中的单份合同文档。
|
||||
/// </summary>
|
||||
public class OnboardingElectronicSignDocumentOutput
|
||||
{
|
||||
/// <summary>本地电子签文档 Id。</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>任务合同 Id。</summary>
|
||||
public long TaskContractId { get; set; }
|
||||
|
||||
/// <summary>契约锁文档 Id。</summary>
|
||||
public string ProviderDocumentId { get; set; }
|
||||
|
||||
/// <summary>文档名称。</summary>
|
||||
public string DocumentName { get; set; }
|
||||
|
||||
/// <summary>文档顺序。</summary>
|
||||
public int DocumentSort { get; set; }
|
||||
|
||||
/// <summary>发起签约的原始 DOCX 文件 Id。</summary>
|
||||
public long OriginalDocxFileId { get; set; }
|
||||
|
||||
/// <summary>签署完成后的 PDF 文件 Id。</summary>
|
||||
public long? SignedPdfFileId { get; set; }
|
||||
|
||||
/// <summary>当前管理员流程中是否已生成过员工签后预览。</summary>
|
||||
public bool EmployeeSignedPreviewed { get; set; }
|
||||
|
||||
/// <summary>本份文件是否需要公司盖章。</summary>
|
||||
public bool CompanySealEnabled { get; set; }
|
||||
|
||||
/// <summary>本份文件是否需要骑缝章。</summary>
|
||||
public bool AcrossPageSealEnabled { get; set; }
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 定义入职电子签流程管理能力。
|
||||
/// </summary>
|
||||
public interface IOnboardingElectronicSignService : ITransient
|
||||
{
|
||||
/// <summary>查询指定入职任务的电子签详情。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> Detail(BaseIdInput input);
|
||||
|
||||
/// <summary>创建或重试电子签流程。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> Initiate(
|
||||
OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>保留旧流程并创建一次全新的电子签流程。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> Reinitiate(
|
||||
OnboardingElectronicSignReinitiateInput input);
|
||||
|
||||
/// <summary>主动同步契约锁状态并在完成后归档。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> Sync(
|
||||
OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>获取或刷新员工签署地址。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> GetSignLink(
|
||||
OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>获取一份员工已签合同的只读浏览页面。</summary>
|
||||
Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
|
||||
OnboardingElectronicSignPreviewInput input);
|
||||
|
||||
/// <summary>确认全部文件已核对后,由后台执行企业盖章。</summary>
|
||||
Task<OnboardingElectronicSignDetailOutput> CompanySeal(
|
||||
OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>催办当前待签员工。</summary>
|
||||
Task Notice(OnboardingElectronicSignTaskInput input);
|
||||
|
||||
/// <summary>作废尚未结束的签约流程。</summary>
|
||||
Task Invalid(OnboardingElectronicSignInvalidInput input);
|
||||
|
||||
/// <summary>处理契约锁服务器明文回调。</summary>
|
||||
Task HandleCallback(string payloadJson);
|
||||
}
|
||||
+1686
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>同步第三方状态时的单向迁移保护;未知/陈旧结果不能覆盖已确认的签署进度。</summary>
|
||||
internal static class OnboardingSignStateGuard
|
||||
{
|
||||
internal static string Resolve(string current, string incoming)
|
||||
{
|
||||
if (current == ElectronicSignStatus.Archived && incoming == ElectronicSignStatus.Completed)
|
||||
return ElectronicSignStatus.Archived;
|
||||
if (IsTerminal(current) && current != incoming)
|
||||
throw Oops.Bah("契约锁返回状态与本地终态不一致,已保留本地状态,请核实后重试");
|
||||
if (current == ElectronicSignStatus.PendingCompanySeal
|
||||
&& incoming is not (ElectronicSignStatus.PendingCompanySeal
|
||||
or ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
|
||||
or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired))
|
||||
throw Oops.Bah("契约锁状态暂未同步,已保留员工签署结果,请稍后重试");
|
||||
return incoming;
|
||||
}
|
||||
|
||||
private static bool IsTerminal(string status)
|
||||
=> status is ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
|
||||
or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired;
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 定义契约锁开放平台所需的最小调用能力。
|
||||
/// </summary>
|
||||
public interface IQiyuesuoClient : ITransient
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建包含个人签署方和企业人工盖章动作的合同草稿。
|
||||
/// </summary>
|
||||
Task<QiyuesuoContractResult> CreateDraft(
|
||||
QiyuesuoCreateDraftInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 向合同草稿中上传一份 DOCX 文件。
|
||||
/// </summary>
|
||||
Task<QiyuesuoDocumentResult> AddDocument(
|
||||
QiyuesuoAddDocumentInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 发起合同并为每份文档指定员工签名与企业盖章位置。
|
||||
/// </summary>
|
||||
Task<QiyuesuoContractResult> Send(
|
||||
QiyuesuoSendContractInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 获取员工签署页面地址。
|
||||
/// </summary>
|
||||
Task<string> GetSignPageUrl(
|
||||
QiyuesuoSignPageInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前合同中指定文档的只读浏览页面地址。
|
||||
/// </summary>
|
||||
Task<string> GetViewPageUrl(
|
||||
QiyuesuoViewPageInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 查询契约锁合同详情。
|
||||
/// </summary>
|
||||
Task<QiyuesuoContractResult> GetContractDetail(string contractId);
|
||||
|
||||
/// <summary>
|
||||
/// 催办当前待签署人。
|
||||
/// </summary>
|
||||
Task Notice(string contractId);
|
||||
|
||||
/// <summary>
|
||||
/// 由后台管理员确认后执行企业盖章节点。
|
||||
/// </summary>
|
||||
Task CompanySign(QiyuesuoCompanySignInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 作废尚未完成的合同流程。
|
||||
/// </summary>
|
||||
Task Invalid(string contractId, string sealId, string reason);
|
||||
|
||||
/// <summary>
|
||||
/// 下载单份签署完成的 PDF 文档。
|
||||
/// </summary>
|
||||
Task<byte[]> DownloadDocument(string documentId);
|
||||
|
||||
/// <summary>
|
||||
/// 下载合同原文、签署文件与签署日志组成的完整归档 ZIP。
|
||||
/// </summary>
|
||||
Task<byte[]> DownloadArchive(string contractId);
|
||||
}
|
||||
+667
@@ -0,0 +1,667 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 直接调用契约锁开放平台,不依赖旧版 .NET Framework SDK。
|
||||
/// </summary>
|
||||
public class QiyuesuoClient : IQiyuesuoClient
|
||||
{
|
||||
private const string SignatureHmacSha256 = "HMACSHA256";
|
||||
private const string AccessTokenHeader = "x-qys-open-accesstoken";
|
||||
private const string TimestampHeader = "x-qys-open-timestamp";
|
||||
private const string NonceHeader = "x-qys-open-nonce";
|
||||
private const string SignatureHeader = "x-qys-open-signature";
|
||||
private const string SignatureVersionHeader = "x-qys-open-signature-version";
|
||||
private const string RequestBodySignatureKey = "x-qys-open-body";
|
||||
private static readonly HttpClient HttpClient = new()
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
|
||||
private readonly QiyuesuoSettingOptions _setting;
|
||||
|
||||
public QiyuesuoClient()
|
||||
{
|
||||
_setting = App.GetConfig<QiyuesuoSettingOptions>(
|
||||
"QiyuesuoSetting",
|
||||
true)
|
||||
?? new QiyuesuoSettingOptions();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QiyuesuoContractResult> CreateDraft(
|
||||
QiyuesuoCreateDraftInput input)
|
||||
{
|
||||
return await PostJson<QiyuesuoContractResult>(
|
||||
"/v2/contract/draft",
|
||||
QiyuesuoContractRequestBuilder.BuildDraft(input));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QiyuesuoDocumentResult> AddDocument(
|
||||
QiyuesuoAddDocumentInput input)
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["contractId"] = input.ContractId,
|
||||
["bizId"] = input.BizId,
|
||||
["title"] = input.Title,
|
||||
["fileSuffix"] = "docx",
|
||||
["documentSort"] = input.DocumentSort.ToString(
|
||||
CultureInfo.InvariantCulture)
|
||||
};
|
||||
return await PostFile<QiyuesuoDocumentResult>(
|
||||
"/v2/document/addbyfile",
|
||||
fields,
|
||||
input.FileBytes,
|
||||
input.FileName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<QiyuesuoContractResult> Send(
|
||||
QiyuesuoSendContractInput input)
|
||||
{
|
||||
return PostJson<QiyuesuoContractResult>(
|
||||
"/v2/contract/send",
|
||||
new
|
||||
{
|
||||
contractId = input.ContractId,
|
||||
bizId = input.BizId,
|
||||
tenantName = input.CompanyName,
|
||||
// 入职合同的签署位置由系统统一预设,禁止签署人自行增加位置,
|
||||
// 避免同一份材料出现“预设签名 + 手动新增签名”的重复签名。
|
||||
stamperRule = "MUST_FORBID",
|
||||
locateAllStamperKeywords = false,
|
||||
stampers = input.Stampers
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> GetSignPageUrl(
|
||||
QiyuesuoSignPageInput input)
|
||||
{
|
||||
var result = await PostJsonElement(
|
||||
"/v2/contract/pageurl",
|
||||
new
|
||||
{
|
||||
contractId = input.ContractId,
|
||||
user = new
|
||||
{
|
||||
contact = input.EmployeeMobile,
|
||||
contactType = "MOBILE"
|
||||
},
|
||||
callbackPage = NullIfBlank(input.CallbackPage)
|
||||
});
|
||||
|
||||
if (result.ValueKind == JsonValueKind.String)
|
||||
return result.GetString();
|
||||
if (TryGetString(result, "url", out var url)
|
||||
|| TryGetString(result, "pageUrl", out url)
|
||||
|| TryGetString(result, "signUrl", out url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
throw Oops.Bah("契约锁未返回员工签署页面地址");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> GetViewPageUrl(
|
||||
QiyuesuoViewPageInput input)
|
||||
{
|
||||
var result = await GetJsonElement(
|
||||
"/v2/contract/viewurl",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["contractId"] = input.ContractId,
|
||||
["documentId"] = input.DocumentId,
|
||||
["tenantName"] = input.CompanyName,
|
||||
["downloadButton"] = "false",
|
||||
["printButton"] = "false"
|
||||
});
|
||||
|
||||
if (result.ValueKind == JsonValueKind.String)
|
||||
return result.GetString();
|
||||
if (TryGetString(result, "url", out var url)
|
||||
|| TryGetString(result, "viewUrl", out url)
|
||||
|| TryGetString(result, "pageUrl", out url))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
throw Oops.Bah("契约锁未返回合同浏览页面地址");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<QiyuesuoContractResult> GetContractDetail(string contractId)
|
||||
{
|
||||
return GetJson<QiyuesuoContractResult>(
|
||||
"/v2/contract/detail",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["contractId"] = contractId,
|
||||
["queryActionOperate"] = "true",
|
||||
["queryActionOperator"] = "true",
|
||||
["queryLocation"] = "true"
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Notice(string contractId)
|
||||
{
|
||||
await PostJsonElement(
|
||||
"/v2/contract/notice",
|
||||
new
|
||||
{
|
||||
contractId,
|
||||
onlySigning = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CompanySign(QiyuesuoCompanySignInput input)
|
||||
{
|
||||
await PostJsonElement(
|
||||
"/v2/contract/companysign",
|
||||
new
|
||||
{
|
||||
contractId = input.ContractId,
|
||||
tenantName = input.CompanyName,
|
||||
sealId = input.SealId
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Invalid(string contractId, string sealId, string reason)
|
||||
{
|
||||
await PostJsonElement(
|
||||
"/v2/contract/invalid",
|
||||
new
|
||||
{
|
||||
contractId,
|
||||
sealId,
|
||||
reason,
|
||||
deleteDoc = false,
|
||||
autoSign = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> DownloadDocument(string documentId)
|
||||
{
|
||||
return Download(
|
||||
"/v2/document/download",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["documentId"] = documentId
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]> DownloadArchive(string contractId)
|
||||
{
|
||||
return Download(
|
||||
"/v2/contract/download",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["contractId"] = contractId,
|
||||
["downloadItems"] = "CONTRACT,SIGNLOG",
|
||||
["needCompressForOneFile"] = "true"
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<T> PostJson<T>(string path, object body)
|
||||
{
|
||||
var result = await PostJsonElement(path, body);
|
||||
return result.Deserialize<T>(JsonOptions)
|
||||
?? throw Oops.Bah("契约锁返回数据为空");
|
||||
}
|
||||
|
||||
private async Task<JsonElement> PostJsonElement(
|
||||
string path,
|
||||
object body)
|
||||
{
|
||||
ValidateSettings();
|
||||
var json = JsonSerializer.Serialize(body, JsonOptions);
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
BuildUrl(path));
|
||||
request.Content = new StringContent(
|
||||
json,
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
AddAuthorizationHeaders(
|
||||
request,
|
||||
path,
|
||||
HttpMethod.Post,
|
||||
jsonBody: json);
|
||||
return await SendForJsonResult(request);
|
||||
}
|
||||
|
||||
private async Task<T> PostFile<T>(
|
||||
string path,
|
||||
Dictionary<string, string> fields,
|
||||
byte[] fileBytes,
|
||||
string fileName)
|
||||
{
|
||||
ValidateSettings();
|
||||
using var content = new MultipartFormDataContent();
|
||||
foreach (var field in fields)
|
||||
{
|
||||
content.Add(
|
||||
new StringContent(field.Value ?? string.Empty, Encoding.UTF8),
|
||||
field.Key);
|
||||
}
|
||||
|
||||
var fileContent = new ByteArrayContent(fileBytes);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
content.Add(fileContent, "file", fileName);
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
BuildUrl(path))
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
AddAuthorizationHeaders(
|
||||
request,
|
||||
path,
|
||||
HttpMethod.Post,
|
||||
fields,
|
||||
fileBytes);
|
||||
var result = await SendForJsonResult(request);
|
||||
return result.Deserialize<T>(JsonOptions)
|
||||
?? throw Oops.Bah("契约锁返回数据为空");
|
||||
}
|
||||
|
||||
private async Task<T> GetJson<T>(
|
||||
string path,
|
||||
Dictionary<string, string> query)
|
||||
{
|
||||
var result = await GetJsonElement(path, query);
|
||||
return result.Deserialize<T>(JsonOptions)
|
||||
?? throw Oops.Bah("契约锁返回数据为空");
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetJsonElement(
|
||||
string path,
|
||||
Dictionary<string, string> query)
|
||||
{
|
||||
ValidateSettings();
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
BuildUrl(path, query));
|
||||
AddAuthorizationHeaders(
|
||||
request,
|
||||
path,
|
||||
HttpMethod.Get,
|
||||
query);
|
||||
return await SendForJsonResult(request);
|
||||
}
|
||||
|
||||
private async Task<byte[]> Download(
|
||||
string path,
|
||||
Dictionary<string, string> query)
|
||||
{
|
||||
ValidateSettings();
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
BuildUrl(path, query));
|
||||
AddAuthorizationHeaders(
|
||||
request,
|
||||
path,
|
||||
HttpMethod.Get,
|
||||
query);
|
||||
using var response = await Send(request);
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync();
|
||||
if (!response.IsSuccessStatusCode || LooksLikeJson(response, bytes))
|
||||
{
|
||||
ThrowProviderError(bytes, response.StatusCode);
|
||||
}
|
||||
|
||||
if (bytes.Length == 0)
|
||||
throw Oops.Bah("契约锁下载文件内容为空");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private async Task<JsonElement> SendForJsonResult(
|
||||
HttpRequestMessage request)
|
||||
{
|
||||
using var response = await Send(request);
|
||||
var responseText = await response.Content.ReadAsStringAsync();
|
||||
if (string.IsNullOrWhiteSpace(responseText))
|
||||
throw Oops.Bah("契约锁返回内容为空");
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
var root = document.RootElement;
|
||||
var hasCode = root.TryGetProperty("code", out var codeElement);
|
||||
var code = hasCode ? ReadCode(codeElement) : 0;
|
||||
var hasResponseCode = TryGetString(
|
||||
root,
|
||||
"responseCode",
|
||||
out var responseCode);
|
||||
var responseCodeSucceeded = !hasResponseCode
|
||||
|| responseCode == "0"
|
||||
|| responseCode == "00000000";
|
||||
if (!response.IsSuccessStatusCode
|
||||
|| (hasCode && code != 0)
|
||||
|| !responseCodeSucceeded)
|
||||
{
|
||||
var hasMessage = TryGetString(
|
||||
root,
|
||||
"message",
|
||||
out var providerMessage)
|
||||
|| TryGetString(
|
||||
root,
|
||||
"responseMessage",
|
||||
out providerMessage);
|
||||
var message = hasMessage
|
||||
? providerMessage
|
||||
: $"HTTP {(int)response.StatusCode},响应码 {responseCode ?? code.ToString()}";
|
||||
var providerCode = hasResponseCode
|
||||
? responseCode
|
||||
: hasCode
|
||||
? code.ToString(CultureInfo.InvariantCulture)
|
||||
: ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
throw Oops.Bah($"契约锁请求失败:{message}(响应码:{providerCode})");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("result", out var result))
|
||||
return root.Clone();
|
||||
return result.Clone();
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> Send(
|
||||
HttpRequestMessage request)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(
|
||||
TimeSpan.FromSeconds(Math.Max(10, _setting.TimeoutSeconds)));
|
||||
try
|
||||
{
|
||||
return await HttpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw Oops.Bah("契约锁请求超时,请稍后重试");
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw Oops.Bah($"无法连接契约锁:{exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddAuthorizationHeaders(
|
||||
HttpRequestMessage request,
|
||||
string path,
|
||||
HttpMethod method,
|
||||
Dictionary<string, string> parameters = null,
|
||||
byte[] fileBytes = null,
|
||||
string jsonBody = null)
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow
|
||||
.ToUnixTimeMilliseconds()
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
var nonce = Guid.NewGuid().ToString();
|
||||
var signature = string.Equals(
|
||||
_setting.SignatureVersion,
|
||||
SignatureHmacSha256,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
? BuildHmacSha256Signature(
|
||||
path,
|
||||
method,
|
||||
timestamp,
|
||||
nonce,
|
||||
parameters,
|
||||
fileBytes,
|
||||
jsonBody)
|
||||
: BuildMd5Signature(timestamp, nonce);
|
||||
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
AccessTokenHeader,
|
||||
_setting.AppToken.Trim());
|
||||
request.Headers.TryAddWithoutValidation(TimestampHeader, timestamp);
|
||||
request.Headers.TryAddWithoutValidation(NonceHeader, nonce);
|
||||
request.Headers.TryAddWithoutValidation(SignatureHeader, signature);
|
||||
request.Headers.TryAddWithoutValidation("version", "OnboardX-1.0");
|
||||
if (!string.IsNullOrWhiteSpace(_setting.SignatureVersion))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
SignatureVersionHeader,
|
||||
_setting.SignatureVersion.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildMd5Signature(string timestamp, string nonce)
|
||||
{
|
||||
var source = _setting.AppToken.Trim()
|
||||
+ _setting.AppSecret.Trim()
|
||||
+ timestamp
|
||||
+ nonce;
|
||||
return Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(source)))
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string BuildHmacSha256Signature(
|
||||
string path,
|
||||
HttpMethod method,
|
||||
string timestamp,
|
||||
string nonce,
|
||||
Dictionary<string, string> parameters,
|
||||
byte[] fileBytes,
|
||||
string jsonBody)
|
||||
{
|
||||
var values = new List<KeyValuePair<string, string>>
|
||||
{
|
||||
new(AccessTokenHeader, _setting.AppToken.Trim()),
|
||||
new(TimestampHeader, timestamp),
|
||||
new(NonceHeader, nonce),
|
||||
new(SignatureVersionHeader, SignatureHmacSha256)
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(jsonBody))
|
||||
values.Add(new KeyValuePair<string, string>(
|
||||
RequestBodySignatureKey,
|
||||
jsonBody));
|
||||
if (parameters != null)
|
||||
{
|
||||
values.AddRange(parameters.Where(item =>
|
||||
!string.IsNullOrWhiteSpace(item.Key)
|
||||
&& !string.IsNullOrWhiteSpace(item.Value)));
|
||||
}
|
||||
if (fileBytes != null)
|
||||
{
|
||||
values.Add(new KeyValuePair<string, string>(
|
||||
"file",
|
||||
Convert.ToHexString(MD5.HashData(fileBytes))
|
||||
.ToLowerInvariant()));
|
||||
}
|
||||
|
||||
var builder = new StringBuilder()
|
||||
.Append("requestMethod=")
|
||||
.Append(method.Method)
|
||||
.Append("&requestUrl=")
|
||||
.Append(path);
|
||||
foreach (var value in values
|
||||
.OrderBy(item => item.Key, StringComparer.Ordinal))
|
||||
{
|
||||
builder.Append('&')
|
||||
.Append(value.Key.Trim())
|
||||
.Append('=')
|
||||
.Append(value.Value.Trim());
|
||||
}
|
||||
|
||||
using var hmac = new HMACSHA256(
|
||||
Encoding.UTF8.GetBytes(_setting.AppSecret.Trim()));
|
||||
return Convert.ToHexString(
|
||||
hmac.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString())))
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private string BuildUrl(
|
||||
string path,
|
||||
Dictionary<string, string> query = null)
|
||||
{
|
||||
var url = $"{_setting.ServerUrl.Trim().TrimEnd('/')}/{path.TrimStart('/')}";
|
||||
if (query == null || query.Count == 0)
|
||||
return url;
|
||||
var queryString = string.Join("&", query.Select(item =>
|
||||
Uri.EscapeDataString(item.Key)
|
||||
+ "="
|
||||
+ Uri.EscapeDataString(item.Value ?? string.Empty)));
|
||||
return url + "?" + queryString;
|
||||
}
|
||||
|
||||
private void ValidateSettings()
|
||||
{
|
||||
if (!_setting.Enabled)
|
||||
throw Oops.Bah("契约锁电子签功能尚未启用");
|
||||
if (string.IsNullOrWhiteSpace(_setting.ServerUrl))
|
||||
throw Oops.Bah("请配置契约锁 ServerUrl");
|
||||
if (string.IsNullOrWhiteSpace(_setting.AppToken))
|
||||
throw Oops.Bah("请配置契约锁 AppToken");
|
||||
if (string.IsNullOrWhiteSpace(_setting.AppSecret))
|
||||
throw Oops.Bah("请配置契约锁 AppSecret");
|
||||
}
|
||||
|
||||
private static int ReadCode(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Number
|
||||
&& element.TryGetInt32(out var numericCode))
|
||||
{
|
||||
return numericCode;
|
||||
}
|
||||
return int.TryParse(element.ToString(), out var textCode)
|
||||
? textCode
|
||||
: -1;
|
||||
}
|
||||
|
||||
private static bool TryGetString(
|
||||
JsonElement element,
|
||||
string propertyName,
|
||||
out string value)
|
||||
{
|
||||
value = null;
|
||||
if (element.ValueKind != JsonValueKind.Object
|
||||
|| !element.TryGetProperty(propertyName, out var property)
|
||||
|| property.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: property.ToString();
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
|
||||
private static bool LooksLikeJson(
|
||||
HttpResponseMessage response,
|
||||
byte[] bytes)
|
||||
{
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (!string.IsNullOrWhiteSpace(contentType)
|
||||
&& contentType.Contains("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var first = bytes.FirstOrDefault(item =>
|
||||
!char.IsWhiteSpace((char)item));
|
||||
return first == '{' || first == '[';
|
||||
}
|
||||
|
||||
private static void ThrowProviderError(
|
||||
byte[] bytes,
|
||||
global::System.Net.HttpStatusCode statusCode)
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(bytes);
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(text);
|
||||
var root = document.RootElement;
|
||||
var hasMessage = TryGetString(
|
||||
root,
|
||||
"message",
|
||||
out var providerMessage)
|
||||
|| TryGetString(
|
||||
root,
|
||||
"responseMessage",
|
||||
out providerMessage);
|
||||
var message = hasMessage ? providerMessage : text;
|
||||
throw Oops.Bah($"契约锁下载失败:{message}");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"契约锁下载失败:HTTP {(int)statusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NullIfBlank(string value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造兼容契约锁长整型 Id 和字符串 Id 两种返回形式的 JSON 配置。
|
||||
/// </summary>
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
options.Converters.Add(new FlexibleStringJsonConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将第三方响应中的字符串或数字统一读取为字符串,避免长整型 Id 精度丢失。
|
||||
/// </summary>
|
||||
private sealed class FlexibleStringJsonConverter : JsonConverter<string>
|
||||
{
|
||||
public override string Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
return reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
JsonTokenType.Number => GetRawNumber(ref reader),
|
||||
JsonTokenType.True => bool.TrueString,
|
||||
JsonTokenType.False => bool.FalseString,
|
||||
_ => reader.GetString()
|
||||
};
|
||||
}
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
string value,
|
||||
JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStringValue(value);
|
||||
}
|
||||
|
||||
private static string GetRawNumber(ref Utf8JsonReader reader)
|
||||
{
|
||||
if (reader.TryGetInt64(out var integer))
|
||||
return integer.ToString(CultureInfo.InvariantCulture);
|
||||
return reader.GetDecimal().ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 创建契约锁合同草稿所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoCreateDraftInput
|
||||
{
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>合同流程标题。</summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>签署企业法定名称。</summary>
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>员工姓名。</summary>
|
||||
public string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>员工手机号。</summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>契约锁业务分类 Id。</summary>
|
||||
public string CategoryId { get; set; }
|
||||
|
||||
/// <summary>契约锁企业印章 Id。</summary>
|
||||
public string SealId { get; set; }
|
||||
|
||||
/// <summary>契约锁企业成员姓名。</summary>
|
||||
public string CreatorName { get; set; }
|
||||
|
||||
/// <summary>契约锁企业成员手机号。</summary>
|
||||
public string CreatorMobile { get; set; }
|
||||
|
||||
/// <summary>契约锁服务器回调地址。</summary>
|
||||
public string CallbackUrl { get; set; }
|
||||
|
||||
/// <summary>是否需要企业签章节点;仅员工签字的流程不创建该节点。</summary>
|
||||
public bool RequiresCompanySign { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传契约锁合同文档所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoAddDocumentInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>文档标题。</summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>文档排序。</summary>
|
||||
public int DocumentSort { get; set; }
|
||||
|
||||
/// <summary>上传文件名。</summary>
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>DOCX 文件内容。</summary>
|
||||
public byte[] FileBytes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁签章位置。
|
||||
/// </summary>
|
||||
public class QiyuesuoStamperInput
|
||||
{
|
||||
/// <summary>企业签署动作 Id。</summary>
|
||||
public string ActionId { get; set; }
|
||||
|
||||
/// <summary>个人签署方 Id。</summary>
|
||||
public string SignatoryId { get; set; }
|
||||
|
||||
/// <summary>签章类型。</summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>契约锁文档 Id。</summary>
|
||||
public string DocumentId { get; set; }
|
||||
|
||||
/// <summary>骑缝章覆盖的契约锁文档 Id,多个 Id 使用英文逗号分隔。</summary>
|
||||
public string DocumentIds { get; set; }
|
||||
|
||||
/// <summary>骑缝章位置指定方式。</summary>
|
||||
public string AcrossType { get; set; }
|
||||
|
||||
/// <summary>企业印章 Id。</summary>
|
||||
public string SealId { get; set; }
|
||||
|
||||
/// <summary>签署位置关键字。</summary>
|
||||
public string Keyword { get; set; }
|
||||
|
||||
/// <summary>同名关键字的序号,从 1 开始。</summary>
|
||||
public int? KeywordIndex { get; set; }
|
||||
|
||||
/// <summary>相对关键字的横向偏移量;契约锁要求必传,不偏移时为 0。</summary>
|
||||
public decimal OffsetX { get; set; }
|
||||
|
||||
/// <summary>相对关键字的纵向偏移量;契约锁要求必传,不偏移时为 0。</summary>
|
||||
public decimal OffsetY { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发起契约锁合同流程所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoSendContractInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>契约锁合同发起企业名称。</summary>
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>所有文档的签名和盖章位置。</summary>
|
||||
public List<QiyuesuoStamperInput> Stampers { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取契约锁员工签署页所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoSignPageInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>员工手机号。</summary>
|
||||
public string EmployeeMobile { get; set; }
|
||||
|
||||
/// <summary>签署完成后的前端返回地址。</summary>
|
||||
public string CallbackPage { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取契约锁合同文档只读浏览页所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoViewPageInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>契约锁合同文档 Id。</summary>
|
||||
public string DocumentId { get; set; }
|
||||
|
||||
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
|
||||
public string CompanyName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业人工盖章所需参数。
|
||||
/// </summary>
|
||||
public class QiyuesuoCompanySignInput
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
|
||||
public string CompanyName { get; set; }
|
||||
|
||||
/// <summary>当前流程使用的企业印章 Id 快照。</summary>
|
||||
public string SealId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同返回对象。
|
||||
/// </summary>
|
||||
public class QiyuesuoContractResult
|
||||
{
|
||||
/// <summary>契约锁合同流程 Id。</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>兼容部分接口返回的合同流程 Id 字段名。</summary>
|
||||
public string ContractId { get; set; }
|
||||
|
||||
/// <summary>本系统幂等业务编号。</summary>
|
||||
public string BizId { get; set; }
|
||||
|
||||
/// <summary>流程标题。</summary>
|
||||
public string Subject { get; set; }
|
||||
|
||||
/// <summary>契约锁合同状态。</summary>
|
||||
public string Status { get; set; }
|
||||
|
||||
/// <summary>签署方列表。</summary>
|
||||
public List<QiyuesuoSignatoryResult> Signatories { get; set; } = new();
|
||||
|
||||
/// <summary>合同文档列表。</summary>
|
||||
public List<QiyuesuoDocumentResult> Documents { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁签署方返回对象。
|
||||
/// </summary>
|
||||
public class QiyuesuoSignatoryResult
|
||||
{
|
||||
/// <summary>签署方 Id。</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>兼容部分接口返回的签署方 Id 字段名。</summary>
|
||||
public string SignatoryId { get; set; }
|
||||
|
||||
/// <summary>签署方类型。</summary>
|
||||
public string TenantType { get; set; }
|
||||
|
||||
/// <summary>签署方名称。</summary>
|
||||
public string TenantName { get; set; }
|
||||
|
||||
/// <summary>签署方状态。</summary>
|
||||
public string Status { get; set; }
|
||||
|
||||
/// <summary>签署顺序。</summary>
|
||||
public int? SerialNo { get; set; }
|
||||
|
||||
/// <summary>企业签署动作。</summary>
|
||||
public List<QiyuesuoActionResult> Actions { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁企业签署动作返回对象。
|
||||
/// </summary>
|
||||
public class QiyuesuoActionResult
|
||||
{
|
||||
/// <summary>签署动作 Id。</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>兼容部分接口返回的签署动作 Id 字段名。</summary>
|
||||
public string ActionId { get; set; }
|
||||
|
||||
/// <summary>签署动作类型。</summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>签署动作状态。</summary>
|
||||
public string Status { get; set; }
|
||||
|
||||
/// <summary>动作执行顺序。</summary>
|
||||
public int? SerialNo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁合同文档返回对象。
|
||||
/// </summary>
|
||||
public class QiyuesuoDocumentResult
|
||||
{
|
||||
/// <summary>契约锁文档 Id。</summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>兼容部分接口返回的文档 Id 字段名。</summary>
|
||||
public string DocumentId { get; set; }
|
||||
|
||||
/// <summary>文档标题。</summary>
|
||||
public string Title { get; set; }
|
||||
|
||||
/// <summary>文档页数。</summary>
|
||||
public int? PageCount { get; set; }
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>发起签约前按文件冻结的签字、盖章要求。</summary>
|
||||
internal sealed record OnboardingSigningDocument(
|
||||
string DocumentId,
|
||||
string EmployeeSignKeyword,
|
||||
string CompanySealKeyword,
|
||||
bool? CompanySealEnabled,
|
||||
bool? AcrossPageSealEnabled);
|
||||
|
||||
/// <summary>构造契约锁请求;无数据库和网络访问,员工签字始终覆盖全部文件。</summary>
|
||||
internal static class QiyuesuoContractRequestBuilder
|
||||
{
|
||||
internal static bool CompanySealEnabled(bool? enabled) => enabled ?? true;
|
||||
|
||||
internal static bool AcrossPageSealEnabled(bool? enabled, QiyuesuoSettingOptions setting) =>
|
||||
enabled ?? setting.EnableAcrossPageSeal;
|
||||
|
||||
internal static bool RequiresCompanySign(
|
||||
bool? companySealEnabled,
|
||||
bool? acrossPageSealEnabled,
|
||||
QiyuesuoSettingOptions setting) =>
|
||||
CompanySealEnabled(companySealEnabled) || AcrossPageSealEnabled(acrossPageSealEnabled, setting);
|
||||
|
||||
internal static object BuildDraft(QiyuesuoCreateDraftInput input)
|
||||
{
|
||||
long sealId = 0;
|
||||
if (input.RequiresCompanySign && !long.TryParse(input.SealId, out sealId))
|
||||
throw Oops.Bah("契约锁 SealId 配置格式不正确");
|
||||
|
||||
var signatories = new List<object>
|
||||
{
|
||||
new
|
||||
{
|
||||
tenantType = "PERSONAL",
|
||||
tenantName = input.EmployeeName,
|
||||
receiver = new
|
||||
{
|
||||
name = input.EmployeeName,
|
||||
contact = input.EmployeeMobile,
|
||||
contactType = "MOBILE"
|
||||
},
|
||||
serialNo = 1,
|
||||
signatoryNo = "employee",
|
||||
actions = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "PERSONAL",
|
||||
name = "员工本人签署",
|
||||
serialNo = 0,
|
||||
actionNo = "employee_sign"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if (input.RequiresCompanySign)
|
||||
{
|
||||
signatories.Add(new
|
||||
{
|
||||
tenantType = "COMPANY",
|
||||
tenantName = input.CompanyName,
|
||||
receiver = new
|
||||
{
|
||||
name = input.CreatorName,
|
||||
contact = input.CreatorMobile,
|
||||
contactType = "MOBILE"
|
||||
},
|
||||
serialNo = 2,
|
||||
signatoryNo = "company",
|
||||
actions = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "COMPANY",
|
||||
name = "企业人工盖章",
|
||||
serialNo = 0,
|
||||
autoSign = false,
|
||||
sealId = input.SealId,
|
||||
corpSealIds = new[] { sealId },
|
||||
actionNo = "company_seal"
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
bizId = input.BizId,
|
||||
subject = input.Subject,
|
||||
tenantName = input.CompanyName,
|
||||
description = "OnboardX 入职电子签",
|
||||
send = false,
|
||||
callbackUrl = string.IsNullOrWhiteSpace(input.CallbackUrl) ? null : input.CallbackUrl.Trim(),
|
||||
category = new { id = input.CategoryId },
|
||||
creator = new
|
||||
{
|
||||
name = input.CreatorName,
|
||||
contact = input.CreatorMobile,
|
||||
contactType = "MOBILE"
|
||||
},
|
||||
signatories
|
||||
};
|
||||
}
|
||||
|
||||
internal static List<QiyuesuoStamperInput> BuildStampers(
|
||||
IReadOnlyList<OnboardingSigningDocument> documents,
|
||||
string personalSignatoryId,
|
||||
string companyActionId,
|
||||
string sealId,
|
||||
QiyuesuoSettingOptions setting)
|
||||
{
|
||||
if (documents.Count == 0)
|
||||
throw Oops.Bah("当前电子签流程没有可签署的文件");
|
||||
if (documents.Any(item => string.IsNullOrWhiteSpace(item.DocumentId)))
|
||||
throw Oops.Bah("契约锁未返回签署文档 Id");
|
||||
if (string.IsNullOrWhiteSpace(personalSignatoryId))
|
||||
throw Oops.Bah("契约锁未返回员工签署方 Id");
|
||||
if (documents.Any(item => RequiresCompanySign(item.CompanySealEnabled, item.AcrossPageSealEnabled, setting))
|
||||
&& string.IsNullOrWhiteSpace(companyActionId))
|
||||
throw Oops.Bah("契约锁草稿未返回企业盖章动作");
|
||||
|
||||
var stampers = new List<QiyuesuoStamperInput>();
|
||||
foreach (var document in documents)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.EmployeeSignKeyword))
|
||||
throw Oops.Bah("请配置员工签名定位关键字");
|
||||
stampers.Add(new QiyuesuoStamperInput
|
||||
{
|
||||
SignatoryId = personalSignatoryId,
|
||||
Type = "PERSONAL",
|
||||
DocumentId = document.DocumentId,
|
||||
Keyword = document.EmployeeSignKeyword,
|
||||
KeywordIndex = Math.Max(1, setting.EmployeeSignKeywordIndex)
|
||||
});
|
||||
if (CompanySealEnabled(document.CompanySealEnabled))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.CompanySealKeyword))
|
||||
throw Oops.Bah("需要公司盖章的文件未配置企业盖章定位关键字");
|
||||
stampers.Add(new QiyuesuoStamperInput
|
||||
{
|
||||
ActionId = companyActionId,
|
||||
Type = "COMPANY",
|
||||
DocumentId = document.DocumentId,
|
||||
SealId = sealId,
|
||||
Keyword = document.CompanySealKeyword,
|
||||
KeywordIndex = Math.Max(1, setting.CompanySealKeywordIndex),
|
||||
OffsetX = setting.CompanySealOffsetX,
|
||||
OffsetY = setting.CompanySealOffsetY
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 合并骑缝章也只能包含已开启该开关的文件,不能把只需个人签字的材料混入。
|
||||
var acrossDocuments = documents
|
||||
.Where(item => AcrossPageSealEnabled(item.AcrossPageSealEnabled, setting))
|
||||
.Select(item => item.DocumentId)
|
||||
.ToList();
|
||||
if (acrossDocuments.Count == 0)
|
||||
return stampers;
|
||||
var acrossType = setting.AcrossPageSealType?.Trim().ToUpperInvariant();
|
||||
if (acrossType is not ("ALL_DOCUMENT" or "SINGLE_DOCUMENT"))
|
||||
throw Oops.Bah("契约锁骑缝章模式只能配置为 ALL_DOCUMENT 或 SINGLE_DOCUMENT");
|
||||
if (acrossType == "ALL_DOCUMENT")
|
||||
{
|
||||
stampers.Add(new QiyuesuoStamperInput
|
||||
{
|
||||
ActionId = companyActionId,
|
||||
Type = "ACROSS_PAGE",
|
||||
DocumentId = acrossDocuments[0],
|
||||
DocumentIds = string.Join(",", acrossDocuments),
|
||||
SealId = sealId,
|
||||
AcrossType = acrossType
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var documentId in acrossDocuments)
|
||||
{
|
||||
stampers.Add(new QiyuesuoStamperInput
|
||||
{
|
||||
ActionId = companyActionId,
|
||||
Type = "ACROSS_PAGE",
|
||||
DocumentId = documentId,
|
||||
SealId = sealId,
|
||||
AcrossType = acrossType
|
||||
});
|
||||
}
|
||||
}
|
||||
return stampers;
|
||||
}
|
||||
|
||||
internal static string ResolvePendingStatus(QiyuesuoContractResult contract)
|
||||
{
|
||||
var employeeSigned = contract.Signatories.Any(item =>
|
||||
string.Equals(item.TenantType, "PERSONAL", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(item.Status, "SIGNED", StringComparison.OrdinalIgnoreCase));
|
||||
var hasCompanyAction = contract.Signatories.Any(item =>
|
||||
string.Equals(item.TenantType, "COMPANY", StringComparison.OrdinalIgnoreCase)
|
||||
&& item.Actions.Any(action => string.Equals(action.Type, "COMPANY", StringComparison.OrdinalIgnoreCase)));
|
||||
// 纯员工签字流程等待平台 COMPLETE 回执,不制造不存在的企业盖章阶段。
|
||||
return employeeSigned && hasCompanyAction
|
||||
? ElectronicSignStatus.PendingCompanySeal
|
||||
: ElectronicSignStatus.PendingEmployeeSign;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
@@ -29,6 +31,136 @@ public class OnboardingEmployeePageInput : BasePageInput
|
||||
/// 岗位 Id。
|
||||
/// </summary>
|
||||
public long? PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用工类型。
|
||||
/// </summary>
|
||||
public string EmploymentType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 转正状态。
|
||||
/// </summary>
|
||||
public string RegularizationStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 社保状态。
|
||||
/// </summary>
|
||||
public string SocialInsuranceStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 合同即将到期筛选(30天内到期或已到期)。
|
||||
/// </summary>
|
||||
public bool? ContractExpiringSoon { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 正式员工档案分页数据及当前查询条件下的维度统计。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeePageOutput : SqlSugarPagedList<ObEmployee>
|
||||
{
|
||||
/// <summary>
|
||||
/// 不受快捷维度筛选影响的员工维度统计。
|
||||
/// </summary>
|
||||
public OnboardingEmployeeStatisticsOutput Statistics { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 正式员工档案维度统计。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeStatisticsOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 在册总数。
|
||||
/// </summary>
|
||||
public int Total { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 正式员工数量。
|
||||
/// </summary>
|
||||
public int FullTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 兼职员工数量。
|
||||
/// </summary>
|
||||
public int PartTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 试用期待转正数量。
|
||||
/// </summary>
|
||||
public int Probation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 社保已缴纳数量。
|
||||
/// </summary>
|
||||
public int SocialPurchased { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 合同即将到期数量(30天内到期或已到期)。
|
||||
/// </summary>
|
||||
public int ExpiringContract { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑员工人事信息。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeHrUpdateInput
|
||||
{
|
||||
[IdNotNull(ErrorMessage = "员工Id不能为空")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "请选择用工类型")]
|
||||
public string EmploymentType { get; set; }
|
||||
|
||||
public string ContractNature { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "请选择社保状态")]
|
||||
public string SocialInsuranceStatus { get; set; }
|
||||
|
||||
public string SocialInsuranceCompany { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 员工转正办理参数。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeRegularizeInput
|
||||
{
|
||||
[IdNotNull(ErrorMessage = "员工Id不能为空")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "请选择转正日期")]
|
||||
public DateTime? RegularizationDate { get; set; }
|
||||
|
||||
public string Remark { get; set; }
|
||||
|
||||
public List<long> FileIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销员工转正参数。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeRegularizationRevokeInput
|
||||
{
|
||||
[IdNotNull(ErrorMessage = "员工Id不能为空")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "请填写撤销原因")]
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 正式员工档案导出结果。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeExportFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Excel 文件字节。
|
||||
/// </summary>
|
||||
public byte[] FileBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 下载文件名。
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,8 +188,31 @@ public class OnboardingEmployeeDetailOutput
|
||||
/// </summary>
|
||||
public List<ObTaskContract> Contracts { get; set; } = new List<ObTaskContract>();
|
||||
|
||||
/// <summary>
|
||||
/// 审核建档时冻结的员工合同业务快照,包含常用字段及全部自定义变量。
|
||||
/// </summary>
|
||||
public List<ObEmployeeContractSnapshot> ContractSnapshots { get; set; }
|
||||
= new List<ObEmployeeContractSnapshot>();
|
||||
|
||||
/// <summary>
|
||||
/// 员工入职时上传的身份证、学历证明、体检报告等资料。
|
||||
/// </summary>
|
||||
public List<OnboardingTaskMaterialDetailOutput> Materials { get; set; }
|
||||
= new List<OnboardingTaskMaterialDetailOutput>();
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁签署文件与签署日志组成的完整归档 ZIP 文件 Id。
|
||||
/// </summary>
|
||||
public long? ElectronicSignArchiveFileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提交与审核记录列表。
|
||||
/// </summary>
|
||||
public List<ObTaskAudit> Audits { get; set; } = new List<ObTaskAudit>();
|
||||
|
||||
/// <summary>
|
||||
/// 员工转正及人事维护历史。
|
||||
/// </summary>
|
||||
public List<ObEmployeeChangeRecord> ChangeRecords { get; set; }
|
||||
= new List<ObEmployeeChangeRecord>();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 定义正式员工档案查询业务能力。
|
||||
/// 定义员工档案查询和人事维护业务能力。
|
||||
/// </summary>
|
||||
public interface IOnboardingEmployeeService : ITransient
|
||||
{
|
||||
/// <summary>
|
||||
/// 分页查询审核通过后生成的正式员工档案。
|
||||
/// 分页查询审核通过后生成的正式员工档案及维度统计。
|
||||
/// </summary>
|
||||
Task<SqlSugarPagedList<ObEmployee>> Page(OnboardingEmployeePageInput input);
|
||||
Task<OnboardingEmployeePageOutput> Page(OnboardingEmployeePageInput input);
|
||||
/// <summary>
|
||||
/// 按当前列表筛选条件导出员工明细。
|
||||
/// </summary>
|
||||
Task<OnboardingEmployeeExportFile> Export(OnboardingEmployeePageInput input);
|
||||
/// <summary>
|
||||
/// 编辑员工用工、社保及合同性质等人事信息。
|
||||
/// </summary>
|
||||
Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input);
|
||||
/// <summary>
|
||||
/// 人工办理员工转正。
|
||||
/// </summary>
|
||||
Task Regularize(OnboardingEmployeeRegularizeInput input);
|
||||
/// <summary>
|
||||
/// 撤销一次员工转正,历史记录和附件继续保留。
|
||||
/// </summary>
|
||||
Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input);
|
||||
/// <summary>
|
||||
/// 查询正式员工及其来源任务、合同和审核记录。
|
||||
/// </summary>
|
||||
|
||||
+1337
-18
File diff suppressed because it is too large
Load Diff
+10
@@ -0,0 +1,10 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 定义正式员工合同快照归档能力。
|
||||
/// </summary>
|
||||
public interface IOnboardingEmployeeContractSnapshotService : ITransient
|
||||
{
|
||||
/// <summary>冻结一名员工来源任务中的全部合同和变量。</summary>
|
||||
Task Build(long employeeId, long taskId);
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 在正式员工建档时,将每份任务合同及其全部变量冻结为长期业务快照。
|
||||
/// </summary>
|
||||
public class OnboardingEmployeeContractSnapshotService : IOnboardingEmployeeContractSnapshotService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
|
||||
public OnboardingEmployeeContractSnapshotService()
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task Build(long employeeId, long taskId)
|
||||
{
|
||||
var contracts = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == taskId)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync();
|
||||
if (contracts.Count == 0)
|
||||
throw Oops.Bah("入职任务没有合同,无法生成员工合同快照");
|
||||
|
||||
var contractIds = contracts.Select(item => item.Id).ToList();
|
||||
var allVariables = await _db.Queryable<ObTaskContractVariable>()
|
||||
.Where(item => contractIds.Contains(item.TaskContractId))
|
||||
.OrderBy(item => item.SortCode)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync();
|
||||
var now = DateTime.Now;
|
||||
var snapshots = new List<ObEmployeeContractSnapshot>();
|
||||
|
||||
foreach (var contract in contracts)
|
||||
{
|
||||
var variables = allVariables
|
||||
.Where(item => item.TaskContractId == contract.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.ThenBy(item => item.Id)
|
||||
.ToList();
|
||||
var valueMap = variables
|
||||
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Last().VariableValue,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var variableSnapshot = variables
|
||||
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group =>
|
||||
{
|
||||
// 历史任务若意外存在重复变量,以排序后的最后一条最终值为准,避免建档时报 500。
|
||||
var item = group.Last();
|
||||
return (object)new
|
||||
{
|
||||
variableName = item.VariableName,
|
||||
variableType = item.VariableType,
|
||||
dataType = item.DataType,
|
||||
variableValue = item.VariableValue,
|
||||
sourceType = item.SourceType,
|
||||
isManualOverride = item.IsManualOverride,
|
||||
sortCode = item.SortCode
|
||||
};
|
||||
},
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
snapshots.Add(new ObEmployeeContractSnapshot
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
EmployeeId = employeeId,
|
||||
TaskId = taskId,
|
||||
TaskContractId = contract.Id,
|
||||
TemplateId = contract.TemplateId,
|
||||
TemplateVersionId = contract.TemplateVersionId,
|
||||
ContractName = contract.ContractName,
|
||||
ContractType = contract.ContractType,
|
||||
ContractStartDate = ParseDate(GetValue(valueMap, "hr.ContractStartDate")),
|
||||
ContractEndDate = ParseDate(GetValue(valueMap, "hr.ContractEndDate")),
|
||||
ContractTerm = GetValue(valueMap, "hr.ContractTerm"),
|
||||
ProbationStartDate = ParseDate(GetValue(valueMap, "hr.ProbationStartDate")),
|
||||
ProbationEndDate = ParseDate(GetValue(valueMap, "hr.ProbationEndDate")),
|
||||
ProbationTerm = GetValue(valueMap, "hr.ProbationTerm"),
|
||||
ProbationSalary = ParseAmount(GetValue(valueMap, "hr.ProbationSalary")),
|
||||
BaseSalary = ParseAmount(GetValue(valueMap, "hr.BaseSalary")),
|
||||
PositionSalary = ParseAmount(GetValue(valueMap, "hr.PositionSalary")),
|
||||
PerformanceAllowance = ParseAmount(GetValue(valueMap, "hr.PerformanceAllowance")),
|
||||
AttendanceBonus = ParseAmount(GetValue(valueMap, "hr.AttendanceBonus")),
|
||||
RegularSalary = ParseAmount(GetValue(valueMap, "hr.RegularSalary")),
|
||||
WorkCity = GetValue(valueMap, "hr.WorkCity"),
|
||||
PositionName = GetValue(valueMap, "hr.PositionName"),
|
||||
EmployeeNo = GetValue(valueMap, "hr.EmployeeNo"),
|
||||
VariablesJson = global::System.Text.Json.JsonSerializer.Serialize(variableSnapshot),
|
||||
SnapshotVersion = 1,
|
||||
SnapshotAt = now,
|
||||
Status = CommonStatusConst.ENABLE,
|
||||
CreateTime = now,
|
||||
CreateUserId = UserManager.UserId,
|
||||
CreateUser = UserManager.Name,
|
||||
IsDelete = false
|
||||
});
|
||||
}
|
||||
|
||||
// Build 只在审核建档事务内执行;先删后建使异常重试保持幂等。
|
||||
await _db.Deleteable<ObEmployeeContractSnapshot>()
|
||||
.Where(item => item.TaskId == taskId)
|
||||
.ExecuteCommandAsync();
|
||||
await _db.Insertable(snapshots).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
private static string GetValue(
|
||||
IReadOnlyDictionary<string, string> valueMap,
|
||||
string variableCode)
|
||||
{
|
||||
return valueMap.TryGetValue(variableCode, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value.Trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static DateTime? ParseDate(string value)
|
||||
{
|
||||
return DateTime.TryParse(value, out var date) ? date.Date : null;
|
||||
}
|
||||
|
||||
private static decimal? ParseAmount(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var normalized = value
|
||||
.Replace("¥", string.Empty)
|
||||
.Replace("¥", string.Empty)
|
||||
.Replace("元", string.Empty)
|
||||
.Trim();
|
||||
if (decimal.TryParse(
|
||||
normalized,
|
||||
NumberStyles.Number | NumberStyles.AllowCurrencySymbol,
|
||||
CultureInfo.GetCultureInfo("zh-CN"),
|
||||
out var amount)
|
||||
|| decimal.TryParse(
|
||||
normalized,
|
||||
NumberStyles.Number,
|
||||
CultureInfo.InvariantCulture,
|
||||
out amount))
|
||||
{
|
||||
return amount;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+79
-18
@@ -6,10 +6,12 @@ namespace SimpleAdmin.Application;
|
||||
public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingEntryConfigService()
|
||||
public OnboardingEntryConfigService(IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -27,6 +29,11 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
/// </summary>
|
||||
public async Task SaveFormField(OnboardingFormFieldSaveInput input)
|
||||
{
|
||||
input.FieldCode = OnboardingCodeValidator.Normalize(input.FieldCode, "字段编码", 100);
|
||||
input.FieldName = input.FieldName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input.FieldName))
|
||||
throw Oops.Bah("字段名称不能为空");
|
||||
|
||||
var fieldCodeQuery = _db.Queryable<ObFormField>()
|
||||
.Where(x => x.FieldCode == input.FieldCode);
|
||||
if (input.Id.HasValue)
|
||||
@@ -50,10 +57,15 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
|
||||
if (input.Id.HasValue)
|
||||
{
|
||||
var exists = await _db.Queryable<ObFormField>().AnyAsync(x => x.Id == input.Id.Value);
|
||||
if (!exists)
|
||||
throw Oops.Bah("字段不存在");
|
||||
|
||||
var exists = await _db.Queryable<ObFormField>().InSingleAsync(input.Id.Value)
|
||||
?? throw Oops.Bah("字段不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
exists.CreateUserId,
|
||||
OnboardingPermissionCodes.EntryConfigManageAll,
|
||||
"入职字段");
|
||||
entity.CreateUserId = exists.CreateUserId;
|
||||
entity.CreateUser = exists.CreateUser;
|
||||
entity.CreateTime = exists.CreateTime;
|
||||
await _db.Updateable(entity).ExecuteCommandAsync();
|
||||
return;
|
||||
}
|
||||
@@ -90,12 +102,21 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
if (input.Ids.Count == 0)
|
||||
return;
|
||||
|
||||
var isReferenced = await _db.Queryable<ObFormRequirement>()
|
||||
var fields = await _db.Queryable<ObFormField>()
|
||||
.Where(item => input.Ids.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (fields.Count != input.Ids.Distinct().Count())
|
||||
throw Oops.Bah("字段不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
fields.Select(item => item.CreateUserId),
|
||||
OnboardingPermissionCodes.EntryConfigManageAll,
|
||||
"入职字段");
|
||||
|
||||
|
||||
var isReferencedByTask = await _db.Queryable<ObTaskFormRequirement>()
|
||||
.AnyAsync(x => input.Ids.Contains(x.FieldId));
|
||||
var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateFormField>()
|
||||
.AnyAsync(x => input.Ids.Contains(x.FieldId));
|
||||
if (isReferenced || isReferencedByTemplate)
|
||||
throw Oops.Bah("字段已被入职模板引用,不能删除");
|
||||
if (isReferencedByTask)
|
||||
throw Oops.Bah("字段已被入职任务引用,不能删除,可改为停用");
|
||||
|
||||
await _db.Deleteable<ObFormField>().In(input.Ids).ExecuteCommandAsync();
|
||||
}
|
||||
@@ -115,8 +136,30 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
/// </summary>
|
||||
public async Task SaveMaterialType(OnboardingMaterialTypeSaveInput input)
|
||||
{
|
||||
input.MaterialCode = OnboardingCodeValidator.Normalize(input.MaterialCode, "资料编码", 100);
|
||||
input.MaterialName = input.MaterialName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input.MaterialName))
|
||||
throw Oops.Bah("资料名称不能为空");
|
||||
if (input.MaxFileCount <= 0)
|
||||
throw Oops.Bah("最大文件数量必须大于0");
|
||||
if (input.MaxFileSizeMb.HasValue && (input.MaxFileSizeMb.Value <= 0 || input.MaxFileSizeMb.Value > 20))
|
||||
throw Oops.Bah("单个文件大小必须设置在1至20MB之间");
|
||||
|
||||
var supportedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"png", "jpg", "jpeg", "gif", "bmp", "webp", "pdf"
|
||||
};
|
||||
var allowedExtensions = (input.AllowedExtensions ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(item => item.TrimStart('.').ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (allowedExtensions.Count == 0)
|
||||
allowedExtensions.AddRange(new[] { "png", "jpg", "jpeg", "pdf" });
|
||||
var unsupportedExtensions = allowedExtensions.Where(item => !supportedExtensions.Contains(item)).ToList();
|
||||
if (unsupportedExtensions.Count > 0)
|
||||
throw Oops.Bah($"不支持的材料扩展名:{string.Join("、", unsupportedExtensions)},仅允许图片或PDF文件");
|
||||
input.AllowedExtensions = string.Join(",", allowedExtensions);
|
||||
|
||||
var materialCodeExists = await _db.Queryable<ObMaterialType>()
|
||||
.AnyAsync(x => x.MaterialCode == input.MaterialCode && x.Id != input.Id);
|
||||
@@ -136,10 +179,15 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
|
||||
if (input.Id.HasValue)
|
||||
{
|
||||
var exists = await _db.Queryable<ObMaterialType>().AnyAsync(x => x.Id == input.Id.Value);
|
||||
if (!exists)
|
||||
throw Oops.Bah("资料类型不存在");
|
||||
|
||||
var exists = await _db.Queryable<ObMaterialType>().InSingleAsync(input.Id.Value)
|
||||
?? throw Oops.Bah("资料类型不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
exists.CreateUserId,
|
||||
OnboardingPermissionCodes.EntryConfigManageAll,
|
||||
"资料类型");
|
||||
entity.CreateUserId = exists.CreateUserId;
|
||||
entity.CreateUser = exists.CreateUser;
|
||||
entity.CreateTime = exists.CreateTime;
|
||||
await _db.Updateable(entity).ExecuteCommandAsync();
|
||||
return;
|
||||
}
|
||||
@@ -155,13 +203,26 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
if (input.Ids.Count == 0)
|
||||
return;
|
||||
|
||||
var isReferenced = await _db.Queryable<ObMaterialRequirement>()
|
||||
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
|
||||
var materialTypes = await _db.Queryable<ObMaterialType>()
|
||||
.Where(item => input.Ids.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (materialTypes.Count != input.Ids.Distinct().Count())
|
||||
throw Oops.Bah("资料类型不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
materialTypes.Select(item => item.CreateUserId),
|
||||
OnboardingPermissionCodes.EntryConfigManageAll,
|
||||
"资料类型");
|
||||
|
||||
var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateMaterial>()
|
||||
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
|
||||
if (isReferenced || isReferencedByTemplate)
|
||||
if (isReferencedByTemplate)
|
||||
throw Oops.Bah("资料类型已被入职模板引用,不能删除");
|
||||
|
||||
var isReferencedByTask = await _db.Queryable<ObTaskMaterialRequirement>()
|
||||
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
|
||||
if (isReferencedByTask)
|
||||
throw Oops.Bah("资料类型已被入职任务引用,不能删除,可改为停用");
|
||||
|
||||
var hasUploads = await _db.Queryable<ObTaskMaterial>()
|
||||
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
|
||||
if (hasUploads)
|
||||
@@ -169,4 +230,4 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
|
||||
|
||||
await _db.Deleteable<ObMaterialType>().In(input.Ids).ExecuteCommandAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-4
@@ -31,6 +31,14 @@ public class OnboardingIdentityValidationService : IOnboardingIdentityValidation
|
||||
throw Oops.Bah("该姓名和身份证号已完成正式员工建档,请勿重复提交入职资料");
|
||||
}
|
||||
|
||||
// 身份证号码代表唯一自然人。即使姓名填写不同,也不能绕过正式员工重复校验。
|
||||
var formalEmployeeIdCardExists = await _db.Queryable<ObEmployee>()
|
||||
.AnyAsync(item => item.IdCardNumber == idCardNumber);
|
||||
if (formalEmployeeIdCardExists)
|
||||
{
|
||||
throw Oops.Bah("该身份证号码已完成正式员工建档,请核对姓名和身份证号码;如需更正,请联系 HR");
|
||||
}
|
||||
|
||||
// 待填写任务不占用身份,以实际先提交的任务为准;撤销或过期任务允许重新发起。
|
||||
var duplicateTaskExists = await _db.Queryable<ObTaskEmployeeInfo>()
|
||||
.InnerJoin<ObOnboardingTask>((info, task) => info.TaskId == task.Id)
|
||||
@@ -42,9 +50,24 @@ public class OnboardingIdentityValidationService : IOnboardingIdentityValidation
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Approved
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Archived))
|
||||
.AnyAsync();
|
||||
if (!duplicateTaskExists)
|
||||
return;
|
||||
if (duplicateTaskExists)
|
||||
{
|
||||
throw Oops.Bah("该姓名和身份证号已存在正在办理的入职任务,请勿重复提交;如需补充材料,请联系 HR 继续使用原任务链接");
|
||||
}
|
||||
|
||||
throw Oops.Bah("该姓名和身份证号已存在正在办理的入职任务,请勿重复提交;如需补充材料,请联系 HR 继续使用原任务链接");
|
||||
// 再单独按身份证号码校验,防止不同姓名使用同一证件重复提交不同任务。
|
||||
var duplicateIdCardTaskExists = await _db.Queryable<ObTaskEmployeeInfo>()
|
||||
.InnerJoin<ObOnboardingTask>((info, task) => info.TaskId == task.Id)
|
||||
.Where((info, task) => info.TaskId != currentTaskId
|
||||
&& info.IdCardNumber == idCardNumber
|
||||
&& (task.TaskStatus == OnboardingTaskStatus.Reviewing
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Rejected
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Approved
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Archived))
|
||||
.AnyAsync();
|
||||
if (duplicateIdCardTaskExists)
|
||||
{
|
||||
throw Oops.Bah("该身份证号码已存在正在办理的入职任务,请核对姓名和身份证号码;如需补充材料,请联系 HR 继续使用原任务链接");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
-12
@@ -6,18 +6,23 @@ namespace SimpleAdmin.Application;
|
||||
public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IConfigService _config;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingTaskLifecycleService(IConfigService config)
|
||||
public OnboardingTaskLifecycleService(IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_config = config;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 归档已审核通过的入职任务。
|
||||
/// </summary>
|
||||
public async Task Archive(OnboardingAuditActionInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => ArchiveCore(input));
|
||||
}
|
||||
|
||||
private async Task ArchiveCore(OnboardingAuditActionInput input)
|
||||
{
|
||||
var task = await Authorize(input.TaskId);
|
||||
if (task.TaskStatus != OnboardingTaskStatus.Approved)
|
||||
@@ -57,6 +62,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
|
||||
/// 撤销入职任务并同步撤销仍然有效的员工填写链接。
|
||||
/// </summary>
|
||||
public async Task Revoke(OnboardingAuditActionInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunTask(_db, input.TaskId, () => RevokeCore(input));
|
||||
}
|
||||
|
||||
private async Task RevokeCore(OnboardingAuditActionInput input)
|
||||
{
|
||||
var task = await Authorize(input.TaskId);
|
||||
if (task.TaskStatus == OnboardingTaskStatus.Approved
|
||||
@@ -102,6 +112,11 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
|
||||
/// 删除已撤销的入职任务及其全部关联快照数据。
|
||||
/// </summary>
|
||||
public async Task Delete(BaseIdInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunTask(_db, input.Id, () => DeleteCore(input));
|
||||
}
|
||||
|
||||
private async Task DeleteCore(BaseIdInput input)
|
||||
{
|
||||
var task = await Authorize(input.Id);
|
||||
if (task.TaskStatus != OnboardingTaskStatus.Revoked)
|
||||
@@ -117,6 +132,28 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var signProcessIds = await _db.Queryable<ObElectronicSignProcess>()
|
||||
.Where(x => x.TaskId == task.Id)
|
||||
.Select(x => x.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (signProcessIds.Count > 0)
|
||||
{
|
||||
await _db.Deleteable<ObElectronicSignEvent>()
|
||||
.Where(x => x.SignProcessId.HasValue
|
||||
&& signProcessIds.Contains(x.SignProcessId.Value))
|
||||
.ExecuteCommandAsync();
|
||||
await _db.Deleteable<ObElectronicSignDocument>()
|
||||
.Where(x => signProcessIds.Contains(x.SignProcessId))
|
||||
.ExecuteCommandAsync();
|
||||
await _db.Deleteable<ObElectronicSignProcess>()
|
||||
.Where(x => signProcessIds.Contains(x.Id))
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
await _db.Deleteable<ObEmployeeContractSnapshot>()
|
||||
.Where(x => x.TaskId == task.Id)
|
||||
.ExecuteCommandAsync();
|
||||
if (taskContractIds.Count > 0)
|
||||
{
|
||||
await _db.Deleteable<ObTaskContractVariable>()
|
||||
@@ -130,7 +167,6 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
|
||||
await _db.Deleteable<ObTaskMaterial>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
|
||||
await _db.Deleteable<ObTaskEmployeeInfo>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
|
||||
await _db.Deleteable<ObTaskFormRequirement>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
|
||||
await _db.Deleteable<ObTaskFormValue>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
|
||||
await _db.Deleteable<ObTaskOperationLog>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
|
||||
await _db.Deleteable<ObTaskAudit>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
|
||||
await _db.Deleteable<ObOnboardingTask>().In(task.Id).ExecuteCommandAsync();
|
||||
@@ -147,14 +183,10 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
|
||||
{
|
||||
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
|
||||
?? throw Oops.Bah("入职任务不存在");
|
||||
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
|
||||
?? throw Oops.Bah("请配置入职审核人");
|
||||
var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId);
|
||||
|
||||
if (!reviewerIds.Contains(UserManager.UserId))
|
||||
{
|
||||
throw Oops.Bah("您不是该入职任务的审核人");
|
||||
}
|
||||
await _dataPermission.EnsureCanManage(
|
||||
task.CreateUserId,
|
||||
OnboardingPermissionCodes.TaskManageAll,
|
||||
"入职任务");
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
+14
-12
@@ -6,18 +6,24 @@ namespace SimpleAdmin.Application;
|
||||
public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IConfigService _config;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingMaterialReviewService(IConfigService config)
|
||||
public OnboardingMaterialReviewService(
|
||||
IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_config = config;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核单份候选人上传材料并记录审核意见。
|
||||
/// </summary>
|
||||
public async Task Review(OnboardingMaterialReviewInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunMaterial(_db, input.TaskMaterialId, () => ReviewCore(input));
|
||||
}
|
||||
|
||||
private async Task ReviewCore(OnboardingMaterialReviewInput input)
|
||||
{
|
||||
if (input.ReviewStatus != TaskMaterialReviewStatus.Passed
|
||||
&& input.ReviewStatus != TaskMaterialReviewStatus.NeedSupplement)
|
||||
@@ -46,15 +52,11 @@ public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
|
||||
await _db.Updateable(material).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
private async Task EnsureReviewer(ObOnboardingTask task)
|
||||
private Task EnsureReviewer(ObOnboardingTask task)
|
||||
{
|
||||
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
|
||||
?? throw Oops.Bah("请配置入职审核人");
|
||||
var reviewerIds = OnboardingReviewerConfigParser.Parse(config.ConfigValue, task.CompanyId);
|
||||
|
||||
if (!reviewerIds.Contains(UserManager.UserId))
|
||||
{
|
||||
throw Oops.Bah("您不是该入职任务的审核人");
|
||||
}
|
||||
return _dataPermission.EnsureCanManage(
|
||||
task.CreateUserId,
|
||||
OnboardingPermissionCodes.TaskManageAll,
|
||||
"入职任务");
|
||||
}
|
||||
}
|
||||
|
||||
+127
-15
@@ -6,10 +6,12 @@ namespace SimpleAdmin.Application;
|
||||
public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingTemplateService()
|
||||
public OnboardingTemplateService(IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -59,6 +61,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
return new OnboardingTemplateDetailOutput
|
||||
{
|
||||
Id = summary.Id,
|
||||
CreateUserId = summary.CreateUserId,
|
||||
CreateUser = summary.CreateUser,
|
||||
TemplateCode = summary.TemplateCode,
|
||||
TemplateName = summary.TemplateName,
|
||||
CompanyId = summary.CompanyId,
|
||||
@@ -92,12 +96,41 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
NormalizeAndValidateInput(input);
|
||||
await ValidateReferences(input);
|
||||
|
||||
var templateId = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
|
||||
var codeExists = await _db.Queryable<ObOnboardingTemplate>()
|
||||
.AnyAsync(item => item.TemplateCode == input.TemplateCode && item.Id != templateId);
|
||||
if (codeExists)
|
||||
var codeOwnerQuery = _db.Queryable<ObOnboardingTemplate>()
|
||||
.ClearFilter()
|
||||
.Where(item => item.TemplateCode == input.TemplateCode);
|
||||
if (input.Id.HasValue)
|
||||
{
|
||||
var currentTemplateId = input.Id.Value;
|
||||
codeOwnerQuery = codeOwnerQuery.Where(item => item.Id != currentTemplateId);
|
||||
}
|
||||
|
||||
var codeOwner = await codeOwnerQuery.FirstAsync();
|
||||
if (codeOwner != null && !codeOwner.IsDelete)
|
||||
throw Oops.Bah("入职模板编码已存在");
|
||||
|
||||
if (input.Id.HasValue && codeOwner != null && codeOwner.IsDelete)
|
||||
throw Oops.Bah("该入职模板编码属于一条已删除记录,请通过新增模板重新启用该编码");
|
||||
|
||||
// 新增时如果编码命中历史软删除记录,复用原 Id 并恢复,避免唯一索引直接抛 500。
|
||||
var isRestore = !input.Id.HasValue && codeOwner != null && codeOwner.IsDelete;
|
||||
var templateId = isRestore
|
||||
? codeOwner.Id
|
||||
: input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
|
||||
|
||||
if (input.Id.HasValue || isRestore)
|
||||
{
|
||||
var existing = isRestore
|
||||
? codeOwner
|
||||
: await _db.Queryable<ObOnboardingTemplate>().InSingleAsync(templateId);
|
||||
if (existing == null)
|
||||
throw Oops.Bah("入职模板不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
existing.CreateUserId,
|
||||
OnboardingPermissionCodes.EntryConfigManageAll,
|
||||
"入职模板");
|
||||
}
|
||||
|
||||
var entity = new ObOnboardingTemplate
|
||||
{
|
||||
Id = templateId,
|
||||
@@ -106,15 +139,17 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
CompanyId = input.CompanyId,
|
||||
DepartmentId = input.DepartmentId,
|
||||
Description = input.Description,
|
||||
Status = input.Status
|
||||
Status = input.Status,
|
||||
IsDelete = false
|
||||
};
|
||||
|
||||
await _db.Ado.BeginTranAsync();
|
||||
try
|
||||
{
|
||||
if (input.Id.HasValue)
|
||||
if (input.Id.HasValue || isRestore)
|
||||
{
|
||||
var exists = await _db.Queryable<ObOnboardingTemplate>().AnyAsync(item => item.Id == templateId);
|
||||
var exists = isRestore || await _db.Queryable<ObOnboardingTemplate>()
|
||||
.AnyAsync(item => item.Id == templateId);
|
||||
if (!exists)
|
||||
throw Oops.Bah("入职模板不存在");
|
||||
await _db.Updateable(entity).IgnoreColumns(ignoreAllNullColumns: true).ExecuteCommandAsync();
|
||||
@@ -142,6 +177,16 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
if (input.Ids.Count == 0)
|
||||
return;
|
||||
|
||||
var templates = await _db.Queryable<ObOnboardingTemplate>()
|
||||
.Where(item => input.Ids.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (templates.Count != input.Ids.Distinct().Count())
|
||||
throw Oops.Bah("入职模板不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
templates.Select(item => item.CreateUserId),
|
||||
OnboardingPermissionCodes.EntryConfigManageAll,
|
||||
"入职模板");
|
||||
|
||||
var isUsed = await _db.Queryable<ObOnboardingTask>()
|
||||
.AnyAsync(item => input.Ids.Contains(item.OnboardingTemplateId));
|
||||
if (isUsed)
|
||||
@@ -184,12 +229,73 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
&& templateIds.Contains(item.Id))
|
||||
.OrderBy(item => item.TemplateName)
|
||||
.ToListAsync();
|
||||
return await BuildOutputs(templates);
|
||||
if (templates.Count == 0)
|
||||
return await BuildOutputs(templates);
|
||||
|
||||
var onboardingTemplateIds = templates.Select(item => item.Id).ToList();
|
||||
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
|
||||
.Where(item => onboardingTemplateIds.Contains(item.OnboardingTemplateId))
|
||||
.ToListAsync();
|
||||
if (contractConfigs.Count == 0)
|
||||
return await BuildOutputs(new List<ObOnboardingTemplate>());
|
||||
|
||||
var contractTemplateIds = contractConfigs
|
||||
.Select(item => item.ContractTemplateId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var contractVersionIds = contractConfigs
|
||||
.Select(item => item.ContractTemplateVersionId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var contractTemplates = await _db.Queryable<ObContractTemplate>()
|
||||
.Where(item => contractTemplateIds.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
var contractVersions = await _db.Queryable<ObContractTemplateVersion>()
|
||||
.Where(item => contractVersionIds.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
var contractPositions = await _db.Queryable<ObContractTemplatePosition>()
|
||||
.Where(item => contractTemplateIds.Contains(item.ContractTemplateId)
|
||||
&& item.PositionId == positionId)
|
||||
.ToListAsync();
|
||||
var now = DateTime.Now;
|
||||
var availableTemplates = templates.Where(template =>
|
||||
{
|
||||
var configs = contractConfigs
|
||||
.Where(item => item.OnboardingTemplateId == template.Id)
|
||||
.ToList();
|
||||
if (configs.Count == 0)
|
||||
return false;
|
||||
|
||||
return configs.All(config =>
|
||||
{
|
||||
var contractTemplate = contractTemplates
|
||||
.FirstOrDefault(item => item.Id == config.ContractTemplateId);
|
||||
var version = contractVersions
|
||||
.FirstOrDefault(item => item.Id == config.ContractTemplateVersionId);
|
||||
return contractTemplate != null
|
||||
&& string.Equals(
|
||||
contractTemplate.Status,
|
||||
"enable",
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
&& contractTemplate.CompanyId == template.CompanyId
|
||||
&& contractTemplate.DepartmentId == template.DepartmentId
|
||||
&& contractPositions.Any(item =>
|
||||
item.ContractTemplateId == config.ContractTemplateId)
|
||||
&& version != null
|
||||
&& version.TemplateId == config.ContractTemplateId
|
||||
&& ContractTemplateVersionValidity.IsAvailable(version, now);
|
||||
});
|
||||
}).ToList();
|
||||
|
||||
return await BuildOutputs(availableTemplates);
|
||||
}
|
||||
|
||||
private static void NormalizeAndValidateInput(OnboardingTemplateSaveInput input)
|
||||
{
|
||||
input.TemplateCode = input.TemplateCode?.Trim();
|
||||
input.TemplateCode = OnboardingCodeValidator.Normalize(
|
||||
input.TemplateCode,
|
||||
"入职模板编码",
|
||||
100);
|
||||
input.TemplateName = input.TemplateName?.Trim();
|
||||
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>();
|
||||
input.Contracts ??= new List<OnboardingTemplateContractInput>();
|
||||
@@ -208,8 +314,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
.Select(group => group.First())
|
||||
.ToList() ?? new List<OnboardingTemplateMaterialInput>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.TemplateCode) || string.IsNullOrWhiteSpace(input.TemplateName))
|
||||
throw Oops.Bah("请填写入职模板编码和名称");
|
||||
if (string.IsNullOrWhiteSpace(input.TemplateName))
|
||||
throw Oops.Bah("请填写入职模板名称");
|
||||
if (input.CompanyId <= 0)
|
||||
throw Oops.Bah("请选择所属公司");
|
||||
if (input.DepartmentId <= 0)
|
||||
@@ -263,6 +369,9 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
{
|
||||
throw Oops.Bah("合同模板与版本不匹配");
|
||||
}
|
||||
var now = DateTime.Now;
|
||||
if (versions.Any(version => !ContractTemplateVersionValidity.IsAvailable(version, now)))
|
||||
throw Oops.Bah("入职模板只能选择当前已生效且尚未失效的启用签署材料版本");
|
||||
|
||||
var contractTemplateIds = input.Contracts
|
||||
.Select(item => item.ContractTemplateId)
|
||||
@@ -299,10 +408,11 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
|
||||
if (input.Status == "enable")
|
||||
{
|
||||
if (contractTemplates.Any(item => item.Status != "enable"))
|
||||
if (contractTemplates.Any(item => !string.Equals(
|
||||
item.Status,
|
||||
"enable",
|
||||
global::System.StringComparison.OrdinalIgnoreCase)))
|
||||
throw Oops.Bah("启用入职模板前,请先启用全部合同模板");
|
||||
if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled))
|
||||
throw Oops.Bah("启用入职模板前,请先启用全部合同版本");
|
||||
}
|
||||
|
||||
var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList();
|
||||
@@ -404,6 +514,8 @@ public class OnboardingTemplateService : IOnboardingTemplateService
|
||||
return new OnboardingTemplateOutput
|
||||
{
|
||||
Id = template.Id,
|
||||
CreateUserId = template.CreateUserId,
|
||||
CreateUser = template.CreateUser,
|
||||
TemplateCode = template.TemplateCode,
|
||||
TemplateName = template.TemplateName,
|
||||
CompanyId = template.CompanyId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务岗位分页查询参数。
|
||||
@@ -52,13 +52,21 @@ public class OnboardingPositionAddInput : ObPosition
|
||||
/// 岗位业务编码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位编码不能为空")]
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位编码不能超过50个字符")]
|
||||
public override string PositionCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 岗位名称。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位名称不能为空")]
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位名称不能超过50个字符")]
|
||||
public override string PositionName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")]
|
||||
public override string Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 实现入职业务岗位业务能力。
|
||||
/// </summary>
|
||||
public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPositionService
|
||||
{
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingPositionService(IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询入职业务岗位。
|
||||
/// </summary>
|
||||
@@ -16,7 +23,8 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionCode), it => it.PositionCode.Contains(input.PositionCode))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
|
||||
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "PositionCode", "PositionName"))
|
||||
.OrderByDescending(it => it.CreateTime)
|
||||
.OrderBy(it => it.PositionName)
|
||||
.ToPagedListAsync(input.PageNum, input.PageSize);
|
||||
|
||||
@@ -106,6 +114,10 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
|
||||
throw Oops.Bah("请选择所属部门");
|
||||
|
||||
var position = await Detail(new BaseIdInput { Id = input.Id });
|
||||
await _dataPermission.EnsureCanManage(
|
||||
position.CreateUserId,
|
||||
OnboardingPermissionCodes.SetupManageAll,
|
||||
"岗位");
|
||||
await EnsureScopeCanChange(position, input.CompanyId, input.DepartmentId.Value);
|
||||
await EnsureCompanyExists(input.CompanyId);
|
||||
await EnsureDepartmentExists(input.CompanyId, input.DepartmentId.Value);
|
||||
@@ -121,6 +133,16 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
|
||||
if (input.Ids.Count == 0)
|
||||
return;
|
||||
|
||||
var positions = await Context.Queryable<ObPosition>()
|
||||
.Where(item => input.Ids.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (positions.Count != input.Ids.Distinct().Count())
|
||||
throw Oops.Bah("岗位不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
positions.Select(item => item.CreateUserId),
|
||||
OnboardingPermissionCodes.SetupManageAll,
|
||||
"岗位");
|
||||
|
||||
var hasTasks = await Context.Queryable<ObOnboardingTask>()
|
||||
.AnyAsync(it => input.Ids.Contains(it.PositionId));
|
||||
if (hasTasks)
|
||||
@@ -167,12 +189,11 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
|
||||
/// </summary>
|
||||
private static void NormalizeInput(OnboardingPositionAddInput input)
|
||||
{
|
||||
input.PositionCode = input.PositionCode?.Trim();
|
||||
input.PositionCode = OnboardingCodeValidator.Normalize(input.PositionCode, "岗位编码", 50);
|
||||
input.PositionName = input.PositionName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(input.PositionCode)
|
||||
|| string.IsNullOrWhiteSpace(input.PositionName))
|
||||
if (string.IsNullOrWhiteSpace(input.PositionName))
|
||||
{
|
||||
throw Oops.Bah("请填写岗位编码和岗位名称");
|
||||
throw Oops.Bah("请填写岗位名称");
|
||||
}
|
||||
|
||||
if (input.Status != "enable" && input.Status != "disable")
|
||||
|
||||
+23
@@ -71,4 +71,27 @@ public class OnboardingSubmitInput
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public string Token { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工是否已在最终提交前逐项确认实名认证信息。
|
||||
/// </summary>
|
||||
public bool IdentityInformationConfirmed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工最终确认的真实姓名。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public string ConfirmedEmployeeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工最终确认的身份证号码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public string ConfirmedIdCardNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工最终确认的手机号码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public string ConfirmedPhone { get; set; }
|
||||
}
|
||||
|
||||
+94
-41
@@ -5,23 +5,32 @@ namespace SimpleAdmin.Application;
|
||||
/// </summary>
|
||||
public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService
|
||||
{
|
||||
private const int PublicMaterialMaxFileSizeMb = 20;
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IOnboardingContractSnapshotService _snapshotService;
|
||||
private readonly IOnboardingIdentityValidationService _identityValidationService;
|
||||
private readonly IOnboardingContractVariableValidationService _contractVariableValidationService;
|
||||
|
||||
public OnboardingPublicSubmissionService(
|
||||
IOnboardingContractSnapshotService snapshotService,
|
||||
IOnboardingIdentityValidationService identityValidationService)
|
||||
IOnboardingIdentityValidationService identityValidationService,
|
||||
IOnboardingContractVariableValidationService contractVariableValidationService)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_snapshotService = snapshotService;
|
||||
_identityValidationService = identityValidationService;
|
||||
_contractVariableValidationService = contractVariableValidationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 关联已上传 OSS 文件与任务材料要求。
|
||||
/// </summary>
|
||||
public async Task SaveMaterial(OnboardingSaveMaterialInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveMaterialCore(input), transactional: true);
|
||||
}
|
||||
|
||||
private async Task SaveMaterialCore(OnboardingSaveMaterialInput input)
|
||||
{
|
||||
var task = await GetEditableTask(input.Token);
|
||||
var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
|
||||
@@ -81,6 +90,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
/// </summary>
|
||||
public async Task DeleteMaterial(
|
||||
OnboardingDeleteMaterialInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunPublic(_db, input.Token, () => DeleteMaterialCore(input), transactional: true);
|
||||
}
|
||||
|
||||
private async Task DeleteMaterialCore(
|
||||
OnboardingDeleteMaterialInput input)
|
||||
{
|
||||
var task = await GetEditableTask(input.Token);
|
||||
var material = await _db.Queryable<ObTaskMaterial>()
|
||||
@@ -99,6 +114,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
/// 记录候选人已经查看并确认动态生成的合同。
|
||||
/// </summary>
|
||||
public async Task ConfirmContract(OnboardingConfirmContractInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunPublic(_db, input.Token, () => ConfirmContractCore(input), transactional: true);
|
||||
}
|
||||
|
||||
private async Task ConfirmContractCore(OnboardingConfirmContractInput input)
|
||||
{
|
||||
var task = await GetEditableTask(input.Token);
|
||||
var contract = await _db.Queryable<ObTaskContract>()
|
||||
@@ -116,18 +136,23 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
/// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
|
||||
/// </summary>
|
||||
public async Task Submit(OnboardingSubmitInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SubmitCore(input), transactional: true, protectIdentity: true);
|
||||
}
|
||||
|
||||
private async Task SubmitCore(OnboardingSubmitInput input)
|
||||
{
|
||||
var task = await GetEditableTask(input.Token);
|
||||
var info = await _db.Queryable<ObTaskEmployeeInfo>()
|
||||
.FirstAsync(item => item.TaskId == task.Id);
|
||||
var basicInfoIncomplete = info == null
|
||||
|| string.IsNullOrWhiteSpace(info.EmployeeName)
|
||||
|| string.IsNullOrWhiteSpace(info.Phone)
|
||||
|| string.IsNullOrWhiteSpace(info.IdCardNumber)
|
||||
|| string.IsNullOrWhiteSpace(info.BankCardNumber)
|
||||
|| string.IsNullOrWhiteSpace(info.BankName);
|
||||
if (basicInfoIncomplete)
|
||||
throw Oops.Bah("请先完整填写基本信息");
|
||||
.FirstAsync(item => item.TaskId == task.Id)
|
||||
?? throw Oops.Bah("请先完整填写基本信息");
|
||||
|
||||
// 最终提交必须重新执行全量校验,不能只依赖员工手机端的页面校验。
|
||||
var formRequirements =
|
||||
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
|
||||
OnboardingEmployeeInfoValidator.ValidateAndNormalize(info, formRequirements);
|
||||
EnsureIdentityInformationConfirmed(input, info);
|
||||
await _db.Updateable(info).ExecuteCommandAsync();
|
||||
|
||||
await _identityValidationService.EnsureNoDuplicate(
|
||||
task.Id,
|
||||
@@ -148,18 +173,28 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
if (hasMissingRequiredMaterial)
|
||||
throw Oops.Bah("仍有必传材料未上传");
|
||||
|
||||
await EnsureRequiredContractVariables(task);
|
||||
await _contractVariableValidationService.Validate(task.Id);
|
||||
|
||||
var hasUnconfirmedRequiredContract = await _db.Queryable<ObTaskContract>()
|
||||
.AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null);
|
||||
if (hasUnconfirmedRequiredContract)
|
||||
throw Oops.Bah("请查看并确认全部必签合同");
|
||||
|
||||
var submittedAt = DateTime.Now;
|
||||
var identityConfirmationHash = BuildIdentityConfirmationHash(info);
|
||||
var originalStatus = task.TaskStatus;
|
||||
var originalSubmitCount = task.SubmitCount;
|
||||
task.TaskStatus = OnboardingTaskStatus.Reviewing;
|
||||
task.SubmitCount++;
|
||||
task.LastSubmittedAt = DateTime.Now;
|
||||
task.LastSubmittedAt = submittedAt;
|
||||
task.RejectReason = null;
|
||||
await _db.Updateable(task).ExecuteCommandAsync();
|
||||
var changed = await _db.Updateable(task)
|
||||
.UpdateColumns(item => new { item.TaskStatus, item.SubmitCount, item.LastSubmittedAt, item.RejectReason })
|
||||
.Where(item => item.Id == task.Id && item.TaskStatus == originalStatus
|
||||
&& item.SubmitCount == originalSubmitCount)
|
||||
.ExecuteCommandAsync();
|
||||
if (changed != 1)
|
||||
throw Oops.Bah("任务状态已变更,请刷新后重试");
|
||||
|
||||
await _db.Insertable(new ObTaskAudit
|
||||
{
|
||||
@@ -167,9 +202,18 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
TaskId = task.Id,
|
||||
AuditRound = task.SubmitCount,
|
||||
AuditAction = OnboardingAuditAction.Submit,
|
||||
AuditOpinion = "员工提交入职资料",
|
||||
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new { info, materials }),
|
||||
AuditedAt = DateTime.Now
|
||||
AuditOpinion = "员工提交入职资料,并确认姓名、身份证号码和手机号码用于实名认证",
|
||||
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
info,
|
||||
materials,
|
||||
identityConfirmation = new
|
||||
{
|
||||
confirmedAt = submittedAt,
|
||||
summaryHash = identityConfirmationHash
|
||||
}
|
||||
}),
|
||||
AuditedAt = submittedAt
|
||||
}).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
@@ -190,10 +234,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
if (suffixNotAllowed)
|
||||
throw Oops.Bah("该材料不允许上传此文件类型");
|
||||
|
||||
var fileTooLarge = requirement.MaxFileSizeMb.HasValue
|
||||
&& input.FileLength > requirement.MaxFileSizeMb.Value * 1024L * 1024L;
|
||||
var maxFileSizeMb = requirement.MaxFileSizeMb.HasValue
|
||||
? Math.Min(requirement.MaxFileSizeMb.Value, PublicMaterialMaxFileSizeMb)
|
||||
: PublicMaterialMaxFileSizeMb;
|
||||
var fileTooLarge = input.FileLength > maxFileSizeMb * 1024L * 1024L;
|
||||
if (fileTooLarge)
|
||||
throw Oops.Bah("文件超过允许的大小");
|
||||
throw Oops.Bah($"单个文件不能超过{maxFileSizeMb}MB,请压缩后重新上传");
|
||||
|
||||
var materials = await _db.Queryable<ObTaskMaterial>()
|
||||
.Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
|
||||
@@ -214,29 +260,36 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
|
||||
throw Oops.Bah("该材料已达到最大上传数量");
|
||||
}
|
||||
|
||||
private async Task EnsureRequiredContractVariables(ObOnboardingTask task)
|
||||
/// <summary>
|
||||
/// 后端再次比较员工勾选确认时的三项实名信息,防止绕过页面或确认后数据发生变化。
|
||||
/// </summary>
|
||||
private static void EnsureIdentityInformationConfirmed(
|
||||
OnboardingSubmitInput input,
|
||||
ObTaskEmployeeInfo info)
|
||||
{
|
||||
var contracts = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == task.Id)
|
||||
.ToListAsync();
|
||||
var contractIds = contracts.Select(contract => contract.Id).ToList();
|
||||
var templateVersionIds = contracts.Select(contract => contract.TemplateVersionId).ToList();
|
||||
var variables = await _db.Queryable<ObTaskContractVariable>()
|
||||
.Where(item => contractIds.Contains(item.TaskContractId))
|
||||
.ToListAsync();
|
||||
var definitions = await _db.Queryable<ObTemplateVariable>()
|
||||
.Where(item => templateVersionIds.Contains(item.TemplateVersionId) && item.Required)
|
||||
.ToListAsync();
|
||||
var missing = contracts
|
||||
.SelectMany(contract => definitions
|
||||
.Where(definition => definition.TemplateVersionId == contract.TemplateVersionId)
|
||||
.Select(definition => new { contract, definition }))
|
||||
.FirstOrDefault(item => string.IsNullOrWhiteSpace(
|
||||
variables.FirstOrDefault(variable => variable.TaskContractId == item.contract.Id
|
||||
&& variable.VariableCode == item.definition.VariableCode)
|
||||
?.VariableValue));
|
||||
if (missing != null)
|
||||
throw Oops.Bah($"合同“{missing.contract.ContractName}”的必填变量“{missing.definition.VariableName}”尚未填写");
|
||||
if (!input.IdentityInformationConfirmed)
|
||||
throw Oops.Bah("请逐项确认姓名、身份证号码和手机号码无误");
|
||||
|
||||
var confirmedEmployeeName = input.ConfirmedEmployeeName?.Trim();
|
||||
var confirmedIdCardNumber = input.ConfirmedIdCardNumber?.Trim().ToUpperInvariant();
|
||||
var confirmedPhone = input.ConfirmedPhone?.Trim();
|
||||
|
||||
if (!string.Equals(confirmedEmployeeName, info.EmployeeName, StringComparison.Ordinal))
|
||||
throw Oops.Bah("姓名在确认后发生了变化,请返回基本信息重新核对并确认");
|
||||
if (!string.Equals(confirmedIdCardNumber, info.IdCardNumber, StringComparison.Ordinal))
|
||||
throw Oops.Bah("身份证号码在确认后发生了变化,请返回基本信息重新核对并确认");
|
||||
if (!string.Equals(confirmedPhone, info.Phone, StringComparison.Ordinal))
|
||||
throw Oops.Bah("手机号码在确认后发生了变化,请返回基本信息重新核对并确认");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审核快照只记录实名信息摘要,完整原始值仍以本轮员工信息快照为准。
|
||||
/// </summary>
|
||||
private static string BuildIdentityConfirmationHash(ObTaskEmployeeInfo info)
|
||||
{
|
||||
var value = $"{info.EmployeeName}\n{info.IdCardNumber}\n{info.Phone}";
|
||||
var bytes = global::System.Text.Encoding.UTF8.GetBytes(value);
|
||||
return Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(bytes));
|
||||
}
|
||||
|
||||
private Task<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId)
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 生成并读取入职登记表内置员工字段的任务级必填快照。
|
||||
/// </summary>
|
||||
internal static class OnboardingEmployeeFieldRequirementSnapshot
|
||||
{
|
||||
private const string EmployeeVariablePrefix = "employee.";
|
||||
|
||||
/// <summary>
|
||||
/// 根据当前内置员工变量配置创建一份任务快照。
|
||||
/// </summary>
|
||||
public static async Task<List<ObTaskFormRequirement>> BuildFromCurrentDefinitions(
|
||||
SqlSugarScope db,
|
||||
long taskId)
|
||||
{
|
||||
var definitions = await db.Queryable<ObVariableDefinition>()
|
||||
.Where(item => item.IsBuiltIn
|
||||
&& item.VariableCategory == ContractVariableType.Employee)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync();
|
||||
|
||||
return definitions.Select(definition => new ObTaskFormRequirement
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
FieldId = definition.Id,
|
||||
FieldCode = definition.VariableCode,
|
||||
FieldName = definition.VariableName,
|
||||
DataType = definition.DataType,
|
||||
Required = definition.Required,
|
||||
SortCode = definition.SortCode
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取任务冻结配置;历史任务没有员工字段快照时使用当前配置兼容。
|
||||
/// </summary>
|
||||
public static async Task<List<ObTaskFormRequirement>> GetForTask(
|
||||
SqlSugarScope db,
|
||||
long taskId)
|
||||
{
|
||||
var snapshots = await db.Queryable<ObTaskFormRequirement>()
|
||||
.Where(item => item.TaskId == taskId)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.ToListAsync();
|
||||
if (snapshots.Any(IsEmployeeField))
|
||||
return snapshots;
|
||||
|
||||
var compatibilitySnapshots = await BuildFromCurrentDefinitions(db, taskId);
|
||||
if (snapshots.Count == 0)
|
||||
return compatibilitySnapshots;
|
||||
|
||||
snapshots.AddRange(compatibilitySnapshots);
|
||||
return snapshots.OrderBy(item => item.SortCode).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断快照是否对应入职登记表内置员工字段。
|
||||
/// </summary>
|
||||
public static bool IsEmployeeField(ObTaskFormRequirement requirement)
|
||||
{
|
||||
return requirement?.FieldCode?.StartsWith(
|
||||
EmployeeVariablePrefix,
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 统一校验并规范化员工端固定入职信息。
|
||||
/// </summary>
|
||||
internal static class OnboardingEmployeeInfoValidator
|
||||
{
|
||||
private static readonly int[] IdCardWeights =
|
||||
{
|
||||
7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2
|
||||
};
|
||||
|
||||
private static readonly char[] IdCardCheckCodes =
|
||||
{
|
||||
'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 规范化文本,并按照任务冻结配置校验固定登记表必填项及字段格式。
|
||||
/// </summary>
|
||||
public static void ValidateAndNormalize(
|
||||
ObTaskEmployeeInfo input,
|
||||
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements = null)
|
||||
{
|
||||
input.EmployeeName = input.EmployeeName?.Trim();
|
||||
input.Phone = input.Phone?.Trim();
|
||||
input.Email = input.Email?.Trim();
|
||||
input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant();
|
||||
input.BankCardNumber = global::System.Text.RegularExpressions.Regex.Replace(
|
||||
input.BankCardNumber ?? string.Empty,
|
||||
@"[\s-]",
|
||||
string.Empty);
|
||||
input.BankName = input.BankName?.Trim();
|
||||
input.BankAccountName = input.EmployeeName;
|
||||
|
||||
ValidateRequiredFields(input, fieldRequirements);
|
||||
|
||||
// 电子签约实名认证始终依赖以下三项,不能通过后台配置关闭。
|
||||
if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100)
|
||||
throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.Gender)
|
||||
&& input.Gender != "男"
|
||||
&& input.Gender != "女")
|
||||
{
|
||||
throw Oops.Bah("请选择正确的性别");
|
||||
}
|
||||
|
||||
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
input.Phone ?? string.Empty,
|
||||
@"^1[3-9]\d{9}$"))
|
||||
{
|
||||
throw Oops.Bah("请输入正确的 11 位手机号码");
|
||||
}
|
||||
|
||||
if (!TryValidateIdCard(input.IdCardNumber, out var idCardBirthday, out var idCardError))
|
||||
throw Oops.Bah(idCardError);
|
||||
|
||||
ValidateBirthday(input.Birthday, idCardBirthday);
|
||||
ValidateOptionalEmail(input.Email);
|
||||
ValidateOptionalNumber(input.Height, "身高", 100M, 250M, "厘米");
|
||||
ValidateOptionalNumber(input.Weight, "体重", 30M, 300M, "公斤");
|
||||
if (!string.IsNullOrWhiteSpace(input.BankCardNumber))
|
||||
ValidateBankCard(input.BankCardNumber);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(input.BankName) && input.BankName.Length > 100)
|
||||
throw Oops.Bah("开户银行名称不能超过 100 个字符");
|
||||
|
||||
ValidateOptionalContactPhone(input.EmergencyPhone);
|
||||
ValidateEducationHistory(input.EducationHistoryJson);
|
||||
ValidateWorkHistory(input.WorkHistoryJson);
|
||||
ValidateFamilyMembers(input.FamilyMembersJson);
|
||||
ValidateJson(input.CustomVariablesJson, global::System.Text.Json.JsonValueKind.Object, "员工补充变量");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按任务快照校验后台配置为必填的内置员工字段。
|
||||
/// </summary>
|
||||
private static void ValidateRequiredFields(
|
||||
ObTaskEmployeeInfo input,
|
||||
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements)
|
||||
{
|
||||
if (fieldRequirements == null || fieldRequirements.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var requirement in fieldRequirements.Where(item =>
|
||||
item.Required
|
||||
&& OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField(item)))
|
||||
{
|
||||
var separatorIndex = requirement.FieldCode.LastIndexOf('.');
|
||||
var propertyName = separatorIndex >= 0
|
||||
? requirement.FieldCode[(separatorIndex + 1)..]
|
||||
: requirement.FieldCode;
|
||||
var property = typeof(ObTaskEmployeeInfo).GetProperty(
|
||||
propertyName,
|
||||
global::System.Reflection.BindingFlags.Instance
|
||||
| global::System.Reflection.BindingFlags.Public
|
||||
| global::System.Reflection.BindingFlags.IgnoreCase);
|
||||
if (property == null)
|
||||
{
|
||||
throw Oops.Bah(
|
||||
$"内置员工变量“{requirement.FieldCode}”没有对应的入职登记表字段,请联系管理员");
|
||||
}
|
||||
|
||||
var value = property.GetValue(input)?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw Oops.Bah($"请填写入职登记表“{requirement.FieldName}”");
|
||||
}
|
||||
}
|
||||
private static bool TryValidateIdCard(
|
||||
string idCardNumber,
|
||||
out DateTime birthday,
|
||||
out string errorMessage)
|
||||
{
|
||||
birthday = default;
|
||||
errorMessage = null;
|
||||
|
||||
var structureIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
idCardNumber ?? string.Empty,
|
||||
"^\\d{17}[0-9X]$");
|
||||
if (!structureIsValid)
|
||||
{
|
||||
errorMessage = "请输入正确的 18 位身份证号码";
|
||||
return false;
|
||||
}
|
||||
|
||||
var birthdayText = idCardNumber.Substring(6, 8);
|
||||
var birthdayIsValid = DateTime.TryParseExact(
|
||||
birthdayText,
|
||||
"yyyyMMdd",
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
global::System.Globalization.DateTimeStyles.None,
|
||||
out birthday);
|
||||
if (!birthdayIsValid || birthday.Date > DateTime.Today)
|
||||
{
|
||||
errorMessage = "身份证号码中的出生日期不正确";
|
||||
return false;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
for (var index = 0; index < IdCardWeights.Length; index++)
|
||||
{
|
||||
total += (idCardNumber[index] - '0') * IdCardWeights[index];
|
||||
}
|
||||
|
||||
if (IdCardCheckCodes[total % 11] == idCardNumber[17])
|
||||
return true;
|
||||
|
||||
errorMessage = "身份证号码校验位不正确";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ValidateBirthday(string birthdayText, DateTime? idCardBirthday)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(birthdayText))
|
||||
return;
|
||||
|
||||
var birthdayIsValid = DateTime.TryParseExact(
|
||||
birthdayText.Trim(),
|
||||
"yyyy-MM-dd",
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
global::System.Globalization.DateTimeStyles.None,
|
||||
out var birthday);
|
||||
if (!birthdayIsValid || birthday.Date > DateTime.Today)
|
||||
throw Oops.Bah("请输入正确的出生日期");
|
||||
|
||||
if (idCardBirthday.HasValue && birthday.Date != idCardBirthday.Value.Date)
|
||||
throw Oops.Bah("出生日期与身份证号码不一致");
|
||||
}
|
||||
|
||||
private static void ValidateOptionalEmail(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return;
|
||||
|
||||
var emailIsValid = email.Length <= 100
|
||||
&& global::System.Net.Mail.MailAddress.TryCreate(email, out _);
|
||||
if (!emailIsValid)
|
||||
throw Oops.Bah("请输入正确的电子邮箱");
|
||||
}
|
||||
|
||||
private static void ValidateOptionalNumber(
|
||||
string value,
|
||||
string fieldName,
|
||||
decimal min,
|
||||
decimal max,
|
||||
string unit)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
|
||||
var numberIsValid = decimal.TryParse(
|
||||
value.Trim(),
|
||||
global::System.Globalization.NumberStyles.Number,
|
||||
global::System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var number);
|
||||
if (!numberIsValid || number < min || number > max)
|
||||
{
|
||||
throw Oops.Bah($"{fieldName}应为 {min:0} 至 {max:0} {unit}之间的数字");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateBankCard(string bankCardNumber)
|
||||
{
|
||||
var bankCardIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
bankCardNumber ?? string.Empty,
|
||||
"^\\d{12,19}$");
|
||||
if (!bankCardIsValid)
|
||||
throw Oops.Bah("请输入 12 至 19 位银行卡号");
|
||||
}
|
||||
|
||||
private static void ValidateOptionalContactPhone(string phone)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(phone))
|
||||
return;
|
||||
|
||||
var contactPhoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
phone.Trim(),
|
||||
"^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$");
|
||||
if (!contactPhoneIsValid)
|
||||
throw Oops.Bah("请输入正确的紧急联系人手机号码或座机号码");
|
||||
}
|
||||
|
||||
private static void ValidateEducationHistory(string json)
|
||||
{
|
||||
ValidateHistoryRows(json, "教育经历", (row, rowNumber) =>
|
||||
{
|
||||
GetRequiredRowValue(row, "timeRange", rowNumber, "教育经历", "时间范围");
|
||||
GetRequiredRowValue(row, "location", rowNumber, "教育经历", "学校或机构");
|
||||
GetRequiredRowValue(row, "content", rowNumber, "教育经历", "专业或学习内容");
|
||||
GetRequiredRowValue(row, "result", rowNumber, "教育经历", "结果或学位");
|
||||
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条教育学习经历");
|
||||
}
|
||||
|
||||
private static void ValidateWorkHistory(string json)
|
||||
{
|
||||
ValidateHistoryRows(json, "工作经历", (row, rowNumber) =>
|
||||
{
|
||||
GetRequiredRowValue(row, "timeRange", rowNumber, "工作经历", "起止时间");
|
||||
GetRequiredRowValue(row, "companyName", rowNumber, "工作经历", "工作单位");
|
||||
GetRequiredRowValue(row, "position", rowNumber, "工作经历", "担任职务");
|
||||
GetRequiredRowValue(row, "leaveReason", rowNumber, "工作经历", "离职原因");
|
||||
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条工作经历(应届生可填写兼职、在校或实习经历)");
|
||||
}
|
||||
|
||||
private static void ValidateFamilyMembers(string json)
|
||||
{
|
||||
ValidateHistoryRows(json, "家庭成员", (row, rowNumber) =>
|
||||
{
|
||||
GetRequiredRowValue(row, "name", rowNumber, "家庭成员", "姓名");
|
||||
GetRequiredRowValue(row, "relationship", rowNumber, "家庭成员", "关系");
|
||||
var ageText = GetRequiredRowValue(row, "age", rowNumber, "家庭成员", "年龄");
|
||||
var phone = GetRequiredRowValue(row, "phone", rowNumber, "家庭成员", "联系电话");
|
||||
|
||||
var ageIsValid = int.TryParse(ageText, out var age) && age >= 0 && age <= 120;
|
||||
if (!ageIsValid)
|
||||
throw Oops.Bah($"第 {rowNumber} 条家庭成员的年龄应为 0 至 120 之间的整数");
|
||||
|
||||
var phoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
phone,
|
||||
"^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$");
|
||||
if (!phoneIsValid)
|
||||
throw Oops.Bah($"第 {rowNumber} 条家庭成员的联系电话格式不正确");
|
||||
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一位家庭成员信息");
|
||||
}
|
||||
|
||||
private static void ValidateHistoryRows(
|
||||
string json,
|
||||
string fieldName,
|
||||
Action<global::System.Text.Json.JsonElement, int> validateRow,
|
||||
bool requireAtLeastOne = false,
|
||||
string emptyErrorMessage = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
if (requireAtLeastOne)
|
||||
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = global::System.Text.Json.JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Array)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
|
||||
var rowNumber = 0;
|
||||
foreach (var row in document.RootElement.EnumerateArray())
|
||||
{
|
||||
rowNumber++;
|
||||
if (row.ValueKind != global::System.Text.Json.JsonValueKind.Object)
|
||||
throw Oops.Bah($"第 {rowNumber} 条{fieldName}格式不正确,请删除后重新添加");
|
||||
|
||||
validateRow(row, rowNumber);
|
||||
}
|
||||
|
||||
if (requireAtLeastOne && rowNumber == 0)
|
||||
{
|
||||
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
|
||||
}
|
||||
}
|
||||
catch (global::System.Text.Json.JsonException)
|
||||
{
|
||||
throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRequiredRowValue(
|
||||
global::System.Text.Json.JsonElement row,
|
||||
string propertyName,
|
||||
int rowNumber,
|
||||
string fieldName,
|
||||
string displayName)
|
||||
{
|
||||
if (!row.TryGetProperty(propertyName, out var property)
|
||||
|| property.ValueKind is global::System.Text.Json.JsonValueKind.Null
|
||||
or global::System.Text.Json.JsonValueKind.Undefined)
|
||||
{
|
||||
throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}");
|
||||
}
|
||||
|
||||
var value = property.ValueKind == global::System.Text.Json.JsonValueKind.String
|
||||
? property.GetString()
|
||||
: property.ToString();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}");
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static void ValidateJson(
|
||||
string json,
|
||||
global::System.Text.Json.JsonValueKind expectedKind,
|
||||
string fieldName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = global::System.Text.Json.JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != expectedKind)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
}
|
||||
catch (global::System.Text.Json.JsonException)
|
||||
{
|
||||
throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写");
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-46
@@ -25,11 +25,19 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
/// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。
|
||||
/// </summary>
|
||||
public async Task<OnboardingTaskPublicDetailOutput> Detail(OnboardingTaskPublicAccessInput input)
|
||||
{
|
||||
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => DetailCore(input));
|
||||
}
|
||||
|
||||
private async Task<OnboardingTaskPublicDetailOutput> DetailCore(OnboardingTaskPublicAccessInput input)
|
||||
{
|
||||
var link = await GetActiveLink(input.Token);
|
||||
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
|
||||
?? throw Oops.Bah("入职任务不存在");
|
||||
|
||||
// 员工端必须展示当前 Token 的实际到期时间,不能依赖任务表中的冗余时间。
|
||||
task.LinkExpireAt = link.ExpiresAt;
|
||||
|
||||
link.AccessCount++;
|
||||
link.LastAccessedAt = DateTime.Now;
|
||||
await _db.Updateable(link).ExecuteCommandAsync();
|
||||
@@ -37,6 +45,8 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
.Where(item => item.TaskId == task.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.ToListAsync();
|
||||
var formRequirements =
|
||||
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
|
||||
|
||||
var contracts = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == task.Id)
|
||||
@@ -103,7 +113,17 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
Contracts = contracts,
|
||||
DynamicValues = new List<ObTaskFormValue>(),
|
||||
Materials = materialOutputs,
|
||||
FormFields = new List<OnboardingPublicFormFieldOutput>(),
|
||||
FormFields = formRequirements
|
||||
.Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField)
|
||||
.Select(requirement => new OnboardingPublicFormFieldOutput
|
||||
{
|
||||
FieldId = requirement.FieldId,
|
||||
FieldCode = requirement.FieldCode,
|
||||
FieldName = requirement.FieldName,
|
||||
DataType = requirement.DataType,
|
||||
OptionsJson = requirement.OptionsJson,
|
||||
Required = requirement.Required
|
||||
}).ToList(),
|
||||
MaterialRequirements = materialRequirements.Select(requirement => new OnboardingPublicMaterialOutput
|
||||
{
|
||||
MaterialTypeId = requirement.MaterialTypeId,
|
||||
@@ -121,6 +141,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
/// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。
|
||||
/// </summary>
|
||||
public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveEmployeeInfoCore(input), transactional: true);
|
||||
}
|
||||
|
||||
private async Task SaveEmployeeInfoCore(OnboardingTaskPublicSaveInput input)
|
||||
{
|
||||
var link = await GetActiveLink(input.Token);
|
||||
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
|
||||
@@ -131,7 +156,9 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
throw Oops.Bah("当前任务不可编辑");
|
||||
}
|
||||
|
||||
ValidateEmployeeInfo(input);
|
||||
var formRequirements =
|
||||
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
|
||||
OnboardingEmployeeInfoValidator.ValidateAndNormalize(input, formRequirements);
|
||||
await _identityValidationService.EnsureNoDuplicate(
|
||||
task.Id,
|
||||
input.EmployeeName,
|
||||
@@ -170,51 +197,17 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
await _snapshotService.Build(task.Id, entity, invalidateGeneratedPreview: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验候选人核心身份信息,避免非法格式进入合同和正式员工档案。
|
||||
/// </summary>
|
||||
private static void ValidateEmployeeInfo(OnboardingTaskPublicSaveInput input)
|
||||
{
|
||||
input.EmployeeName = input.EmployeeName?.Trim();
|
||||
input.Phone = input.Phone?.Trim();
|
||||
input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100)
|
||||
throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符");
|
||||
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
input.Phone ?? string.Empty,
|
||||
"^1[3-9]\\d{9}$"))
|
||||
{
|
||||
throw Oops.Bah("请输入正确的 11 位手机号码");
|
||||
}
|
||||
|
||||
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
input.IdCardNumber ?? string.Empty,
|
||||
"^\\d{17}[0-9X]$"))
|
||||
{
|
||||
throw Oops.Bah("请输入正确的 18 位身份证号码");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.CustomVariablesJson))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var document = global::System.Text.Json.JsonDocument.Parse(input.CustomVariablesJson);
|
||||
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Object)
|
||||
throw new global::System.Text.Json.JsonException();
|
||||
}
|
||||
catch (global::System.Text.Json.JsonException)
|
||||
{
|
||||
throw Oops.Bah("员工补充变量格式不正确,请刷新页面后重新填写");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据员工当前已填写信息校验变量并提前生成合同 DOCX。
|
||||
/// </summary>
|
||||
public async Task<OnboardingPublicContractPrepareOutput> PrepareContract(
|
||||
OnboardingPublicContractSourceInput input)
|
||||
{
|
||||
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => PrepareContractCore(input));
|
||||
}
|
||||
|
||||
private async Task<OnboardingPublicContractPrepareOutput> PrepareContractCore(
|
||||
OnboardingPublicContractSourceInput input)
|
||||
{
|
||||
var link = await GetActiveLink(input.Token);
|
||||
var contract = await _db.Queryable<ObTaskContract>()
|
||||
@@ -237,10 +230,9 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
}
|
||||
|
||||
await _snapshotService.Build(link.TaskId);
|
||||
var fileId = await _contractDocumentService.Generate(new OnboardingGenerateContractInput
|
||||
{
|
||||
TaskContractId = contract.Id
|
||||
});
|
||||
var fileId = await _contractDocumentService.GenerateForPublicAccess(
|
||||
contract.Id,
|
||||
link.TaskId);
|
||||
|
||||
var generatedAt = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.Id == contract.Id)
|
||||
@@ -257,6 +249,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
|
||||
/// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。
|
||||
/// </summary>
|
||||
public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input)
|
||||
{
|
||||
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => GetContractSourceFileIdCore(input));
|
||||
}
|
||||
|
||||
private async Task<long> GetContractSourceFileIdCore(OnboardingPublicContractSourceInput input)
|
||||
{
|
||||
var link = await GetActiveLink(input.Token);
|
||||
var contract = await _db.Queryable<ObTaskContract>()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
@@ -23,6 +25,11 @@ public class OnboardingTaskCreateInput
|
||||
[IdNotNull(ErrorMessage = "岗位Id不能为空")]
|
||||
public long PositionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用工类型:full_time 正式用工,part_time 兼职用工。
|
||||
/// </summary>
|
||||
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
|
||||
|
||||
/// <summary>
|
||||
/// 本次任务使用的入职模板 Id。
|
||||
/// </summary>
|
||||
@@ -47,6 +54,41 @@ public class OnboardingTaskCreateInput
|
||||
/// <summary>
|
||||
/// 业务备注。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "人事备注不能为空")]
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量复制入职任务参数。
|
||||
/// </summary>
|
||||
public class OnboardingTaskCopyInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 被复制的原入职任务 Id。
|
||||
/// </summary>
|
||||
[IdNotNull(ErrorMessage = "原入职任务Id不能为空")]
|
||||
public long SourceTaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 在原任务之外需要新建的任务数量。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 每条新任务分别填写的人事备注。
|
||||
/// </summary>
|
||||
public List<OnboardingTaskCopyItemInput> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单条复制任务的个性化参数。
|
||||
/// </summary>
|
||||
public class OnboardingTaskCopyItemInput
|
||||
{
|
||||
/// <summary>
|
||||
/// 新任务的人事备注,用于区分候选人和专属链接。
|
||||
/// </summary>
|
||||
[Required(ErrorMessage = "人事备注不能为空")]
|
||||
public string Remark { get; set; }
|
||||
}
|
||||
|
||||
@@ -96,4 +138,14 @@ public class OnboardingTaskCreatedOutput
|
||||
/// 员工填写链接过期时间。
|
||||
/// </summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 批量复制入职任务结果。
|
||||
/// </summary>
|
||||
public class OnboardingTaskCopyOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 本次复制生成的全部新任务及独立链接信息。
|
||||
/// </summary>
|
||||
public List<OnboardingTaskCreatedOutput> Tasks { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -79,24 +79,6 @@ public class OnboardingTaskPublicSaveInput : ObTaskEmployeeInfo
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public string Token { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 员工姓名。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public new string EmployeeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 手机号码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public new string Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 身份证号码。
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.DataAnnotations.Required]
|
||||
public new string IdCardNumber { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,4 +9,9 @@ public interface IOnboardingTaskService : ITransient
|
||||
/// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。
|
||||
/// </summary>
|
||||
Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 复制原任务的业务配置和人事参数,批量创建独立的新入职任务。
|
||||
/// </summary>
|
||||
Task<OnboardingTaskCopyOutput> Copy(OnboardingTaskCopyInput input);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,17 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IConfigService _configService;
|
||||
private readonly IOnboardingContractSnapshotService _snapshotService;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingTaskService(
|
||||
IConfigService configService,
|
||||
IOnboardingContractSnapshotService snapshotService)
|
||||
IOnboardingContractSnapshotService snapshotService,
|
||||
IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_configService = configService;
|
||||
_snapshotService = snapshotService;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -23,6 +26,11 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
/// </summary>
|
||||
public async Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Remark))
|
||||
throw Oops.Bah("请填写人事备注");
|
||||
|
||||
ValidateEmploymentType(input.EmploymentType);
|
||||
|
||||
var companyEnabled = await _db.Queryable<ObCompany>()
|
||||
.AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable");
|
||||
if (!companyEnabled)
|
||||
@@ -72,7 +80,10 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
if (contractTemplates.Count != templateIds.Count
|
||||
|| contractTemplates.Any(item => item.CompanyId != input.CompanyId
|
||||
|| item.DepartmentId != input.DepartmentId
|
||||
|| item.Status != "enable"))
|
||||
|| !string.Equals(
|
||||
item.Status,
|
||||
"enable",
|
||||
global::System.StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板");
|
||||
}
|
||||
@@ -84,11 +95,13 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
|
||||
|
||||
var versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).Distinct().ToList();
|
||||
var enabledVersionCount = await _db.Queryable<ObContractTemplateVersion>()
|
||||
.CountAsync(item => versionIds.Contains(item.Id)
|
||||
&& item.VersionStatus == ContractTemplateVersionStatus.Enabled);
|
||||
if (enabledVersionCount != versionIds.Count)
|
||||
throw Oops.Bah("入职模板包含未启用的合同版本,请先调整模板配置");
|
||||
var now = DateTime.Now;
|
||||
var taskVersions = await _db.Queryable<ObContractTemplateVersion>()
|
||||
.Where(item => versionIds.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (taskVersions.Count != versionIds.Count
|
||||
|| taskVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now)))
|
||||
throw Oops.Bah("入职模板包含未生效、已失效或未启用的签署材料版本,请先调整模板配置");
|
||||
|
||||
var materialIds = materialConfigs.Select(item => item.MaterialTypeId).Distinct().ToList();
|
||||
var materialTypes = materialIds.Count == 0
|
||||
@@ -117,11 +130,12 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
CompanyId = input.CompanyId,
|
||||
DepartmentId = input.DepartmentId,
|
||||
PositionId = input.PositionId,
|
||||
EmploymentType = input.EmploymentType,
|
||||
OnboardingTemplateId = onboardingTemplate.Id,
|
||||
AssignedReviewerUserId = input.AssignedReviewerUserId,
|
||||
LinkExpireAt = expiresAt,
|
||||
TaskStatus = OnboardingTaskStatus.PendingFill,
|
||||
Remark = input.Remark
|
||||
Remark = input.Remark.Trim()
|
||||
};
|
||||
|
||||
ValidateInitialContractDateRanges(input.ContractVariables);
|
||||
@@ -149,9 +163,15 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
CreateTime = DateTime.Now
|
||||
}).ExecuteCommandAsync();
|
||||
|
||||
var employeeFieldRequirements =
|
||||
await OnboardingEmployeeFieldRequirementSnapshot.BuildFromCurrentDefinitions(_db, taskId);
|
||||
if (employeeFieldRequirements.Count > 0)
|
||||
await _db.Insertable(employeeFieldRequirements).ExecuteCommandAsync();
|
||||
|
||||
var contracts = contractConfigs.Select(config =>
|
||||
{
|
||||
var template = contractTemplates.First(item => item.Id == config.ContractTemplateId);
|
||||
var version = taskVersions.First(item => item.Id == config.ContractTemplateVersionId);
|
||||
return new ObTaskContract
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
@@ -161,6 +181,8 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
ContractName = template.TemplateName,
|
||||
ContractType = template.ContractType,
|
||||
Required = config.Required,
|
||||
CompanySealEnabled = version.CompanySealEnabled,
|
||||
AcrossPageSealEnabled = version.AcrossPageSealEnabled,
|
||||
PrintCopies = config.PrintCopies
|
||||
};
|
||||
}).ToList();
|
||||
@@ -207,6 +229,265 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 复制原任务锁定的业务配置与人事参数,并原子化创建多条独立任务。
|
||||
/// </summary>
|
||||
public async Task<OnboardingTaskCopyOutput> Copy(OnboardingTaskCopyInput input)
|
||||
{
|
||||
const int maxCopyCount = 50;
|
||||
if (input.Count < 1 || input.Count > maxCopyCount)
|
||||
throw Oops.Bah($"复制数量必须在1到{maxCopyCount}之间");
|
||||
|
||||
input.Items ??= new List<OnboardingTaskCopyItemInput>();
|
||||
if (input.Items.Count != input.Count)
|
||||
throw Oops.Bah("复制数量与人事备注数量不一致");
|
||||
|
||||
var missingRemarkIndex = input.Items.FindIndex(item =>
|
||||
string.IsNullOrWhiteSpace(item.Remark));
|
||||
if (missingRemarkIndex >= 0)
|
||||
throw Oops.Bah($"请填写第{missingRemarkIndex + 1}份任务的人事备注");
|
||||
|
||||
var sourceTask = await _db.Queryable<ObOnboardingTask>()
|
||||
.InSingleAsync(input.SourceTaskId)
|
||||
?? throw Oops.Bah("原入职任务不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
sourceTask.CreateUserId,
|
||||
OnboardingPermissionCodes.TaskManageAll,
|
||||
"入职任务");
|
||||
if (!sourceTask.DepartmentId.HasValue)
|
||||
throw Oops.Bah("原入职任务未设置部门,不能复制");
|
||||
|
||||
var companyEnabled = await _db.Queryable<ObCompany>()
|
||||
.AnyAsync(item => item.Id == sourceTask.CompanyId && item.Status == "enable");
|
||||
if (!companyEnabled)
|
||||
throw Oops.Bah("原任务所属公司不存在或未启用");
|
||||
|
||||
var departmentEnabled = await _db.Queryable<ObDepartment>()
|
||||
.AnyAsync(item => item.Id == sourceTask.DepartmentId.Value
|
||||
&& item.CompanyId == sourceTask.CompanyId
|
||||
&& item.Status == "enable");
|
||||
if (!departmentEnabled)
|
||||
throw Oops.Bah("原任务所属部门不存在或未启用");
|
||||
|
||||
var positionEnabled = await _db.Queryable<ObPosition>()
|
||||
.AnyAsync(item => item.Id == sourceTask.PositionId
|
||||
&& item.CompanyId == sourceTask.CompanyId
|
||||
&& item.DepartmentId == sourceTask.DepartmentId.Value
|
||||
&& item.Status == "enable");
|
||||
if (!positionEnabled)
|
||||
throw Oops.Bah("原任务所属岗位不存在或未启用");
|
||||
|
||||
var onboardingTemplateEnabled = await _db.Queryable<ObOnboardingTemplate>()
|
||||
.AnyAsync(item => item.Id == sourceTask.OnboardingTemplateId
|
||||
&& item.CompanyId == sourceTask.CompanyId
|
||||
&& item.DepartmentId == sourceTask.DepartmentId.Value
|
||||
&& item.Status == "enable");
|
||||
if (!onboardingTemplateEnabled)
|
||||
throw Oops.Bah("原任务使用的入职模板不存在或未启用");
|
||||
|
||||
var sourceContracts = await _db.Queryable<ObTaskContract>()
|
||||
.Where(item => item.TaskId == sourceTask.Id)
|
||||
.ToListAsync();
|
||||
if (sourceContracts.Count == 0)
|
||||
throw Oops.Bah("原入职任务没有合同快照,不能复制");
|
||||
|
||||
var versionIds = sourceContracts
|
||||
.Select(item => item.TemplateVersionId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var now = DateTime.Now;
|
||||
var copyVersions = await _db.Queryable<ObContractTemplateVersion>()
|
||||
.Where(item => versionIds.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (copyVersions.Count != versionIds.Count
|
||||
|| copyVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now)))
|
||||
throw Oops.Bah("原任务包含未生效、已失效或未启用的签署材料版本,不能继续复制");
|
||||
|
||||
var sourceMaterialRequirements = await _db.Queryable<ObTaskMaterialRequirement>()
|
||||
.Where(item => item.TaskId == sourceTask.Id)
|
||||
.OrderBy(item => item.SortCode)
|
||||
.ToListAsync();
|
||||
var sourceEmployeeFieldRequirements =
|
||||
(await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, sourceTask.Id))
|
||||
.Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField)
|
||||
.ToList();
|
||||
var sourceContractIds = sourceContracts.Select(item => item.Id).ToList();
|
||||
var sourceManualVariables = await _db.Queryable<ObTaskContractVariable>()
|
||||
.Where(item => sourceContractIds.Contains(item.TaskContractId)
|
||||
&& item.SourceType == ContractVariableSourceType.HrInput
|
||||
&& item.IsManualOverride)
|
||||
.ToListAsync();
|
||||
|
||||
var initialContractVariables = (
|
||||
from contract in sourceContracts
|
||||
join variable in sourceManualVariables
|
||||
on contract.Id equals variable.TaskContractId
|
||||
select new OnboardingInitialContractVariableInput
|
||||
{
|
||||
ContractTemplateVersionId = contract.TemplateVersionId,
|
||||
VariableCode = variable.VariableCode,
|
||||
VariableValue = variable.VariableValue
|
||||
}).ToList();
|
||||
ValidateInitialContractDateRanges(initialContractVariables);
|
||||
|
||||
var expiryConfig = await _configService.GetByConfigKey(
|
||||
CateGoryConst.CONFIG_BIZ_DEFINE,
|
||||
"onboard.link.expireHours");
|
||||
if (expiryConfig == null
|
||||
|| !int.TryParse(expiryConfig.ConfigValue, out var expiryHours)
|
||||
|| expiryHours <= 0)
|
||||
{
|
||||
throw Oops.Bah("请在系统配置中设置 onboard.link.expireHours(正整数小时)");
|
||||
}
|
||||
|
||||
var outputs = new List<OnboardingTaskCreatedOutput>();
|
||||
await _db.Ado.BeginTranAsync();
|
||||
try
|
||||
{
|
||||
foreach (var item in input.Items)
|
||||
{
|
||||
var output = await CreateCopiedTask(
|
||||
sourceTask,
|
||||
sourceContracts,
|
||||
sourceMaterialRequirements,
|
||||
sourceEmployeeFieldRequirements,
|
||||
initialContractVariables,
|
||||
expiryHours,
|
||||
item.Remark.Trim());
|
||||
outputs.Add(output);
|
||||
}
|
||||
|
||||
await _db.Ado.CommitTranAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _db.Ado.RollbackTranAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new OnboardingTaskCopyOutput
|
||||
{
|
||||
Tasks = outputs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据原任务快照创建一条全新的待填写任务,不复制任何候选人业务数据。
|
||||
/// </summary>
|
||||
private async Task<OnboardingTaskCreatedOutput> CreateCopiedTask(
|
||||
ObOnboardingTask sourceTask,
|
||||
List<ObTaskContract> sourceContracts,
|
||||
List<ObTaskMaterialRequirement> sourceMaterialRequirements,
|
||||
List<ObTaskFormRequirement> sourceEmployeeFieldRequirements,
|
||||
List<OnboardingInitialContractVariableInput> initialContractVariables,
|
||||
int expiryHours,
|
||||
string remark)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
|
||||
var expiresAt = now.AddHours(expiryHours);
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var task = new ObOnboardingTask
|
||||
{
|
||||
Id = taskId,
|
||||
TaskNo = $"OB{now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}",
|
||||
CompanyId = sourceTask.CompanyId,
|
||||
DepartmentId = sourceTask.DepartmentId,
|
||||
PositionId = sourceTask.PositionId,
|
||||
EmploymentType = string.IsNullOrWhiteSpace(sourceTask.EmploymentType)
|
||||
? EmployeeEmploymentType.FullTime
|
||||
: sourceTask.EmploymentType,
|
||||
OnboardingTemplateId = sourceTask.OnboardingTemplateId,
|
||||
AssignedReviewerUserId = sourceTask.AssignedReviewerUserId,
|
||||
LinkExpireAt = expiresAt,
|
||||
TaskStatus = OnboardingTaskStatus.PendingFill,
|
||||
Remark = remark
|
||||
};
|
||||
|
||||
await _db.Insertable(task).ExecuteCommandAsync();
|
||||
await _db.Insertable(new ObTaskLink
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
PlainToken = token,
|
||||
TokenHash = Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(
|
||||
global::System.Text.Encoding.UTF8.GetBytes(token))),
|
||||
ExpiresAt = expiresAt
|
||||
}).ExecuteCommandAsync();
|
||||
|
||||
await _db.Insertable(new ObTaskEmployeeInfo
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
Status = CommonStatusConst.ENABLE,
|
||||
CreateTime = now
|
||||
}).ExecuteCommandAsync();
|
||||
|
||||
if (sourceEmployeeFieldRequirements.Count > 0)
|
||||
{
|
||||
var copiedEmployeeFieldRequirements = sourceEmployeeFieldRequirements.Select(source =>
|
||||
new ObTaskFormRequirement
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
FieldId = source.FieldId,
|
||||
FieldCode = source.FieldCode,
|
||||
FieldName = source.FieldName,
|
||||
DataType = source.DataType,
|
||||
OptionsJson = source.OptionsJson,
|
||||
Required = source.Required,
|
||||
SortCode = source.SortCode
|
||||
}).ToList();
|
||||
await _db.Insertable(copiedEmployeeFieldRequirements).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
var copiedContracts = sourceContracts.Select(source => new ObTaskContract
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
TemplateId = source.TemplateId,
|
||||
TemplateVersionId = source.TemplateVersionId,
|
||||
ContractName = source.ContractName,
|
||||
ContractType = source.ContractType,
|
||||
Required = source.Required,
|
||||
CompanySealEnabled = source.CompanySealEnabled,
|
||||
AcrossPageSealEnabled = source.AcrossPageSealEnabled,
|
||||
PrintCopies = source.PrintCopies
|
||||
}).ToList();
|
||||
await _db.Insertable(copiedContracts).ExecuteCommandAsync();
|
||||
|
||||
if (sourceMaterialRequirements.Count > 0)
|
||||
{
|
||||
var copiedRequirements = sourceMaterialRequirements.Select(source =>
|
||||
new ObTaskMaterialRequirement
|
||||
{
|
||||
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
|
||||
TaskId = taskId,
|
||||
MaterialTypeId = source.MaterialTypeId,
|
||||
MaterialCode = source.MaterialCode,
|
||||
MaterialName = source.MaterialName,
|
||||
AllowedExtensions = source.AllowedExtensions,
|
||||
MaxFileCount = source.MaxFileCount,
|
||||
MaxFileSizeMb = source.MaxFileSizeMb,
|
||||
Required = source.Required,
|
||||
SortCode = source.SortCode
|
||||
}).ToList();
|
||||
await _db.Insertable(copiedRequirements).ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
// 重新生成公司、系统和空员工变量,再覆盖原任务的人事参数。
|
||||
await _snapshotService.Build(taskId);
|
||||
await ApplyInitialContractVariables(initialContractVariables, copiedContracts);
|
||||
|
||||
return new OnboardingTaskCreatedOutput
|
||||
{
|
||||
TaskId = taskId,
|
||||
TaskNo = task.TaskNo,
|
||||
LinkToken = token,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验并保存任务创建时填写的人事合同变量。
|
||||
/// </summary>
|
||||
@@ -289,7 +570,7 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
/// <summary>
|
||||
/// 校验人事创建任务时填写的合同日期先后关系。
|
||||
/// </summary>
|
||||
private static void ValidateInitialContractDateRanges(
|
||||
internal static void ValidateInitialContractDateRanges(
|
||||
List<OnboardingInitialContractVariableInput> inputs)
|
||||
{
|
||||
inputs ??= new List<OnboardingInitialContractVariableInput>();
|
||||
@@ -323,4 +604,13 @@ public class OnboardingTaskService : IOnboardingTaskService
|
||||
if (endDate.Date < startDate.Date)
|
||||
throw Oops.Bah($"{rangeName}结束日期不能早于开始日期");
|
||||
}
|
||||
|
||||
private static void ValidateEmploymentType(string employmentType)
|
||||
{
|
||||
if (employmentType != EmployeeEmploymentType.FullTime
|
||||
&& employmentType != EmployeeEmploymentType.PartTime)
|
||||
{
|
||||
throw Oops.Bah("用工类型不正确,请选择正式员工或兼职员工");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -6,9 +6,13 @@ namespace SimpleAdmin.Application;
|
||||
public class OnboardingTaskContractVariableService : IOnboardingTaskContractVariableService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingTaskContractVariableService()
|
||||
=> _db = DbContext.DB;
|
||||
public OnboardingTaskContractVariableService(IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询任务下全部合同及其变量当前值。
|
||||
@@ -77,6 +81,11 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
|
||||
/// 保存允许人事编辑的任务合同变量值。
|
||||
/// </summary>
|
||||
public async Task Save(OnboardingTaskContractVariableSaveInput input)
|
||||
{
|
||||
await OnboardingConcurrency.RunContract(_db, input.TaskContractId, () => SaveCore(input), transactional: true);
|
||||
}
|
||||
|
||||
private async Task SaveCore(OnboardingTaskContractVariableSaveInput input)
|
||||
{
|
||||
var contract = await _db.Queryable<ObTaskContract>()
|
||||
.InSingleAsync(input.TaskContractId)
|
||||
@@ -85,6 +94,10 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
|
||||
var task = await _db.Queryable<ObOnboardingTask>()
|
||||
.InSingleAsync(contract.TaskId)
|
||||
?? throw Oops.Bah("入职任务不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
task.CreateUserId,
|
||||
OnboardingPermissionCodes.TaskManageAll,
|
||||
"入职任务");
|
||||
|
||||
if (task.TaskStatus != OnboardingTaskStatus.PendingFill &&
|
||||
task.TaskStatus != OnboardingTaskStatus.Rejected)
|
||||
|
||||
+64
-1
@@ -1,4 +1,4 @@
|
||||
namespace SimpleAdmin.Application;
|
||||
namespace SimpleAdmin.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务分页查询参数。
|
||||
@@ -24,6 +24,64 @@ public class OnboardingTaskPageInput : BasePageInput
|
||||
/// 入职业务部门 Id。
|
||||
/// </summary>
|
||||
public long? DepartmentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 入职业务岗位 Id。
|
||||
/// </summary>
|
||||
public long? PositionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务分页数据及当前查询条件下的状态统计。
|
||||
/// </summary>
|
||||
public class OnboardingTaskPageOutput : SqlSugarPagedList<ObOnboardingTask>
|
||||
{
|
||||
/// <summary>
|
||||
/// 不受状态筛选影响的任务状态统计。
|
||||
/// </summary>
|
||||
public OnboardingTaskStatisticsOutput Statistics { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 入职任务状态统计。
|
||||
/// </summary>
|
||||
public class OnboardingTaskStatisticsOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 待候选人填写数量。
|
||||
/// </summary>
|
||||
public int PendingFill { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 待人事审核数量。
|
||||
/// </summary>
|
||||
public int Reviewing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 已退回待修改数量。
|
||||
/// </summary>
|
||||
public int Rejected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 审核通过或已归档数量。
|
||||
/// </summary>
|
||||
public int Approved { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 数据库按任务状态分组后的内部统计行。
|
||||
/// </summary>
|
||||
public class OnboardingTaskStatusCountOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 任务状态。
|
||||
/// </summary>
|
||||
public string TaskStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 该状态的任务数量。
|
||||
/// </summary>
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -76,4 +134,9 @@ public class OnboardingTaskMaterialDetailOutput : ObTaskMaterial
|
||||
/// 材料文件后缀,用于判断是否支持图片预览。
|
||||
/// </summary>
|
||||
public string FileSuffix { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 格式化后的文件大小,例如 1.5MB。
|
||||
/// </summary>
|
||||
public string FileSizeInfo { get; set; }
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ public interface IOnboardingTaskQueryService : ITransient
|
||||
/// <summary>
|
||||
/// 分页查询后台入职任务。
|
||||
/// </summary>
|
||||
Task<SqlSugarPagedList<ObOnboardingTask>> Page(OnboardingTaskPageInput input);
|
||||
Task<OnboardingTaskPageOutput> Page(OnboardingTaskPageInput input);
|
||||
|
||||
/// <summary>
|
||||
/// 查询任务基础信息、人员资料、合同、材料、表单值、链接和审核记录。
|
||||
|
||||
+89
-9
@@ -7,32 +7,92 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IConfigService _configService;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
public OnboardingTaskQueryService(IConfigService configService)
|
||||
public OnboardingTaskQueryService(
|
||||
IConfigService configService,
|
||||
IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_configService = configService;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询后台入职任务。
|
||||
/// </summary>
|
||||
public async Task<SqlSugarPagedList<ObOnboardingTask>> Page(OnboardingTaskPageInput input)
|
||||
public async Task<OnboardingTaskPageOutput> Page(OnboardingTaskPageInput input)
|
||||
{
|
||||
const string approvedGroupStatus = "approved_or_archived";
|
||||
var filterApprovedGroup = string.Equals(
|
||||
input.TaskStatus,
|
||||
approvedGroupStatus,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var list = await _db.Queryable<ObOnboardingTask>()
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo), item => item.TaskNo.Contains(input.TaskNo))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskStatus), item => item.TaskStatus == input.TaskStatus)
|
||||
.WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value)
|
||||
.WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value)
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo),
|
||||
item => item.TaskNo.Contains(input.TaskNo))
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskStatus) && !filterApprovedGroup,
|
||||
item => item.TaskStatus == input.TaskStatus)
|
||||
.WhereIF(filterApprovedGroup,
|
||||
item => item.TaskStatus == OnboardingTaskStatus.Approved
|
||||
|| item.TaskStatus == OnboardingTaskStatus.Archived)
|
||||
.WhereIF(input.CompanyId.HasValue,
|
||||
item => item.CompanyId == input.CompanyId.Value)
|
||||
.WhereIF(input.DepartmentId.HasValue,
|
||||
item => item.DepartmentId == input.DepartmentId.Value)
|
||||
.WhereIF(input.PositionId.HasValue,
|
||||
item => item.PositionId == input.PositionId.Value)
|
||||
.OrderByDescending(item => item.CreateTime)
|
||||
.ToPagedListAsync(input.PageNum, input.PageSize);
|
||||
|
||||
if (list.List != null && list.List.Any())
|
||||
{
|
||||
await FillTaskMetadata(list.List);
|
||||
}
|
||||
|
||||
return list;
|
||||
// 统计继续应用组织、岗位和任务编号条件,但不受当前状态筛选影响。
|
||||
var statusRows = await _db.Queryable<ObOnboardingTask>()
|
||||
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo),
|
||||
item => item.TaskNo.Contains(input.TaskNo))
|
||||
.WhereIF(input.CompanyId.HasValue,
|
||||
item => item.CompanyId == input.CompanyId.Value)
|
||||
.WhereIF(input.DepartmentId.HasValue,
|
||||
item => item.DepartmentId == input.DepartmentId.Value)
|
||||
.WhereIF(input.PositionId.HasValue,
|
||||
item => item.PositionId == input.PositionId.Value)
|
||||
.GroupBy(item => item.TaskStatus)
|
||||
.Select(item => new OnboardingTaskStatusCountOutput
|
||||
{
|
||||
TaskStatus = item.TaskStatus,
|
||||
Count = SqlFunc.AggregateCount(item.Id)
|
||||
})
|
||||
.ToListAsync();
|
||||
var statusCountMap = statusRows.ToDictionary(
|
||||
item => item.TaskStatus,
|
||||
item => item.Count);
|
||||
|
||||
return new OnboardingTaskPageOutput
|
||||
{
|
||||
PageNum = list.PageNum,
|
||||
PageSize = list.PageSize,
|
||||
Total = list.Total,
|
||||
Pages = list.Pages,
|
||||
List = list.List,
|
||||
HasPrevPages = list.HasPrevPages,
|
||||
HasNextPages = list.HasNextPages,
|
||||
Statistics = new OnboardingTaskStatisticsOutput
|
||||
{
|
||||
PendingFill = statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.PendingFill),
|
||||
Reviewing = statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.Reviewing),
|
||||
Rejected = statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.Rejected),
|
||||
Approved = statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.Approved)
|
||||
+ statusCountMap.GetValueOrDefault(
|
||||
OnboardingTaskStatus.Archived)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,6 +128,7 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
|
||||
{
|
||||
output.FileName = file.Name;
|
||||
output.FileSuffix = file.Suffix;
|
||||
output.FileSizeInfo = file.SizeInfo;
|
||||
}
|
||||
|
||||
return output;
|
||||
@@ -96,9 +157,18 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
|
||||
/// 获取或重新生成任务有效填写链接。
|
||||
/// </summary>
|
||||
public async Task<OnboardingTaskCreatedOutput> GetTaskLink(BaseIdInput input)
|
||||
{
|
||||
return await OnboardingConcurrency.RunTask(_db, input.Id, () => GetTaskLinkCore(input));
|
||||
}
|
||||
|
||||
private async Task<OnboardingTaskCreatedOutput> GetTaskLinkCore(BaseIdInput input)
|
||||
{
|
||||
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(input.Id)
|
||||
?? throw Oops.Bah("入职任务不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
task.CreateUserId,
|
||||
OnboardingPermissionCodes.TaskManageAll,
|
||||
"入职任务");
|
||||
var canShareLink = task.TaskStatus == OnboardingTaskStatus.PendingFill
|
||||
|| task.TaskStatus == OnboardingTaskStatus.Rejected;
|
||||
if (!canShareLink)
|
||||
@@ -116,6 +186,16 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
|
||||
|
||||
if (activeLink != null)
|
||||
{
|
||||
// 修复历史数据或旧页面造成的冗余到期时间不一致。
|
||||
var activeExpiresAt = activeLink.ExpiresAt;
|
||||
if (task.LinkExpireAt != activeExpiresAt)
|
||||
{
|
||||
await _db.Updateable<ObOnboardingTask>()
|
||||
.SetColumns(item => item.LinkExpireAt == activeExpiresAt)
|
||||
.Where(item => item.Id == task.Id)
|
||||
.ExecuteCommandAsync();
|
||||
}
|
||||
|
||||
return new OnboardingTaskCreatedOutput
|
||||
{
|
||||
TaskId = task.Id,
|
||||
|
||||
+17
-1
@@ -6,13 +6,15 @@
|
||||
public class OnboardingVariableDefinitionService : IOnboardingVariableDefinitionService
|
||||
{
|
||||
private readonly SqlSugarScope _db;
|
||||
private readonly IOnboardingDataPermissionService _dataPermission;
|
||||
|
||||
/// <summary>
|
||||
/// 初始化合同变量字典服务。
|
||||
/// </summary>
|
||||
public OnboardingVariableDefinitionService()
|
||||
public OnboardingVariableDefinitionService(IOnboardingDataPermissionService dataPermission)
|
||||
{
|
||||
_db = DbContext.DB;
|
||||
_dataPermission = dataPermission;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -62,6 +64,10 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition
|
||||
|
||||
var entity = await _db.Queryable<ObVariableDefinition>().InSingleAsync(input.Id)
|
||||
?? throw Oops.Bah("变量不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
entity.CreateUserId,
|
||||
OnboardingPermissionCodes.EntryConfigManageAll,
|
||||
"合同变量");
|
||||
|
||||
if (!string.Equals(entity.VariableCode, input.VariableCode, StringComparison.Ordinal))
|
||||
throw Oops.Bah("变量编码创建后不能修改");
|
||||
@@ -86,6 +92,16 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition
|
||||
if (input.Ids.Count == 0)
|
||||
return;
|
||||
|
||||
var variables = await _db.Queryable<ObVariableDefinition>()
|
||||
.Where(item => input.Ids.Contains(item.Id))
|
||||
.ToListAsync();
|
||||
if (variables.Count != input.Ids.Distinct().Count())
|
||||
throw Oops.Bah("变量不存在");
|
||||
await _dataPermission.EnsureCanManage(
|
||||
variables.Select(item => item.CreateUserId),
|
||||
OnboardingPermissionCodes.EntryConfigManageAll,
|
||||
"合同变量");
|
||||
|
||||
var hasBuiltIn = await _db.Queryable<ObVariableDefinition>()
|
||||
.Where(it => input.Ids.Contains(it.Id) && it.IsBuiltIn)
|
||||
.AnyAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
|
||||
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
|
||||
//
|
||||
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
|
||||
// 1.请不要删除和修改根目录下的LICENSE文件。
|
||||
@@ -45,26 +45,37 @@ public class BasePageInput : IValidatableObject
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
//配合小诺排序参数
|
||||
if (SortOrder == "descend")
|
||||
SortOrder = (string.IsNullOrWhiteSpace(SortOrder) ? "desc" : SortOrder.Trim().ToLowerInvariant()) switch
|
||||
{
|
||||
SortOrder = "desc";
|
||||
}
|
||||
else if (SortOrder == "ascend")
|
||||
{
|
||||
SortOrder = "asc";
|
||||
}
|
||||
if (!string.IsNullOrEmpty(SortField))
|
||||
{
|
||||
//分割排序字段
|
||||
var fields = SortField.Split(" ");
|
||||
if (fields.Length > 1)
|
||||
{
|
||||
yield return new ValidationResult("排序字段错误", new[]
|
||||
{
|
||||
nameof(SortField)
|
||||
});
|
||||
}
|
||||
}
|
||||
"ascend" => "asc",
|
||||
"descend" => "desc",
|
||||
var value => value
|
||||
};
|
||||
if (SortOrder != "asc" && SortOrder != "desc")
|
||||
yield return new ValidationResult("排序方向仅支持 asc/desc", new[] { nameof(SortOrder) });
|
||||
|
||||
if (!string.IsNullOrEmpty(SortField)
|
||||
&& !global::System.Text.RegularExpressions.Regex.IsMatch(
|
||||
SortField, @"\A[A-Za-z_][A-Za-z0-9_]{0,63}\z"))
|
||||
yield return new ValidationResult("排序字段格式不正确", new[] { nameof(SortField) });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将客户端字段映射为服务端白名单列,禁止把排序表达式传入 ORM。
|
||||
/// 服务层再次校验,避免非 HTTP 调用绕过 MVC 参数校验。
|
||||
/// </summary>
|
||||
public string GetSafeOrderBy(params string[] allowedFields)
|
||||
{
|
||||
var error = Validate(new ValidationContext(this)).FirstOrDefault();
|
||||
if (error != null)
|
||||
throw Oops.Bah(error.ErrorMessage);
|
||||
if (string.IsNullOrEmpty(SortField))
|
||||
return string.Empty;
|
||||
var field = allowedFields.FirstOrDefault(item =>
|
||||
string.Equals(item, SortField, StringComparison.OrdinalIgnoreCase));
|
||||
if (field == null)
|
||||
throw Oops.Bah("不支持按此字段排序");
|
||||
return $"{field} {SortOrder}";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"ConnectionStrings": [
|
||||
{
|
||||
"ConfigId": "SimpleAdmin", //租户ID
|
||||
"ConnectionString": "AllowLoadLocalInfile=true;Server=116.168.197.2;User ID=admin;Password=so^867z$4S;Port=16306;Database=OnboardX;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串
|
||||
"ConnectionString": "AllowLoadLocalInfile=true;Server=onboardx-mysql.cnzhyx.cn;User ID=onboardx;Password=@gFYj78eNaTm3BS!JU)aUQ;Port=3306;Database=onboardx;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串
|
||||
"DbType": "MySql", //数据库类型
|
||||
"IsAutoCloseConnection": true //是否自动释放
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@ public class LoginOutPut
|
||||
/// </summary>
|
||||
public class LoginUserOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户Id
|
||||
/// </summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 头像
|
||||
///</summary>
|
||||
|
||||
@@ -34,7 +34,7 @@ public class FileService : DbRepository<SysFile>, IFileService
|
||||
{
|
||||
var query = Context.Queryable<SysFile>().WhereIF(!string.IsNullOrEmpty(input.Engine), it => it.Engine == input.Engine)//根据关键字查询
|
||||
.WhereIF(!string.IsNullOrEmpty(input.SearchKey), it => it.Name.Contains(input.SearchKey))//根据关键字查询
|
||||
.OrderByIF(!string.IsNullOrEmpty(input.SortField), $"{input.SortField} {input.SortOrder}")//排序
|
||||
.OrderByIF(!string.IsNullOrEmpty(input.SortField), input.GetSafeOrderBy("Id", "Name", "Engine", "Bucket", "Suffix", "SizeKb", "SizeInfo", "CreateTime", "UpdateTime", "CreateUser"))//排序
|
||||
.OrderBy(it => it.Id);
|
||||
var pageInfo = await query.ToPagedListAsync(input.PageNum, input.PageSize);//分页
|
||||
return pageInfo;
|
||||
@@ -115,10 +115,26 @@ public class FileService : DbRepository<SysFile>, IFileService
|
||||
IFormFile file = new FormFile(stream, 0, content.Length, "file", fileName)
|
||||
{
|
||||
Headers = new HeaderDictionary(),
|
||||
ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
ContentType = ResolveGeneratedContentType(fileName)
|
||||
};
|
||||
return await StorageFile(engine, file);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据归档文件扩展名设置 OSS 元数据,避免 PDF、ZIP 被错误标记为 DOCX。
|
||||
/// </summary>
|
||||
private static string ResolveGeneratedContentType(string fileName)
|
||||
{
|
||||
return Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||
{
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".pdf" => "application/pdf",
|
||||
".zip" => "application/zip",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
#region 方法
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -25,6 +25,10 @@ public class ConfigService : DbRepository<SysConfig>, IConfigService
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<SysConfig>> GetConfigsByCategory(string category)
|
||||
{
|
||||
// 业务自定义配置需立即生效,始终以数据库中的最新数据为准。
|
||||
if (category == CateGoryConst.CONFIG_BIZ_DEFINE)
|
||||
return await GetListAsync(it => it.Category == category);
|
||||
|
||||
var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key
|
||||
//先从redis拿配置
|
||||
var configList = _simpleCacheService.HashGetAll<SysConfig>(key).Values.ToList();
|
||||
@@ -51,6 +55,10 @@ public class ConfigService : DbRepository<SysConfig>, IConfigService
|
||||
/// <inheritdoc/>
|
||||
public async Task<SysConfig> GetByConfigKey(string category, string configKey)
|
||||
{
|
||||
// 业务自定义配置不走缓存,避免修改后仍读取到本机旧值。
|
||||
if (category == CateGoryConst.CONFIG_BIZ_DEFINE)
|
||||
return await GetFirstAsync(it => it.Category == category && it.ConfigKey == configKey);
|
||||
|
||||
var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key
|
||||
//先从redis拿配置
|
||||
var config = _simpleCacheService.HashGetOne<SysConfig>(key, configKey);
|
||||
|
||||
+32
@@ -18,6 +18,38 @@ public class OnboardingContractDocumentController
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建入职任务前,按人事当前填写的参数下载指定 DOCX 合同预览。
|
||||
/// </summary>
|
||||
[HttpPost("preview-before-create")]
|
||||
[DisplayName("创建入职任务前预览合同")]
|
||||
public async Task<FileContentResult> PreviewBeforeCreate(
|
||||
[FromBody] OnboardingContractPreviewInput input)
|
||||
{
|
||||
var preview = await _service.PreviewBeforeCreate(input);
|
||||
return new FileContentResult(
|
||||
preview.FileBytes,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||||
{
|
||||
FileDownloadName = preview.FileName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建入职任务前,将全部 DOCX 合同预览打包下载。
|
||||
/// </summary>
|
||||
[HttpPost("preview-all-before-create")]
|
||||
[DisplayName("创建入职任务前预览全部合同")]
|
||||
public async Task<FileContentResult> PreviewAllBeforeCreate(
|
||||
[FromBody] OnboardingContractPreviewInput input)
|
||||
{
|
||||
var preview = await _service.PreviewAllBeforeCreate(input);
|
||||
return new FileContentResult(preview.FileBytes, "application/zip")
|
||||
{
|
||||
FileDownloadName = preview.FileName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成入职合同文件
|
||||
/// </summary>
|
||||
|
||||
+20
@@ -37,6 +37,11 @@ public class OnboardingContractTemplateController
|
||||
if (!string.Equals(suffix, ".docx", StringComparison.OrdinalIgnoreCase))
|
||||
throw Oops.Bah("合同模板仅支持 DOCX 文件");
|
||||
|
||||
await using var input = file.OpenReadStream();
|
||||
using var bytes = new MemoryStream();
|
||||
await input.CopyToAsync(bytes);
|
||||
DocxTemplateProcessor.ExtractVariableCodes(bytes.ToArray());
|
||||
|
||||
return await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
|
||||
}
|
||||
|
||||
@@ -64,6 +69,7 @@ public class OnboardingContractTemplateController
|
||||
/// 查询入职模板当前公司、部门及岗位可选择的合同模板。
|
||||
/// </summary>
|
||||
[HttpPost("list-available")]
|
||||
[DisplayName("查询可选合同模板")]
|
||||
public async Task<dynamic> ListAvailable([FromBody] OnboardingContractTemplateAvailableInput input)
|
||||
{
|
||||
return await _service.ListAvailable(input);
|
||||
@@ -113,6 +119,7 @@ public class OnboardingContractTemplateController
|
||||
/// 新增合同模板版本。
|
||||
/// </summary>
|
||||
[HttpPost("version/add")]
|
||||
[DisplayName("新增合同模板版本")]
|
||||
public async Task AddVersion([FromBody] OnboardingContractVersionAddInput input)
|
||||
{
|
||||
await _service.AddVersion(input);
|
||||
@@ -122,15 +129,27 @@ public class OnboardingContractTemplateController
|
||||
/// 设置合同模板版本状态。
|
||||
/// </summary>
|
||||
[HttpPost("version/set-status")]
|
||||
[DisplayName("设置合同模板版本状态")]
|
||||
public async Task SetVersionStatus([FromBody] OnboardingContractVersionStatusInput input)
|
||||
{
|
||||
await _service.SetVersionStatus(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 修改合同版本的公司盖章及骑缝章开关。
|
||||
/// </summary>
|
||||
[HttpPost("version/set-seals")]
|
||||
[DisplayName("设置合同版本盖章配置")]
|
||||
public async Task SetVersionSeals([FromBody] OnboardingContractVersionSealInput input)
|
||||
{
|
||||
await _service.SetVersionSeals(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除未被入职模板或任务使用的合同模板版本。
|
||||
/// </summary>
|
||||
[HttpPost("version/delete")]
|
||||
[DisplayName("删除合同模板版本")]
|
||||
public async Task DeleteVersion([FromBody] BaseIdInput input)
|
||||
{
|
||||
await _service.DeleteVersion(input);
|
||||
@@ -150,6 +169,7 @@ public class OnboardingContractTemplateController
|
||||
/// 编辑模板变量。
|
||||
/// </summary>
|
||||
[HttpPost("variable/edit")]
|
||||
[DisplayName("编辑合同模板变量")]
|
||||
public async Task EditVariable([FromBody] OnboardingTemplateVariableEditInput input)
|
||||
{
|
||||
await _service.EditVariable(input);
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
using System.Text;
|
||||
|
||||
namespace SimpleAdmin.Web.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 接收契约锁服务器状态通知;第一版按平台要求使用明文回调。
|
||||
/// </summary>
|
||||
[ApiDescriptionSettings("Application", Tag = "入职电子签回调")]
|
||||
[Route("open/onboarding/qiyuesuo")]
|
||||
[AllowAnonymous]
|
||||
public class OnboardingElectronicSignCallbackController
|
||||
{
|
||||
private readonly IOnboardingElectronicSignService _service;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public OnboardingElectronicSignCallbackController(
|
||||
IOnboardingElectronicSignService service,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_service = service;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 接收契约锁回调并返回平台可识别的非统一响应。
|
||||
/// </summary>
|
||||
[HttpPost("callback")]
|
||||
[NonUnify]
|
||||
public async Task<IActionResult> Callback()
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = _httpContextAccessor.HttpContext?.Request
|
||||
?? throw new InvalidOperationException("当前请求上下文不存在");
|
||||
var payloadJson = await ReadPayload(request);
|
||||
await _service.HandleCallback(payloadJson);
|
||||
return new OkObjectResult(new
|
||||
{
|
||||
code = 0,
|
||||
message = "success"
|
||||
});
|
||||
}
|
||||
catch (global::System.Exception exception)
|
||||
{
|
||||
// 契约锁按 HTTP 状态码判断回调是否成功;返回 500 后平台会继续重试。
|
||||
return new ObjectResult(new
|
||||
{
|
||||
code = -1,
|
||||
message = exception.Message
|
||||
})
|
||||
{
|
||||
StatusCode = StatusCodes.Status500InternalServerError
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 契约锁明文回调使用表单格式;同时兼容测试工具直接发送 JSON。
|
||||
/// </summary>
|
||||
private static async Task<string> ReadPayload(HttpRequest request)
|
||||
{
|
||||
if (request.HasFormContentType)
|
||||
{
|
||||
var form = await request.ReadFormAsync();
|
||||
var values = form
|
||||
.OrderBy(item => item.Key, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
item => item.Key,
|
||||
item => item.Value.ToString());
|
||||
return global::System.Text.Json.JsonSerializer.Serialize(values);
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(
|
||||
request.Body,
|
||||
Encoding.UTF8,
|
||||
true,
|
||||
leaveOpen: true);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace SimpleAdmin.Web.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 提供入职任务电子签管理接口。
|
||||
/// </summary>
|
||||
[ApiDescriptionSettings("Application", Tag = "入职电子签约")]
|
||||
[Route("biz/onboarding/electronic-sign")]
|
||||
[RolePermission]
|
||||
public class OnboardingElectronicSignController
|
||||
{
|
||||
private readonly IOnboardingElectronicSignService _service;
|
||||
|
||||
public OnboardingElectronicSignController(
|
||||
IOnboardingElectronicSignService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询入职任务的电子签状态和合同文件。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[DisplayName("查看入职电子签详情")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> Detail(
|
||||
[FromQuery] BaseIdInput input)
|
||||
{
|
||||
return await _service.Detail(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动发起或继续上次失败的契约锁流程。
|
||||
/// </summary>
|
||||
[HttpPost("initiate")]
|
||||
[DisplayName("发起入职电子签")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> Initiate(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
return await _service.Initiate(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保留原流程记录并创建一次全新的契约锁签约流程。
|
||||
/// </summary>
|
||||
[HttpPost("reinitiate")]
|
||||
[DisplayName("重新发起入职电子签")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> Reinitiate(
|
||||
[FromBody] OnboardingElectronicSignReinitiateInput input)
|
||||
{
|
||||
return await _service.Reinitiate(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 主动同步契约锁状态,完成后自动归档。
|
||||
/// </summary>
|
||||
[HttpPost("sync")]
|
||||
[DisplayName("同步入职电子签状态")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> Sync(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
return await _service.Sync(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取仍然有效的员工签署地址,过期则重新生成。
|
||||
/// </summary>
|
||||
[HttpPost("get-link")]
|
||||
[DisplayName("获取员工电子签链接")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> GetSignLink(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
return await _service.GetSignLink(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取员工已签、企业未盖章文件的只读浏览页面。
|
||||
/// </summary>
|
||||
[HttpPost("preview-employee-signed")]
|
||||
[DisplayName("预览员工已签合同")]
|
||||
public async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
|
||||
[FromBody] OnboardingElectronicSignPreviewInput input)
|
||||
{
|
||||
return await _service.PreviewEmployeeSignedDocument(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 全部文件核对完成后,由后台管理员确认企业盖章。
|
||||
/// </summary>
|
||||
[HttpPost("company-seal")]
|
||||
[DisplayName("确认入职合同企业盖章")]
|
||||
public async Task<OnboardingElectronicSignDetailOutput> CompanySeal(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
return await _service.CompanySeal(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 催办当前待签员工。
|
||||
/// </summary>
|
||||
[HttpPost("notice")]
|
||||
[DisplayName("催办员工电子签")]
|
||||
public async Task Notice(
|
||||
[FromBody] OnboardingElectronicSignTaskInput input)
|
||||
{
|
||||
await _service.Notice(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 作废尚未结束的电子签流程。
|
||||
/// </summary>
|
||||
[HttpPost("invalid")]
|
||||
[DisplayName("作废入职电子签")]
|
||||
public async Task Invalid(
|
||||
[FromBody] OnboardingElectronicSignInvalidInput input)
|
||||
{
|
||||
await _service.Invalid(input);
|
||||
}
|
||||
}
|
||||
+80
-5
@@ -2,37 +2,112 @@ using System.ComponentModel;
|
||||
namespace SimpleAdmin.Web.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 提供正式员工档案相关 HTTP 接口。
|
||||
/// 提供员工档案及人事维护相关 HTTP 接口。
|
||||
/// </summary>
|
||||
[ApiDescriptionSettings("Application", Tag = "入职正式员工档案")]
|
||||
[ApiDescriptionSettings("Application", Tag = "入职员工档案")]
|
||||
[Route("biz/onboarding/employee")]
|
||||
[RolePermission]
|
||||
public class OnboardingEmployeeController
|
||||
{
|
||||
private readonly IOnboardingEmployeeService _service;
|
||||
private readonly IFileService _fileService;
|
||||
|
||||
public OnboardingEmployeeController(IOnboardingEmployeeService service)
|
||||
public OnboardingEmployeeController(
|
||||
IOnboardingEmployeeService service,
|
||||
IFileService fileService)
|
||||
{
|
||||
_service = service;
|
||||
_fileService = fileService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询正式员工档案。
|
||||
/// </summary>
|
||||
[HttpGet("page")]
|
||||
[DisplayName("分页查询正式员工档案")]
|
||||
[DisplayName("分页查询员工档案")]
|
||||
public async Task<dynamic> Page([FromQuery] OnboardingEmployeePageInput input)
|
||||
{
|
||||
return await _service.Page(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按当前列表筛选条件导出员工基本信息和合同快照。
|
||||
/// </summary>
|
||||
[HttpPost("export")]
|
||||
[DisplayName("导出员工明细")]
|
||||
public async Task<FileContentResult> Export([FromBody] OnboardingEmployeePageInput input)
|
||||
{
|
||||
var file = await _service.Export(input);
|
||||
return new FileContentResult(
|
||||
file.FileBytes,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
{
|
||||
FileDownloadName = file.FileName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询正式员工详情及来源记录。
|
||||
/// </summary>
|
||||
[HttpGet("detail")]
|
||||
[DisplayName("查询正式员工详情")]
|
||||
[DisplayName("查询员工档案详情")]
|
||||
public async Task<dynamic> Detail([FromQuery] BaseIdInput input)
|
||||
{
|
||||
return await _service.Detail(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 编辑员工用工、社保及合同性质等人事信息。
|
||||
/// </summary>
|
||||
[HttpPost("hr-info/update")]
|
||||
[DisplayName("编辑员工人事信息")]
|
||||
public async Task UpdateHrInfo([FromBody] OnboardingEmployeeHrUpdateInput input)
|
||||
{
|
||||
await _service.UpdateHrInfo(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传转正等员工人事材料。
|
||||
/// </summary>
|
||||
[HttpPost("attachment/upload")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
[DisplayName("上传员工人事材料")]
|
||||
public async Task<long> UploadAttachment([FromForm] IFormFile file)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
throw Oops.Bah("请选择需要上传的材料文件");
|
||||
|
||||
var allowedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
||||
".jpg", ".jpeg", ".png", ".webp", ".zip"
|
||||
};
|
||||
var extension = Path.GetExtension(file.FileName);
|
||||
if (!allowedExtensions.Contains(extension))
|
||||
throw Oops.Bah("仅支持 PDF、Word、Excel、图片或 ZIP 文件");
|
||||
|
||||
return await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 人工办理员工转正。
|
||||
/// </summary>
|
||||
[HttpPost("regularization/confirm")]
|
||||
[DisplayName("办理员工转正")]
|
||||
public async Task Regularize([FromBody] OnboardingEmployeeRegularizeInput input)
|
||||
{
|
||||
await _service.Regularize(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 撤销员工转正。
|
||||
/// </summary>
|
||||
[HttpPost("regularization/revoke")]
|
||||
[DisplayName("撤销员工转正")]
|
||||
public async Task RevokeRegularization(
|
||||
[FromBody] OnboardingEmployeeRegularizationRevokeInput input)
|
||||
{
|
||||
await _service.RevokeRegularization(input);
|
||||
}
|
||||
}
|
||||
|
||||
+64
-1
@@ -50,7 +50,7 @@ public class OnboardingPublicSubmissionController
|
||||
/// </summary>
|
||||
[HttpPost("upload-material")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(20 * 1024 * 1024)]
|
||||
[RequestSizeLimit(21 * 1024 * 1024)]
|
||||
public async Task UploadMaterial(
|
||||
[FromQuery] string token,
|
||||
[FromQuery] long materialTypeId,
|
||||
@@ -70,6 +70,7 @@ public class OnboardingPublicSubmissionController
|
||||
FileLength = file.Length,
|
||||
TaskMaterialId = taskMaterialId
|
||||
});
|
||||
await ValidateMaterialFileContent(file);
|
||||
|
||||
var fileId = await _fileService.UploadFile(SysDictConst.FILE_ENGINE_ALIYUN_OSS, file);
|
||||
await _service.SaveMaterial(new OnboardingSaveMaterialInput
|
||||
@@ -80,4 +81,66 @@ public class OnboardingPublicSubmissionController
|
||||
TaskMaterialId = taskMaterialId
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task ValidateMaterialFileContent(IFormFile file)
|
||||
{
|
||||
var suffix = global::System.IO.Path.GetExtension(file.FileName).TrimStart('.').ToLowerInvariant();
|
||||
var supported = suffix is "png" or "jpg" or "jpeg" or "gif" or "bmp" or "webp" or "pdf";
|
||||
if (!supported)
|
||||
throw Oops.Bah("仅允许上传图片或PDF文件");
|
||||
|
||||
var header = new byte[12];
|
||||
var bytesRead = 0;
|
||||
await using (var stream = file.OpenReadStream())
|
||||
{
|
||||
while (bytesRead < header.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(header.AsMemory(bytesRead, header.Length - bytesRead));
|
||||
if (read == 0) break;
|
||||
bytesRead += read;
|
||||
}
|
||||
}
|
||||
|
||||
var contentMatchesSuffix = suffix switch
|
||||
{
|
||||
"jpg" or "jpeg" => bytesRead >= 3 && header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF,
|
||||
"png" => bytesRead >= 8
|
||||
&& header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47
|
||||
&& header[4] == 0x0D && header[5] == 0x0A && header[6] == 0x1A && header[7] == 0x0A,
|
||||
"gif" => bytesRead >= 6
|
||||
&& header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46
|
||||
&& header[3] == 0x38 && (header[4] == 0x37 || header[4] == 0x39) && header[5] == 0x61,
|
||||
"bmp" => bytesRead >= 2 && header[0] == 0x42 && header[1] == 0x4D,
|
||||
"webp" => bytesRead >= 12
|
||||
&& header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46
|
||||
&& header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50,
|
||||
"pdf" => bytesRead >= 5
|
||||
&& header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44
|
||||
&& header[3] == 0x46 && header[4] == 0x2D,
|
||||
_ => false
|
||||
};
|
||||
if (!contentMatchesSuffix)
|
||||
throw Oops.Bah("文件真实格式与扩展名不一致,请上传有效的图片或PDF文件");
|
||||
|
||||
if (suffix == "pdf") return;
|
||||
|
||||
await using var imageStream = file.OpenReadStream();
|
||||
SkiaSharp.SKCodec codec;
|
||||
try
|
||||
{
|
||||
codec = SkiaSharp.SKCodec.Create(imageStream);
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw Oops.Bah("图片文件已损坏或格式不受支持");
|
||||
}
|
||||
|
||||
using (codec)
|
||||
{
|
||||
if (codec == null || codec.Info.Width <= 0 || codec.Info.Height <= 0)
|
||||
throw Oops.Bah("图片文件已损坏或格式不受支持");
|
||||
if ((long)codec.Info.Width * codec.Info.Height > 40_000_000L)
|
||||
throw Oops.Bah("图片分辨率过大,请压缩后重新上传");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace SimpleAdmin.Web.Core;
|
||||
|
||||
/// <summary>
|
||||
@@ -25,4 +26,14 @@ public class OnboardingTaskController
|
||||
{
|
||||
return await _service.Create(input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在原任务之外批量复制独立的新入职任务。
|
||||
/// </summary>
|
||||
[HttpPost("copy")]
|
||||
[DisplayName("复制入职任务")]
|
||||
public async Task<dynamic> Copy([FromBody] OnboardingTaskCopyInput input)
|
||||
{
|
||||
return await _service.Copy(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ public class BizUserController : IDynamicApiController
|
||||
/// <returns></returns>
|
||||
[HttpGet(template: "template")]
|
||||
[SuppressMonitor]
|
||||
[DisplayName("下载人员导入模板")]
|
||||
public async Task<dynamic> Template()
|
||||
{
|
||||
return await _userService.Template();
|
||||
|
||||
Binary file not shown.
@@ -8,6 +8,11 @@
|
||||
<None Update="ip2region.db">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<Content Include="..\SimpleAdmin.Web.Core\Upload\员工导出模版.xlsx">
|
||||
<Link>Upload\员工导出模版.xlsx</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SimpleAdmin.Web.Core\SimpleAdmin.Web.Core.csproj" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"AppSettings": {
|
||||
"InjectSpecificationDocument": true, //是否开启swagger
|
||||
"InjectSpecificationDocument": false, //是否开启swagger
|
||||
"Urls": "http://0.0.0.0:5566" //监听地址
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
//swagger设置
|
||||
"SpecificationDocumentSettings": {
|
||||
"DocumentTitle": "未病局后台管理系统",
|
||||
"DocumentTitle": "员工后台管理系统",
|
||||
"DocExpansionState": "None",
|
||||
"GroupOpenApiInfos": []
|
||||
},
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
-- OnboardX 契约锁电子签流程表
|
||||
-- 适用数据库:MySQL 8.0+
|
||||
-- 说明:
|
||||
-- 1. 一条电子签流程可以包含同一入职任务下的多份合同。
|
||||
-- 2. ProviderContractId、ProviderDocumentId 一律使用 VARCHAR,避免第三方 19 位 Id 在前端精度丢失。
|
||||
-- 3. 回调事件通过 PayloadHash 幂等处理,可安全接收契约锁重复通知。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ob_electronic_sign_process` (
|
||||
`Id` BIGINT NOT NULL COMMENT '主键',
|
||||
`TaskId` BIGINT NOT NULL COMMENT '入职任务Id',
|
||||
`EmployeeId` BIGINT NOT NULL COMMENT '正式员工Id',
|
||||
`Provider` VARCHAR(30) NOT NULL DEFAULT 'qiyuesuo' COMMENT '电子签服务商',
|
||||
`BizId` VARCHAR(100) NOT NULL COMMENT '本系统幂等业务编号',
|
||||
`IsCurrent` TINYINT(1) NULL DEFAULT 1 COMMENT '是否为任务当前电子签流程,历史流程为空',
|
||||
`ProviderContractId` VARCHAR(100) NULL COMMENT '契约锁合同流程Id',
|
||||
`CategoryId` VARCHAR(100) NOT NULL COMMENT '契约锁业务分类Id快照',
|
||||
`SealId` VARCHAR(100) NOT NULL COMMENT '契约锁印章Id快照',
|
||||
`Subject` VARCHAR(300) NOT NULL COMMENT '电子签流程标题',
|
||||
`EmployeeName` VARCHAR(100) NOT NULL COMMENT '员工姓名快照',
|
||||
`EmployeeMobile` VARCHAR(30) NOT NULL COMMENT '员工手机号快照',
|
||||
`EmployeeIdCardNumber` VARCHAR(30) NULL COMMENT '员工身份证号码快照',
|
||||
`CreatorName` VARCHAR(100) NOT NULL COMMENT '创建人姓名快照',
|
||||
`CreatorMobile` VARCHAR(30) NOT NULL COMMENT '创建人手机号快照',
|
||||
`SignStatus` VARCHAR(40) NOT NULL COMMENT '本系统电子签状态',
|
||||
`ProviderStatus` VARCHAR(60) NULL COMMENT '契约锁原始合同状态',
|
||||
`SignUrl` LONGTEXT NULL COMMENT '员工签署页面地址',
|
||||
`SignUrlExpireAt` DATETIME(3) NULL COMMENT '签署页面地址到期时间',
|
||||
`StartedAt` DATETIME(3) NOT NULL COMMENT '管理员发起时间',
|
||||
`SentAt` DATETIME(3) NULL COMMENT '合同发送时间',
|
||||
`CompletedAt` DATETIME(3) NULL COMMENT '全部签署完成时间',
|
||||
`ArchivedAt` DATETIME(3) NULL COMMENT '归档完成时间',
|
||||
`ArchiveFileId` BIGINT NULL COMMENT '完整归档ZIP文件Id',
|
||||
`LastSyncedAt` DATETIME(3) NULL COMMENT '最后主动同步时间',
|
||||
`LastCallbackAt` DATETIME(3) NULL COMMENT '最后回调时间',
|
||||
`LastError` LONGTEXT NULL COMMENT '最近一次失败原因',
|
||||
`RetryCount` INT NOT NULL DEFAULT 0 COMMENT '失败重试次数',
|
||||
`ReissueReason` VARCHAR(500) NULL COMMENT '重新发起原因',
|
||||
`ReissuedAt` DATETIME(3) NULL COMMENT '被新流程替代时间',
|
||||
`ReissuedByProcessId` BIGINT NULL COMMENT '替代当前流程的新流程Id',
|
||||
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
|
||||
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态',
|
||||
`CreateTime` DATETIME(3) NULL COMMENT '创建时间',
|
||||
`UpdateTime` DATETIME(3) NULL COMMENT '更新时间',
|
||||
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
|
||||
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
|
||||
`CreateUser` VARCHAR(200) NULL COMMENT '创建人',
|
||||
`UpdateUser` VARCHAR(200) NULL COMMENT '更新人',
|
||||
`IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_ob_esign_process_biz` (`BizId`),
|
||||
UNIQUE KEY `uk_ob_esign_process_current` (`TaskId`, `IsCurrent`),
|
||||
UNIQUE KEY `uk_ob_esign_process_provider_contract` (`ProviderContractId`),
|
||||
KEY `idx_ob_esign_process_task` (`TaskId`, `StartedAt`),
|
||||
KEY `idx_ob_esign_process_status` (`SignStatus`, `UpdateTime`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职电子签流程';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ob_electronic_sign_document` (
|
||||
`Id` BIGINT NOT NULL COMMENT '主键',
|
||||
`SignProcessId` BIGINT NOT NULL COMMENT '电子签流程Id',
|
||||
`TaskContractId` BIGINT NOT NULL COMMENT '任务合同Id',
|
||||
`ProviderDocumentId` VARCHAR(100) NOT NULL COMMENT '契约锁文档Id',
|
||||
`DocumentName` VARCHAR(300) NOT NULL COMMENT '文档名称快照',
|
||||
`DocumentSort` INT NOT NULL DEFAULT 1 COMMENT '文档顺序',
|
||||
`OriginalDocxFileId` BIGINT NOT NULL COMMENT '原始DOCX文件Id',
|
||||
`SignedPdfFileId` BIGINT NULL COMMENT '签署完成PDF文件Id',
|
||||
`EmployeeSignKeyword` VARCHAR(200) NOT NULL COMMENT '员工签名关键字快照',
|
||||
`CompanySealKeyword` VARCHAR(200) NOT NULL COMMENT '企业盖章关键字快照',
|
||||
`EmployeeActionId` VARCHAR(100) NULL COMMENT '员工签署动作Id',
|
||||
`CompanyActionId` VARCHAR(100) NULL COMMENT '企业盖章动作Id',
|
||||
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
|
||||
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态',
|
||||
`CreateTime` DATETIME(3) NULL COMMENT '创建时间',
|
||||
`UpdateTime` DATETIME(3) NULL COMMENT '更新时间',
|
||||
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
|
||||
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
|
||||
`CreateUser` VARCHAR(200) NULL COMMENT '创建人',
|
||||
`UpdateUser` VARCHAR(200) NULL COMMENT '更新人',
|
||||
`IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_ob_esign_document_task_contract` (`SignProcessId`, `TaskContractId`),
|
||||
UNIQUE KEY `uk_ob_esign_document_provider` (`ProviderDocumentId`),
|
||||
KEY `idx_ob_esign_document_process_sort` (`SignProcessId`, `DocumentSort`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职电子签文档';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ob_electronic_sign_event` (
|
||||
`Id` BIGINT NOT NULL COMMENT '主键',
|
||||
`SignProcessId` BIGINT NULL COMMENT '电子签流程Id',
|
||||
`PayloadHash` VARCHAR(64) NOT NULL COMMENT '回调内容SHA-256',
|
||||
`EventSource` VARCHAR(30) NOT NULL COMMENT '事件来源',
|
||||
`EventType` VARCHAR(100) NULL COMMENT '契约锁事件类型',
|
||||
`ProviderStatus` VARCHAR(60) NULL COMMENT '契约锁状态',
|
||||
`PayloadJson` LONGTEXT NULL COMMENT '事件原始内容',
|
||||
`ProcessResult` VARCHAR(500) NULL COMMENT '处理结果',
|
||||
`ReceivedAt` DATETIME(3) NOT NULL COMMENT '接收时间',
|
||||
`ProcessedAt` DATETIME(3) NULL COMMENT '处理完成时间',
|
||||
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
|
||||
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态',
|
||||
`CreateTime` DATETIME(3) NULL COMMENT '创建时间',
|
||||
`UpdateTime` DATETIME(3) NULL COMMENT '更新时间',
|
||||
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
|
||||
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
|
||||
`CreateUser` VARCHAR(200) NULL COMMENT '创建人',
|
||||
`UpdateUser` VARCHAR(200) NULL COMMENT '更新人',
|
||||
`IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_ob_esign_event_payload_hash` (`PayloadHash`),
|
||||
KEY `idx_ob_esign_event_process` (`SignProcessId`, `ReceivedAt`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职电子签事件';
|
||||
@@ -0,0 +1,33 @@
|
||||
-- 增加入职电子签“作废后重新发起”能力。
|
||||
-- 已执行 20260903_qiyuesuo_electronic_sign.sql 的数据库需要单独执行本文件。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `ob_electronic_sign_process`
|
||||
ADD COLUMN `IsCurrent` TINYINT(1) NULL DEFAULT NULL
|
||||
COMMENT '是否为任务当前电子签流程,历史流程为空'
|
||||
AFTER `BizId`;
|
||||
|
||||
ALTER TABLE `ob_electronic_sign_process`
|
||||
ADD COLUMN `ReissueReason` VARCHAR(500) NULL
|
||||
COMMENT '重新发起原因'
|
||||
AFTER `RetryCount`,
|
||||
ADD COLUMN `ReissuedAt` DATETIME(3) NULL
|
||||
COMMENT '被新流程替代时间'
|
||||
AFTER `ReissueReason`,
|
||||
ADD COLUMN `ReissuedByProcessId` BIGINT NULL
|
||||
COMMENT '替代当前流程的新流程Id'
|
||||
AFTER `ReissuedAt`;
|
||||
|
||||
-- 雪花 Id 与创建时间同序,每个任务只把最新一条流程标记为当前流程。
|
||||
UPDATE `ob_electronic_sign_process` process
|
||||
INNER JOIN (
|
||||
SELECT `TaskId`, MAX(`Id`) AS `LatestProcessId`
|
||||
FROM `ob_electronic_sign_process`
|
||||
GROUP BY `TaskId`
|
||||
) latest ON latest.`LatestProcessId` = process.`Id`
|
||||
SET process.`IsCurrent` = 1;
|
||||
|
||||
ALTER TABLE `ob_electronic_sign_process`
|
||||
ADD UNIQUE KEY `uk_ob_esign_process_current` (`TaskId`, `IsCurrent`);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- 补充电子签署人实名快照。
|
||||
-- 已执行 20260903_qiyuesuo_electronic_sign.sql 的数据库需要单独执行本文件。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `ob_electronic_sign_process`
|
||||
ADD COLUMN `EmployeeIdCardNumber` VARCHAR(30) NULL
|
||||
COMMENT '员工身份证号码快照'
|
||||
AFTER `EmployeeMobile`;
|
||||
@@ -0,0 +1,163 @@
|
||||
-- 员工合同快照:一名正式员工的一份任务合同对应一条记录。
|
||||
-- 常用字段独立存储用于筛选导出,VariablesJson 按 VariableCode 保存全部标准及自定义变量。
|
||||
-- 适用数据库:MySQL 8.0+
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ob_employee_contract_snapshot` (
|
||||
`Id` BIGINT NOT NULL COMMENT '主键',
|
||||
`EmployeeId` BIGINT NOT NULL COMMENT '正式员工Id',
|
||||
`TaskId` BIGINT NOT NULL COMMENT '来源入职任务Id',
|
||||
`TaskContractId` BIGINT NOT NULL COMMENT '来源任务合同Id',
|
||||
`TemplateId` BIGINT NOT NULL COMMENT '合同模板Id',
|
||||
`TemplateVersionId` BIGINT NOT NULL COMMENT '合同模板版本Id',
|
||||
`ContractName` VARCHAR(300) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合同名称快照',
|
||||
`ContractType` VARCHAR(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同类型快照',
|
||||
`ContractStartDate` DATE DEFAULT NULL COMMENT '合同开始日期',
|
||||
`ContractEndDate` DATE DEFAULT NULL COMMENT '合同结束日期',
|
||||
`ContractTerm` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同期限原始文本',
|
||||
`ProbationStartDate` DATE DEFAULT NULL COMMENT '试用期开始日期',
|
||||
`ProbationEndDate` DATE DEFAULT NULL COMMENT '试用期结束日期',
|
||||
`ProbationTerm` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '试用期期限原始文本',
|
||||
`ProbationSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '试用薪资,对应hr.ProbationSalary,不自动推算',
|
||||
`BaseSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '基本工资',
|
||||
`PositionSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '岗位工资',
|
||||
`PerformanceAllowance` DECIMAL(18,2) DEFAULT NULL COMMENT '绩效津贴',
|
||||
`AttendanceBonus` DECIMAL(18,2) DEFAULT NULL COMMENT '全勤奖',
|
||||
`RegularSalary` DECIMAL(18,2) DEFAULT NULL COMMENT '转正后薪资',
|
||||
`WorkCity` VARCHAR(300) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '工作地点或城市',
|
||||
`PositionName` VARCHAR(300) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同岗位名称',
|
||||
`EmployeeNo` VARCHAR(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合同员工工号',
|
||||
`VariablesJson` JSON NOT NULL COMMENT '按VariableCode索引的完整合同变量快照',
|
||||
`SnapshotVersion` INT NOT NULL DEFAULT 1 COMMENT '快照结构版本',
|
||||
`SnapshotAt` DATETIME(3) NOT NULL COMMENT '快照生成时间',
|
||||
`ExtJson` LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '扩展信息',
|
||||
`Status` VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'enable' COMMENT '记录状态',
|
||||
`CreateTime` DATETIME(3) DEFAULT NULL COMMENT '创建时间',
|
||||
`UpdateTime` DATETIME(3) DEFAULT NULL COMMENT '更新时间',
|
||||
`CreateUserId` BIGINT DEFAULT NULL COMMENT '创建者Id',
|
||||
`UpdateUserId` BIGINT DEFAULT NULL COMMENT '修改者Id',
|
||||
`CreateUser` VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
|
||||
`UpdateUser` VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
|
||||
`IsDelete` TINYINT(1) DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_ob_employee_contract_snapshot_contract` (`TaskContractId`),
|
||||
KEY `idx_ob_employee_contract_snapshot_employee` (`EmployeeId`, `IsDelete`),
|
||||
KEY `idx_ob_employee_contract_snapshot_task` (`TaskId`, `IsDelete`),
|
||||
KEY `idx_ob_employee_contract_snapshot_dates` (`ContractStartDate`, `ContractEndDate`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正式员工合同业务快照';
|
||||
|
||||
-- 为已经审核建档且仍保留任务合同变量的员工补建快照。
|
||||
INSERT IGNORE INTO `ob_employee_contract_snapshot` (
|
||||
`Id`, `EmployeeId`, `TaskId`, `TaskContractId`, `TemplateId`, `TemplateVersionId`,
|
||||
`ContractName`, `ContractType`, `ContractStartDate`, `ContractEndDate`, `ContractTerm`,
|
||||
`ProbationStartDate`, `ProbationEndDate`, `ProbationTerm`, `ProbationSalary`,
|
||||
`BaseSalary`, `PositionSalary`, `PerformanceAllowance`, `AttendanceBonus`, `RegularSalary`,
|
||||
`WorkCity`, `PositionName`, `EmployeeNo`, `VariablesJson`, `SnapshotVersion`, `SnapshotAt`,
|
||||
`Status`, `CreateTime`, `CreateUserId`, `CreateUser`, `IsDelete`
|
||||
)
|
||||
SELECT
|
||||
contract.`Id`,
|
||||
task.`EmployeeId`,
|
||||
task.`Id`,
|
||||
contract.`Id`,
|
||||
contract.`TemplateId`,
|
||||
contract.`TemplateVersionId`,
|
||||
contract.`ContractName`,
|
||||
contract.`ContractType`,
|
||||
CASE
|
||||
WHEN TRIM(variable.`ContractStartDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
|
||||
THEN STR_TO_DATE(TRIM(variable.`ContractStartDateText`), '%Y-%m-%d')
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE
|
||||
WHEN TRIM(variable.`ContractEndDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
|
||||
THEN STR_TO_DATE(TRIM(variable.`ContractEndDateText`), '%Y-%m-%d')
|
||||
ELSE NULL
|
||||
END,
|
||||
variable.`ContractTerm`,
|
||||
CASE
|
||||
WHEN TRIM(variable.`ProbationStartDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
|
||||
THEN STR_TO_DATE(TRIM(variable.`ProbationStartDateText`), '%Y-%m-%d')
|
||||
ELSE NULL
|
||||
END,
|
||||
CASE
|
||||
WHEN TRIM(variable.`ProbationEndDateText`) REGEXP '^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}$'
|
||||
THEN STR_TO_DATE(TRIM(variable.`ProbationEndDateText`), '%Y-%m-%d')
|
||||
ELSE NULL
|
||||
END,
|
||||
variable.`ProbationTerm`,
|
||||
CASE WHEN TRIM(variable.`ProbationSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`ProbationSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`BaseSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`BaseSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`PositionSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`PositionSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`PerformanceAllowanceText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`PerformanceAllowanceText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`AttendanceBonusText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`AttendanceBonusText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
CASE WHEN TRIM(variable.`RegularSalaryText`) REGEXP '^-?[0-9]+([.][0-9]+)?$'
|
||||
THEN CAST(TRIM(variable.`RegularSalaryText`) AS DECIMAL(18,2)) ELSE NULL END,
|
||||
variable.`WorkCity`,
|
||||
variable.`PositionName`,
|
||||
variable.`EmployeeNo`,
|
||||
COALESCE(variable.`VariablesJson`, JSON_OBJECT()),
|
||||
1,
|
||||
COALESCE(task.`AuditedAt`, employee.`CreateTime`, NOW(3)),
|
||||
'enable',
|
||||
COALESCE(employee.`CreateTime`, NOW(3)),
|
||||
employee.`CreateUserId`,
|
||||
employee.`CreateUser`,
|
||||
0
|
||||
FROM `ob_onboarding_task` task
|
||||
INNER JOIN `ob_employee` employee
|
||||
ON employee.`Id` = task.`EmployeeId`
|
||||
INNER JOIN `ob_task_contract` contract
|
||||
ON contract.`TaskId` = task.`Id`
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
row_value.`TaskContractId`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ContractStartDate' THEN row_value.`VariableValue` END) AS `ContractStartDateText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ContractEndDate' THEN row_value.`VariableValue` END) AS `ContractEndDateText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ContractTerm' THEN row_value.`VariableValue` END) AS `ContractTerm`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationStartDate' THEN row_value.`VariableValue` END) AS `ProbationStartDateText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationEndDate' THEN row_value.`VariableValue` END) AS `ProbationEndDateText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationTerm' THEN row_value.`VariableValue` END) AS `ProbationTerm`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.ProbationSalary' THEN row_value.`VariableValue` END) AS `ProbationSalaryText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.BaseSalary' THEN row_value.`VariableValue` END) AS `BaseSalaryText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.PositionSalary' THEN row_value.`VariableValue` END) AS `PositionSalaryText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.PerformanceAllowance' THEN row_value.`VariableValue` END) AS `PerformanceAllowanceText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.AttendanceBonus' THEN row_value.`VariableValue` END) AS `AttendanceBonusText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.RegularSalary' THEN row_value.`VariableValue` END) AS `RegularSalaryText`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.WorkCity' THEN row_value.`VariableValue` END) AS `WorkCity`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.PositionName' THEN row_value.`VariableValue` END) AS `PositionName`,
|
||||
MAX(CASE WHEN row_value.`VariableCode` = 'hr.EmployeeNo' THEN row_value.`VariableValue` END) AS `EmployeeNo`,
|
||||
JSON_OBJECTAGG(
|
||||
row_value.`VariableCode`,
|
||||
JSON_OBJECT(
|
||||
'variableName', row_value.`VariableName`,
|
||||
'variableType', row_value.`VariableType`,
|
||||
'dataType', row_value.`DataType`,
|
||||
'variableValue', row_value.`VariableValue`,
|
||||
'sourceType', row_value.`SourceType`,
|
||||
'isManualOverride', row_value.`IsManualOverride`,
|
||||
'sortCode', row_value.`SortCode`
|
||||
)
|
||||
) AS `VariablesJson`
|
||||
FROM `ob_task_contract_variable` row_value
|
||||
WHERE row_value.`IsDelete` = 0
|
||||
GROUP BY row_value.`TaskContractId`
|
||||
) variable
|
||||
ON variable.`TaskContractId` = contract.`Id`
|
||||
WHERE task.`EmployeeId` IS NOT NULL
|
||||
AND task.`IsDelete` = 0
|
||||
AND employee.`IsDelete` = 0
|
||||
AND contract.`IsDelete` = 0;
|
||||
|
||||
SELECT
|
||||
COUNT(*) AS `员工合同快照数量`,
|
||||
COUNT(DISTINCT `EmployeeId`) AS `已归档员工数量`,
|
||||
COUNT(DISTINCT `TaskContractId`) AS `已归档合同数量`
|
||||
FROM `ob_employee_contract_snapshot`
|
||||
WHERE `IsDelete` = 0;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- OnboardX 契约锁公司级业务分类与印章配置
|
||||
-- 适用数据库:MySQL 8.0+
|
||||
-- 可重复执行;仅增加字段,不覆盖任何公司已有配置。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS `ob_qys_company_add_column_if_missing`;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE `ob_qys_company_add_column_if_missing`(
|
||||
IN p_column_name VARCHAR(128),
|
||||
IN p_column_definition VARCHAR(1000)
|
||||
)
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_company'
|
||||
AND COLUMN_NAME = p_column_name
|
||||
) THEN
|
||||
SET @ddl = CONCAT(
|
||||
'ALTER TABLE `ob_company` ADD COLUMN `',
|
||||
p_column_name, '` ', p_column_definition
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL `ob_qys_company_add_column_if_missing`(
|
||||
'QiyuesuoCategoryId',
|
||||
'VARCHAR(100) NULL COMMENT ''契约锁默认业务分类Id(按公司独立配置)'' AFTER `ContactPhone`'
|
||||
);
|
||||
CALL `ob_qys_company_add_column_if_missing`(
|
||||
'QiyuesuoSealId',
|
||||
'VARCHAR(100) NULL COMMENT ''契约锁企业印章Id(按公司独立配置)'' AFTER `QiyuesuoCategoryId`'
|
||||
);
|
||||
|
||||
DROP PROCEDURE IF EXISTS `ob_qys_company_add_column_if_missing`;
|
||||
|
||||
SELECT
|
||||
Id,
|
||||
CompanyCode,
|
||||
CompanyName,
|
||||
LegalName,
|
||||
QiyuesuoCategoryId,
|
||||
QiyuesuoSealId,
|
||||
CASE
|
||||
WHEN NULLIF(TRIM(QiyuesuoCategoryId), '') IS NOT NULL
|
||||
AND NULLIF(TRIM(QiyuesuoSealId), '') IS NOT NULL THEN '已配置'
|
||||
ELSE '待配置'
|
||||
END AS QiyuesuoConfigStatus
|
||||
FROM ob_company
|
||||
WHERE IsDelete = 0 OR IsDelete IS NULL
|
||||
ORDER BY CompanyName;
|
||||
@@ -0,0 +1,172 @@
|
||||
-- 员工用工类型、人工转正、社保状态及人事变更材料。
|
||||
-- 适用数据库:MySQL 8.0+
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS ob_upgrade_employee_hr_management;
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE ob_upgrade_employee_hr_management()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_onboarding_task'
|
||||
AND COLUMN_NAME = 'EmploymentType'
|
||||
) THEN
|
||||
ALTER TABLE ob_onboarding_task
|
||||
ADD COLUMN EmploymentType VARCHAR(20) COLLATE utf8mb4_unicode_ci
|
||||
NOT NULL DEFAULT 'full_time' COMMENT '用工类型:full_time正式、part_time兼职'
|
||||
AFTER PositionId;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee'
|
||||
AND COLUMN_NAME = 'EmploymentType'
|
||||
) THEN
|
||||
ALTER TABLE ob_employee
|
||||
ADD COLUMN EmploymentType VARCHAR(20) COLLATE utf8mb4_unicode_ci
|
||||
NOT NULL DEFAULT 'full_time' COMMENT '用工类型:full_time正式、part_time兼职'
|
||||
AFTER PositionId;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee'
|
||||
AND COLUMN_NAME = 'RegularizationStatus'
|
||||
) THEN
|
||||
ALTER TABLE ob_employee
|
||||
ADD COLUMN RegularizationStatus VARCHAR(20) COLLATE utf8mb4_unicode_ci
|
||||
NOT NULL DEFAULT 'probation' COMMENT '转正状态:probation、regular、not_applicable'
|
||||
AFTER EmploymentType;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee'
|
||||
AND COLUMN_NAME = 'SocialInsuranceStatus'
|
||||
) THEN
|
||||
ALTER TABLE ob_employee
|
||||
ADD COLUMN SocialInsuranceStatus VARCHAR(20) COLLATE utf8mb4_unicode_ci
|
||||
NOT NULL DEFAULT 'unknown' COMMENT '社保状态:unknown、not_purchased、purchased、stopped、not_applicable'
|
||||
AFTER RegularizationStatus;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee_contract_snapshot'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_employee_contract_snapshot'
|
||||
AND COLUMN_NAME = 'ProbationSalary'
|
||||
) THEN
|
||||
ALTER TABLE ob_employee_contract_snapshot
|
||||
ADD COLUMN ProbationSalary DECIMAL(18,2) DEFAULT NULL
|
||||
COMMENT '试用薪资,对应hr.ProbationSalary,不自动推算'
|
||||
AFTER ProbationTerm;
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
DELIMITER ;
|
||||
CALL ob_upgrade_employee_hr_management();
|
||||
DROP PROCEDURE IF EXISTS ob_upgrade_employee_hr_management;
|
||||
|
||||
-- 历史任务及员工默认按正式用工处理;历史转正日期仅用于一次性初始化状态。
|
||||
UPDATE ob_onboarding_task
|
||||
SET EmploymentType = 'full_time'
|
||||
WHERE EmploymentType IS NULL OR EmploymentType NOT IN ('full_time', 'part_time');
|
||||
|
||||
UPDATE ob_employee
|
||||
SET
|
||||
EmploymentType = CASE
|
||||
WHEN EmploymentType = 'part_time' THEN 'part_time'
|
||||
ELSE 'full_time'
|
||||
END,
|
||||
RegularizationStatus = CASE
|
||||
WHEN EmploymentType = 'part_time' THEN 'not_applicable'
|
||||
WHEN RegularizationDate IS NOT NULL THEN 'regular'
|
||||
ELSE 'probation'
|
||||
END,
|
||||
SocialInsuranceStatus = CASE
|
||||
WHEN SocialInsuranceStatus IN (
|
||||
'unknown', 'not_purchased', 'purchased', 'stopped', 'not_applicable'
|
||||
) THEN SocialInsuranceStatus
|
||||
ELSE 'unknown'
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ob_employee_change_record (
|
||||
Id BIGINT NOT NULL COMMENT '主键',
|
||||
EmployeeId BIGINT NOT NULL COMMENT '员工Id',
|
||||
ChangeType VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更类型',
|
||||
EffectiveDate DATE DEFAULT NULL COMMENT '业务生效日期',
|
||||
BeforeJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '变更前快照',
|
||||
AfterJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '变更后快照',
|
||||
Remark VARCHAR(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
|
||||
ExtJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '扩展信息',
|
||||
Status VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'enable' COMMENT '记录状态',
|
||||
CreateTime DATETIME(3) DEFAULT NULL COMMENT '创建时间',
|
||||
UpdateTime DATETIME(3) DEFAULT NULL COMMENT '更新时间',
|
||||
CreateUserId BIGINT DEFAULT NULL COMMENT '创建者Id',
|
||||
UpdateUserId BIGINT DEFAULT NULL COMMENT '修改者Id',
|
||||
CreateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
|
||||
UpdateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
|
||||
IsDelete TINYINT(1) DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (Id),
|
||||
KEY idx_ob_employee_change_record_employee (EmployeeId, IsDelete, CreateTime),
|
||||
KEY idx_ob_employee_change_record_type (ChangeType, EffectiveDate)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工人事信息变更记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ob_employee_attachment (
|
||||
Id BIGINT NOT NULL COMMENT '主键',
|
||||
EmployeeId BIGINT NOT NULL COMMENT '员工Id',
|
||||
ChangeRecordId BIGINT DEFAULT NULL COMMENT '关联人事变更记录Id',
|
||||
BusinessType VARCHAR(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '附件业务类型',
|
||||
FileId BIGINT NOT NULL COMMENT '系统文件Id',
|
||||
FileName VARCHAR(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上传时文件名快照',
|
||||
UploadedAt DATETIME(3) NOT NULL COMMENT '上传关联时间',
|
||||
ExtJson LONGTEXT COLLATE utf8mb4_unicode_ci COMMENT '扩展信息',
|
||||
Status VARCHAR(20) COLLATE utf8mb4_unicode_ci DEFAULT 'enable' COMMENT '记录状态',
|
||||
CreateTime DATETIME(3) DEFAULT NULL COMMENT '创建时间',
|
||||
UpdateTime DATETIME(3) DEFAULT NULL COMMENT '更新时间',
|
||||
CreateUserId BIGINT DEFAULT NULL COMMENT '创建者Id',
|
||||
UpdateUserId BIGINT DEFAULT NULL COMMENT '修改者Id',
|
||||
CreateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人',
|
||||
UpdateUser VARCHAR(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '更新人',
|
||||
IsDelete TINYINT(1) DEFAULT 0 COMMENT '软删除',
|
||||
PRIMARY KEY (Id),
|
||||
KEY idx_ob_employee_attachment_employee (EmployeeId, BusinessType, IsDelete),
|
||||
KEY idx_ob_employee_attachment_change (ChangeRecordId, IsDelete),
|
||||
KEY idx_ob_employee_attachment_file (FileId)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工人事材料附件';
|
||||
|
||||
-- 标准试用薪资变量;已有同编码历史数据时恢复并统一为内置数值变量。
|
||||
INSERT INTO ob_variable_definition (
|
||||
Id, VariableCode, VariableName, VariableCategory, DataType,
|
||||
IsBuiltIn, Required, SortCode, Remark, Status,
|
||||
CreateTime, IsDelete
|
||||
) VALUES (
|
||||
841400000000115, 'hr.ProbationSalary', '试用薪资(元)', 'hr', 'number',
|
||||
1, 0, 335, '合同约定试用期薪资,不自动推算', 'enable',
|
||||
NOW(), 0
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
VariableName = VALUES(VariableName),
|
||||
VariableCategory = 'hr',
|
||||
DataType = 'number',
|
||||
IsBuiltIn = 1,
|
||||
SortCode = 335,
|
||||
Remark = VALUES(Remark),
|
||||
Status = 'enable',
|
||||
IsDelete = 0;
|
||||
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM ob_employee_change_record) AS '员工人事变更记录数',
|
||||
(SELECT COUNT(*) FROM ob_employee_attachment) AS '员工人事附件数',
|
||||
(SELECT COUNT(*) FROM ob_variable_definition
|
||||
WHERE VariableCode = 'hr.ProbationSalary' AND IsDelete = 0) AS '试用薪资标准变量数';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 入职登记表内置员工字段必填规则的任务快照表。
|
||||
-- 可在本地、测试和正式环境重复执行。
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `ob_task_form_requirement` (
|
||||
`Id` BIGINT NOT NULL COMMENT '主键',
|
||||
`TaskId` BIGINT NOT NULL COMMENT '入职任务Id',
|
||||
`FieldId` BIGINT NOT NULL COMMENT '原字段或变量定义Id',
|
||||
`FieldCode` VARCHAR(100) NOT NULL COMMENT '冻结字段编码',
|
||||
`FieldName` VARCHAR(200) NOT NULL COMMENT '冻结字段名称',
|
||||
`DataType` VARCHAR(50) NOT NULL COMMENT '冻结数据类型',
|
||||
`OptionsJson` JSON NULL COMMENT '冻结选项JSON',
|
||||
`Required` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否必填',
|
||||
`SortCode` INT NULL COMMENT '显示顺序',
|
||||
`Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '状态',
|
||||
`CreateTime` DATETIME(3) NULL COMMENT '创建时间',
|
||||
`UpdateTime` DATETIME(3) NULL COMMENT '更新时间',
|
||||
`CreateUserId` BIGINT NULL COMMENT '创建者Id',
|
||||
`UpdateUserId` BIGINT NULL COMMENT '修改者Id',
|
||||
`CreateUser` VARCHAR(200) NULL COMMENT '创建人',
|
||||
`UpdateUser` VARCHAR(200) NULL COMMENT '更新人',
|
||||
`IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除',
|
||||
`ExtJson` LONGTEXT NULL COMMENT '扩展信息',
|
||||
PRIMARY KEY (`Id`),
|
||||
UNIQUE KEY `uk_ob_task_form_requirement` (`TaskId`, `FieldId`),
|
||||
KEY `idx_ob_task_form_requirement_task_sort` (`TaskId`, `SortCode`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务入职登记字段必填快照';
|
||||
@@ -0,0 +1,75 @@
|
||||
-- 签署材料版本的公司盖章/骑缝章开关及任务、电子签快照。MySQL 8.0+
|
||||
-- 上线顺序:先在目标数据库执行本文件,再部署后端及后台前端。
|
||||
-- 可重复执行;只新增可空列,不回填或覆盖历史业务数据。
|
||||
-- NULL 表示历史规则:公司盖章需要,骑缝章沿用 QiyuesuoSetting.EnableAcrossPageSeal。
|
||||
-- 新上传版本显式保存 true/false;任务及电子签文档分别保存快照。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS ob_upgrade_contract_version_seal_switches;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE ob_upgrade_contract_version_seal_switches()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_contract_template_version'
|
||||
AND COLUMN_NAME = 'CompanySealEnabled'
|
||||
) THEN
|
||||
ALTER TABLE `ob_contract_template_version`
|
||||
ADD COLUMN `CompanySealEnabled` TINYINT(1) NULL DEFAULT NULL
|
||||
COMMENT '公司盖章开关;NULL沿用历史需要盖章';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_contract_template_version'
|
||||
AND COLUMN_NAME = 'AcrossPageSealEnabled'
|
||||
) THEN
|
||||
ALTER TABLE `ob_contract_template_version`
|
||||
ADD COLUMN `AcrossPageSealEnabled` TINYINT(1) NULL DEFAULT NULL
|
||||
COMMENT '骑缝章开关;NULL沿用历史系统配置';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_task_contract'
|
||||
AND COLUMN_NAME = 'CompanySealEnabled'
|
||||
) THEN
|
||||
ALTER TABLE `ob_task_contract`
|
||||
ADD COLUMN `CompanySealEnabled` TINYINT(1) NULL DEFAULT NULL
|
||||
COMMENT '公司盖章开关;NULL沿用历史需要盖章';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_task_contract'
|
||||
AND COLUMN_NAME = 'AcrossPageSealEnabled'
|
||||
) THEN
|
||||
ALTER TABLE `ob_task_contract`
|
||||
ADD COLUMN `AcrossPageSealEnabled` TINYINT(1) NULL DEFAULT NULL
|
||||
COMMENT '骑缝章开关;NULL沿用历史系统配置';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_electronic_sign_document'
|
||||
AND COLUMN_NAME = 'CompanySealEnabled'
|
||||
) THEN
|
||||
ALTER TABLE `ob_electronic_sign_document`
|
||||
ADD COLUMN `CompanySealEnabled` TINYINT(1) NULL DEFAULT NULL
|
||||
COMMENT '公司盖章开关;NULL沿用历史需要盖章';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'ob_electronic_sign_document'
|
||||
AND COLUMN_NAME = 'AcrossPageSealEnabled'
|
||||
) THEN
|
||||
ALTER TABLE `ob_electronic_sign_document`
|
||||
ADD COLUMN `AcrossPageSealEnabled` TINYINT(1) NULL DEFAULT NULL
|
||||
COMMENT '骑缝章开关;NULL沿用历史系统配置';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL ob_upgrade_contract_version_seal_switches();
|
||||
DROP PROCEDURE IF EXISTS ob_upgrade_contract_version_seal_switches;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user