42 Commits
Author SHA1 Message Date
fuenmao 83854ce901 新增公司盖章与骑缝章配置 2026-10-15 17:41:55 +08:00
fuenmao 0bb8263431 合同映射调整 2026-10-15 10:40:51 +08:00
fuenmao 14cfd4146e 修改正式配置 2026-10-14 16:03:12 +08:00
fuenmao 3eae5f8e5f 契约锁参数调整 2026-09-20 14:04:45 +08:00
fuenmao ff7e9c1496 相关问题修复 2026-09-18 20:44:57 +08:00
fuenmao c42d82d100 修复权限问题 2026-09-18 17:34:15 +08:00
fuenmao fa48e73b84 相关bug调整 2026-09-18 16:08:14 +08:00
fuenmao ab0248d73d 修改相关问题 2026-09-18 10:53:46 +08:00
fuenmao bd20ceac4e 修复相关bug 2026-09-17 16:04:14 +08:00
fuenmao aaec170c4f 新增必填项验证 2026-09-17 14:33:45 +08:00
fuenmao 9de05b44b8 修改相关bug 2026-09-17 11:37:39 +08:00
fuenmao e9894538e8 修复相关漏洞 2026-09-17 09:53:50 +08:00
fuenmao 20ea1bc70c 修复文件模版占位符错误问题 2026-09-15 16:43:45 +08:00
fuenmao 43a498162e 优化文档下载 2026-09-14 17:55:05 +08:00
fuenmao f004aec10f 修复相关bug 2026-09-14 15:59:37 +08:00
fuenmao 20409635d6 修复入职权限问题 2026-09-14 14:59:32 +08:00
fuenmao 1d925bd10b 员工入职模版修复 2026-09-14 10:44:30 +08:00
fuenmao 378bf7fe51 新增员工确认信息实名认证相关信息板块 2026-09-11 11:31:09 +08:00
fuenmao 571db6fc55 新增员工统计 2026-09-11 10:38:20 +08:00
fuenmao f858b04701 权限相关调整 2026-09-10 16:43:11 +08:00
fuenmao 1fd557a19e 配置调整 2026-09-09 17:08:23 +08:00
fuenmao 95b14b198a 111 2026-09-09 14:57:41 +08:00
fuenmao c015bc356c 完善第三方签署 2026-09-08 17:48:30 +08:00
fuenmao da777327f5 电子签基础功能 2026-09-07 16:17:49 +08:00
fuenmao 384eaff444 相关优化 2026-09-02 15:55:00 +08:00
fuenmao 6151872b6b 入职任务预览,入职任务cope,验证填入的信息 2026-08-29 17:26:29 +08:00
fuenmao 1d42c52d53 Merge branch 'master' of https://gitea.cnzhyx.cn/yuanlin/OnboardXAdmin 2026-08-29 11:27:09 +08:00
fuenmao b5ae0a44c7 正式库提交 2026-08-29 11:26:56 +08:00
tiananlin c925c8ba32 添加 SimpleAdmin.Web.Entry/Dockerfile 2026-08-28 18:10:45 +08:00
fuenmao f5b40e5659 1111 2026-08-28 17:33:30 +08:00
fuenmao 58804be611 1111 2026-08-28 11:11:22 +08:00
fuenmao 80940c60a5 1111 2026-08-27 16:27:08 +08:00
fuenmao ae8167a566 代码优化 2026-08-27 11:09:20 +08:00
fuenmao 658869b3a7 111 2026-08-26 19:21:29 +08:00
fuenmao 9741481a62 111 2026-08-26 18:17:58 +08:00
fuenmao db3d58bc99 1111 2026-08-26 15:30:31 +08:00
fuenmao 9ae5e0bc54 11 2026-08-26 11:00:15 +08:00
fuenmao be20a31332 111 2026-08-25 20:37:17 +08:00
fuenmao 38801533e9 调整 2026-08-25 13:54:16 +08:00
fuenmao 085bf5e2ab 逻辑调整 2026-08-25 10:20:53 +08:00
fuenmao 4679bd6c13 oss板块 2026-08-24 18:16:08 +08:00
fuenmao 0931e1f343 相关bug调整 2026-08-24 16:36:47 +08:00
172 changed files with 18464 additions and 1765 deletions
+3 -2
View File
@@ -8,6 +8,7 @@
</PropertyGroup>
<PropertyGroup>
<DocumentationFile>$(MSBuildProjectName).xml</DocumentationFile>
<!-- 由 SDK 按目标框架输出 XML,避免 net6/net7/net8 并行编译时共同写入项目根目录的同名文件。 -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
</Project>
@@ -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; }
}
@@ -17,6 +17,18 @@ namespace SimpleAdmin.Application;
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObContractTemplate : BaseEntity
{
/// <summary>
/// 合同模板所属入职业务公司 Id。
/// </summary>
[SugarColumn(IsNullable = true)]
public long? CompanyId { get; set; }
/// <summary>
/// 合同模板所属入职业务部门 Id。
/// </summary>
[SugarColumn(IsNullable = true)]
public long? DepartmentId { get; set; }
/// <summary>
/// 合同模板业务编码。
/// </summary>
@@ -36,4 +48,28 @@ public class ObContractTemplate : BaseEntity
/// 模板用途说明。
/// </summary>
public string Description { get; set; }
/// <summary>
/// 所属公司名称,仅用于接口展示。
/// </summary>
[SugarColumn(IsIgnore = true)]
public string CompanyName { get; set; }
/// <summary>
/// 所属部门名称,仅用于接口展示。
/// </summary>
[SugarColumn(IsIgnore = true)]
public string DepartmentName { get; set; }
/// <summary>
/// 适用岗位 Id 列表,仅用于接口展示和编辑回显。
/// </summary>
[SugarColumn(IsIgnore = true)]
public List<long> PositionIds { get; set; } = new();
/// <summary>
/// 适用岗位名称列表,仅用于接口展示。
/// </summary>
[SugarColumn(IsIgnore = true)]
public List<string> PositionNames { get; set; } = new();
}
@@ -0,0 +1,19 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 合同模板适用岗位关系,用于限制合同模板可被哪些岗位的入职模板选择。
/// </summary>
[SugarTable("ob_contract_template_position", TableDescription = "合同模板适用岗位")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObContractTemplatePosition : BaseEntity
{
/// <summary>
/// 合同模板主档案 Id。
/// </summary>
public long ContractTemplateId { get; set; }
/// <summary>
/// 适用岗位 Id。
/// </summary>
public long PositionId { 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>
@@ -0,0 +1,40 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务部门档案,属于某家入职公司。
/// </summary>
[SugarTable("ob_department", TableDescription = "入职业务部门")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObDepartment : BaseEntity
{
/// <summary>
/// 所属入职业务公司 Id。
/// </summary>
public long CompanyId { get; set; }
/// <summary>
/// 部门业务编码。
/// </summary>
public virtual string DepartmentCode { get; set; }
/// <summary>
/// 部门名称。
/// </summary>
public virtual string DepartmentName { get; set; }
/// <summary>
/// 排序码。
/// </summary>
public int SortCode { get; set; } = 99;
/// <summary>
/// 业务备注。
/// </summary>
public virtual string Remark { get; set; }
/// <summary>
/// 所属公司名称 (展示用)
/// </summary>
[SugarColumn(IsIgnore = true)]
public string CompanyName { get; set; }
}
@@ -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
{
@@ -37,6 +37,18 @@ public class ObEmployee : BaseEntity
/// </summary>
public long CompanyId { get; set; }
/// <summary>
/// 所属公司名称,仅用于接口展示,不映射数据库字段。
/// </summary>
[SugarColumn(IsIgnore = true)]
public string CompanyName { get; set; }
/// <summary>
/// 所属入职业务部门 Id。
/// </summary>
[SugarColumn(IsNullable = true)]
public long? DepartmentId { get; set; }
/// <summary>
/// 所属部门名称。
/// </summary>
@@ -47,6 +59,27 @@ 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>
[SugarColumn(IsIgnore = true)]
public string PositionName { get; set; }
/// <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; }
}
@@ -1,14 +1,4 @@
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
//
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。
// 2.请不要删除和修改SimpleAdmin源码头部的版权声明。
// 3.分发源码时候,请注明软件出处 https://gitee.com/dotnetmoyu/SimpleAdmin
// 4.基于本软件的作品,只能使用 SimpleAdmin 作为后台服务,除外情况不可商用且不允许二次分发或开源。
// 5.请不得将本软件应用于危害国家安全、荣誉和利益的行为,不能以任何形式用于非法为目的的行为。
// 6.任何基于本软件而产生的一切法律纠纷和责任,均于我司无关。
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 新员工入职任务,承载候选人填写、审核、转正式员工和归档全过程。
@@ -32,11 +22,27 @@ public class ObOnboardingTask : BaseEntity
/// </summary>
public long CompanyId { get; set; }
/// <summary>
/// 所属入职业务部门 Id。
/// </summary>
[SugarColumn(IsNullable = true)]
public long? DepartmentId { get; set; }
/// <summary>
/// 入职任务所属岗位 Id。
/// </summary>
public long PositionId { get; set; }
/// <summary>
/// 用工类型:full_time 正式用工,part_time 兼职用工。
/// </summary>
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
/// <summary>
/// 创建任务时锁定的入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 创建任务时指定的审核人用户 Id。
/// </summary>
@@ -81,4 +87,28 @@ public class ObOnboardingTask : BaseEntity
/// 业务备注。
/// </summary>
public string Remark { get; set; }
/// <summary>
/// 公司名称 (展示用)
/// </summary>
[SugarColumn(IsIgnore = true)]
public string CompanyName { get; set; }
/// <summary>
/// 部门名称 (展示用)
/// </summary>
[SugarColumn(IsIgnore = true)]
public string DepartmentName { get; set; }
/// <summary>
/// 岗位名称 (展示用)
/// </summary>
[SugarColumn(IsIgnore = true)]
public string PositionName { get; set; }
/// <summary>
/// 入职模板名称 (展示用)
/// </summary>
[SugarColumn(IsIgnore = true)]
public string OnboardingTemplateName { get; set; }
}
@@ -0,0 +1,35 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板,统一组织公司、岗位、合同、补充字段和上传材料。
/// </summary>
[SugarTable("ob_onboarding_template", TableDescription = "入职模板")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplate : BaseEntity
{
/// <summary>
/// 入职模板业务编码。
/// </summary>
public string TemplateCode { get; set; }
/// <summary>
/// 入职模板显示名称。
/// </summary>
public string TemplateName { get; set; }
/// <summary>
/// 所属入职业务公司 Id。
/// </summary>
public long CompanyId { get; set; }
/// <summary>
/// 所属入职业务部门 Id。
/// </summary>
[SugarColumn(IsNullable = true)]
public long? DepartmentId { get; set; }
/// <summary>
/// 模板用途和适用范围说明。
/// </summary>
public string Description { get; set; }
}
@@ -0,0 +1,39 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板需要使用的合同文件及锁定版本。
/// </summary>
[SugarTable("ob_onboarding_template_contract", TableDescription = "入职模板合同配置")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplateContract : BaseEntity
{
/// <summary>
/// 入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 合同模板主档 Id。
/// </summary>
public long ContractTemplateId { get; set; }
/// <summary>
/// 锁定的合同模板版本 Id。
/// </summary>
public long ContractTemplateVersionId { get; set; }
/// <summary>
/// 员工是否必须查看并确认该合同。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 合同打印份数。
/// </summary>
public int PrintCopies { get; set; } = 1;
/// <summary>
/// 显示和生成顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -0,0 +1,29 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板需要员工填写的动态补充字段。
/// </summary>
[SugarTable("ob_onboarding_template_form_field", TableDescription = "入职模板补充字段")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplateFormField : BaseEntity
{
/// <summary>
/// 入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 动态补充字段 Id。
/// </summary>
public long FieldId { get; set; }
/// <summary>
/// 员工是否必须填写。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 手机端显示顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -0,0 +1,29 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板需要员工上传的材料配置。
/// </summary>
[SugarTable("ob_onboarding_template_material", TableDescription = "入职模板上传材料")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplateMaterial : BaseEntity
{
/// <summary>
/// 入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 上传材料类型 Id。
/// </summary>
public long MaterialTypeId { get; set; }
/// <summary>
/// 员工是否必须上传。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 手机端显示顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -0,0 +1,19 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板适用岗位关系。
/// </summary>
[SugarTable("ob_onboarding_template_position", TableDescription = "入职模板适用岗位")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObOnboardingTemplatePosition : BaseEntity
{
/// <summary>
/// 入职模板 Id。
/// </summary>
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 适用岗位 Id。
/// </summary>
public long PositionId { get; set; }
}
@@ -11,7 +11,7 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务岗位档案,保存岗位默认合同期限、试用期及业务参数。
/// 入职业务岗位档案,保存岗位所属部门、默认合同期限、试用期及业务参数。
/// </summary>
[SugarTable("ob_position", TableDescription = "入职业务岗位")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
@@ -22,6 +22,12 @@ public class ObPosition : BaseEntity
/// </summary>
public long CompanyId { get; set; }
/// <summary>
/// 所属入职业务部门 Id。
/// </summary>
[SugarColumn(IsNullable = true)]
public long? DepartmentId { get; set; }
/// <summary>
/// 岗位业务编码。
/// </summary>
@@ -50,5 +56,17 @@ public class ObPosition : BaseEntity
/// <summary>
/// 业务备注。
/// </summary>
public string Remark { get; set; }
public virtual string Remark { get; set; }
/// <summary>
/// 所属公司名称 (展示用)
/// </summary>
[SugarColumn(IsIgnore = true)]
public string CompanyName { get; set; }
/// <summary>
/// 所属部门名称 (展示用)
/// </summary>
[SugarColumn(IsIgnore = true)]
public string DepartmentName { get; set; }
}
@@ -47,6 +47,17 @@ public class ObTaskContract : BaseEntity
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 任务创建时冻结的合同打印份数。
/// </summary>
public int PrintCopies { get; set; } = 1;
/// <summary>任务创建时冻结的公司盖章要求;历史空值沿用需要盖章。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>任务创建时冻结的骑缝章要求;历史空值沿用系统配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary>
/// 任务合同当前状态。
/// </summary>
@@ -42,6 +42,11 @@ public class ObTaskContractVariable : BaseEntity
/// </summary>
public string DataType { get; set; }
/// <summary>
/// 变量在任务合同中的展示顺序,创建任务时从模板变量复制并固定。
/// </summary>
public int SortCode { get; set; } = 100;
/// <summary>
/// 合同生成时使用的最终变量值。
/// </summary>
@@ -11,9 +11,9 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 候选人在入职任务中提交的固定人员基础信息。
/// 候选人在入职任务中提交的《入职登记表》标准员工档案信息。
/// </summary>
[SugarTable("ob_task_employee_info", TableDescription = "候选人提交的固定基础信息")]
[SugarTable("ob_task_employee_info", TableDescription = "候选人填写的入职登记表")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObTaskEmployeeInfo : BaseEntity
{
@@ -23,45 +23,57 @@ public class ObTaskEmployeeInfo : BaseEntity
public long TaskId { get; set; }
/// <summary>
/// 员工编号。
/// 员工编号(选填/人事分配)。
/// </summary>
public string EmployeeNo { get; set; }
/// <summary>
/// 内部人员编号。
/// </summary>
public string InternalNo { get; set; }
// ==================== 1. 基本个人信息 ====================
/// <summary>
/// 员工姓名。
/// </summary>
public string EmployeeName { get; set; }
/// <summary>
/// 所属部门名称。
/// </summary>
public string DepartmentName { get; set; }
/// <summary>
/// 性别。
/// </summary>
public string Gender { get; set; }
/// <summary>
/// 手机号码。
/// 民族。
/// </summary>
public string Phone { get; set; }
public string Nation { get; set; }
/// <summary>
/// 身份证号码。
/// 出生年月日。
/// </summary>
public string IdCardNumber { get; set; }
public string Birthday { get; set; }
/// <summary>
/// 毕业院校。
/// 户籍所在地。
/// </summary>
public string College { get; set; }
public string RegisteredResidence { get; set; }
/// <summary>
/// 身高 (cm)。
/// </summary>
public string Height { get; set; }
/// <summary>
/// 体重 (kg)。
/// </summary>
public string Weight { get; set; }
/// <summary>
/// 健康状况。
/// </summary>
public string HealthCondition { get; set; }
/// <summary>
/// 婚育情况。
/// </summary>
public string MaritalStatus { get; set; }
// ==================== 2. 学历与资质 ====================
/// <summary>
/// 最高学历。
/// </summary>
@@ -73,82 +85,125 @@ public class ObTaskEmployeeInfo : BaseEntity
public string Major { get; set; }
/// <summary>
/// 出生日期。
/// 毕业院校。
/// </summary>
public DateTime? BirthDate { get; set; }
public string GraduatedSchool { get; set; }
/// <summary>
/// 户籍所在地。
/// 职业资格。
/// </summary>
public string RegisteredResidence { get; set; }
public string VocationalQualification { get; set; }
/// <summary>
/// 民族。
/// 资格等级。
/// </summary>
public string Nation { get; set; }
public string QualificationLevel { get; set; }
/// <summary>
/// 现居住或联系地址。
/// 有何技术特长。
/// </summary>
public string ContactAddress { get; set; }
public string TechnicalSpecialty { get; set; }
// ==================== 3. 联系方式与薪酬账户 ====================
/// <summary>
/// 联系电话/手机号。
/// </summary>
public string Phone { get; set; }
/// <summary>
/// 入职日期。
/// 电子邮箱。
/// </summary>
public DateTime? EntryDate { get; set; }
public string Email { get; set; }
/// <summary>
/// 合同开始日期。
/// 身份证号码。
/// </summary>
public DateTime? ContractStartDate { get; set; }
public string IdCardNumber { get; set; }
/// <summary>
/// 合同结束日期。
/// 暂住地址 / 现居住地址。
/// </summary>
public DateTime? ContractEndDate { get; set; }
public string CurrentAddress { get; set; }
/// <summary>
/// 试用期开始日期。
/// 房东联系方式。
/// </summary>
public DateTime? ProbationStartDate { get; set; }
/// <summary>
/// 试用期结束日期。
/// </summary>
public DateTime? ProbationEndDate { get; set; }
/// <summary>
/// 转正日期。
/// </summary>
public DateTime? RegularizationDate { get; set; }
/// <summary>
/// 社保缴纳公司。
/// </summary>
public string SocialInsuranceCompany { get; set; }
/// <summary>
/// 合同性质。
/// </summary>
public string ContractNature { get; set; }
public string LandlordContact { get; set; }
/// <summary>
/// 工资银行卡号。
/// </summary>
public string BankCardNumber { get; set; }
/// <summary>
/// 银行卡开户名。
/// </summary>
public string BankAccountName { get; set; }
/// <summary>
/// 开户银行名称。
/// </summary>
public string BankName { get; set; }
/// <summary>
/// 业务备注。
/// 银行卡开户人姓名。
/// </summary>
public string Remark { get; set; }
public string BankAccountName { get; set; }
// ==================== 4. 紧急联系人 ====================
/// <summary>
/// 紧急联系人姓名。
/// </summary>
public string EmergencyContact { get; set; }
/// <summary>
/// 紧急联系人电话。
/// </summary>
public string EmergencyPhone { get; set; }
/// <summary>
/// 紧急联系人联系地址。
/// </summary>
public string EmergencyAddress { get; set; }
// ==================== 5. 动态多条经历(JSON 存储) ====================
/// <summary>
/// 教育学习经历 JSON 列表: [ { timeRange, location, content, result } ]
/// </summary>
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string EducationHistoryJson { get; set; }
/// <summary>
/// 主要工作经历 JSON 列表: [ { timeRange, companyName, salary, position, leaveReason } ]
/// </summary>
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string WorkHistoryJson { get; set; }
/// <summary>
/// 家庭成员 JSON 列表: [ { name, relationship, age, company, phone } ]
/// </summary>
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string FamilyMembersJson { get; set; }
// ==================== 6. 入职情况与说明 ====================
/// <summary>
/// 入职部门。
/// </summary>
public string OnboardingDepartment { get; set; }
/// <summary>
/// 入职时间。
/// </summary>
public string OnboardingDate { get; set; }
/// <summary>
/// 入职岗位。
/// </summary>
public string OnboardingPosition { get; set; }
/// <summary>
/// 其他说明。
/// </summary>
public string OtherRemarks { get; set; }
/// <summary>
/// 员工补充自定义变量键值 JSON: { "VariableName": "Value" }
/// </summary>
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string CustomVariablesJson { get; set; }
}
@@ -0,0 +1,49 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职任务创建时冻结的动态补充字段要求。
/// </summary>
[SugarTable("ob_task_form_requirement", TableDescription = "任务补充字段快照")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObTaskFormRequirement : BaseEntity
{
/// <summary>
/// 入职任务 Id。
/// </summary>
public long TaskId { get; set; }
/// <summary>
/// 原动态字段 Id。
/// </summary>
public long FieldId { get; set; }
/// <summary>
/// 冻结的字段业务编码。
/// </summary>
public string FieldCode { get; set; }
/// <summary>
/// 冻结的字段显示名称。
/// </summary>
public string FieldName { get; set; }
/// <summary>
/// 冻结的数据类型。
/// </summary>
public string DataType { get; set; }
/// <summary>
/// 冻结的选项 JSON。
/// </summary>
public string OptionsJson { get; set; }
/// <summary>
/// 是否必须填写。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 手机端显示顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -1,17 +1,7 @@
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
//
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。
// 2.请不要删除和修改SimpleAdmin源码头部的版权声明。
// 3.分发源码时候,请注明软件出处 https://gitee.com/dotnetmoyu/SimpleAdmin
// 4.基于本软件的作品,只能使用 SimpleAdmin 作为后台服务,除外情况不可商用且不允许二次分发或开源。
// 5.请不得将本软件应用于危害国家安全、荣誉和利益的行为,不能以任何形式用于非法为目的的行为。
// 6.任何基于本软件而产生的一切法律纠纷和责任,均于我司无关。
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 新员工专属填写链接,仅保存 Token 哈希及链接生命周期信息。
/// 新员工专属填写链接。
/// </summary>
[SugarTable("ob_task_link", TableDescription = "新员工专属填写链接")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
@@ -23,7 +13,14 @@ public class ObTaskLink : BaseEntity
public long TaskId { get; set; }
/// <summary>
/// 访问 Token 的 SHA-256 哈希,不保存明文 Token。
/// 明文 Token,仅供受保护的管理端链接接口内部读取。
/// </summary>
[SugarColumn(IsNullable = true)]
[global::System.Text.Json.Serialization.JsonIgnore]
public string PlainToken { get; set; }
/// <summary>
/// 访问 Token 的 SHA-256 哈希。
/// </summary>
public string TokenHash { get; set; }
@@ -0,0 +1,54 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职任务创建时冻结的上传材料要求。
/// </summary>
[SugarTable("ob_task_material_requirement", TableDescription = "任务上传材料快照")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObTaskMaterialRequirement : BaseEntity
{
/// <summary>
/// 入职任务 Id。
/// </summary>
public long TaskId { get; set; }
/// <summary>
/// 原材料类型 Id。
/// </summary>
public long MaterialTypeId { get; set; }
/// <summary>
/// 冻结的材料编码。
/// </summary>
public string MaterialCode { get; set; }
/// <summary>
/// 冻结的材料名称。
/// </summary>
public string MaterialName { get; set; }
/// <summary>
/// 冻结的允许扩展名。
/// </summary>
public string AllowedExtensions { get; set; }
/// <summary>
/// 冻结的最大文件数量。
/// </summary>
public int MaxFileCount { get; set; }
/// <summary>
/// 冻结的单文件大小上限,单位 MB。
/// </summary>
public int? MaxFileSizeMb { get; set; }
/// <summary>
/// 是否必须上传。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 手机端显示顺序。
/// </summary>
public int? SortCode { get; set; }
}
@@ -0,0 +1,55 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 全局合同变量字典定义表。
/// 包含标准入职登记表变量、人事合同变量及企业自定义变量。
/// </summary>
[SugarTable("ob_variable_definition", TableDescription = "合同变量字典定义")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObVariableDefinition : BaseEntity
{
/// <summary>
/// 变量全编码,如 employee.EmployeeName, hr.BaseSalary, company.CompanyName, system.TaskNo。
/// </summary>
public string VariableCode { get; set; }
/// <summary>
/// 变量中文显示名称,如“员工姓名”、“基本工资”。
/// </summary>
public string VariableName { get; set; }
/// <summary>
/// 变量作用域分类:employee(员工变量), hr(人事参数), company(公司变量), system(系统变量)。
/// </summary>
public string VariableCategory { get; set; }
/// <summary>
/// 数据类型:string, number, date, multiline。
/// </summary>
public string DataType { get; set; } = "string";
/// <summary>
/// 默认值或占位提示。
/// </summary>
public string DefaultValue { get; set; }
/// <summary>
/// 是否为入职登记表内置字段(内置字段不可删,且第1步自动收集)。
/// </summary>
public bool IsBuiltIn { get; set; } = false;
/// <summary>
/// 是否必填。
/// </summary>
public bool Required { get; set; } = false;
/// <summary>
/// 排序码。
/// </summary>
public int SortCode { get; set; } = 100;
/// <summary>
/// 变量说明/备注。
/// </summary>
public string Remark { get; set; }
}
@@ -36,7 +36,7 @@ public static class OnboardingTaskStatus
public const string Rejected = "rejected";
/// <summary>
/// 已通过,系统已经生成正式员工档案。
/// 已通过,系统已经生成员工档案。
/// </summary>
public const string Approved = "approved";
@@ -128,17 +128,147 @@ 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>
public static class ContractVariableType
{
/// <summary>
/// 导入模板时尚未确认来源的变量。
/// </summary>
public const string Unresolved = "unresolved";
/// <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,39 +1,47 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职审核业务能力。
/// 实现入职任务审核与退回业务能力。
/// </summary>
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>()
.FirstAsync(item => item.TaskId == task.Id)
?? throw Oops.Bah("候选人资料不存在");
var hasMaterialNeedSupplement = await _db.Queryable<ObTaskMaterial>()
var hasUnapprovedMaterial = await _db.Queryable<ObTaskMaterial>()
.AnyAsync(item => item.TaskId == task.Id
&& item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement);
if (hasMaterialNeedSupplement)
&& item.ReviewStatus != TaskMaterialReviewStatus.Passed);
if (hasUnapprovedMaterial)
{
throw Oops.Bah("仍有资料需要补充,不能审核通过");
throw Oops.Bah("仍有资料未审核通过,不能审核通过入职任务");
}
// 审核通过是创建正式员工的唯一入口,因此必须在入库前拦截身份证号或手机号重复。
@@ -44,26 +52,105 @@ public class OnboardingAuditService : IOnboardingAuditService
throw Oops.Bah("该身份证号或手机号已存在正式员工");
}
var extraValues = await _db.Queryable<ObTaskFormValue>()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
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.ExtraInfoJson = global::System.Text.Json.JsonSerializer.Serialize(extraValues);
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);
employee.DepartmentName = dept?.DepartmentName ?? employeeInfo.OnboardingDepartment;
}
else
{
employee.DepartmentName = employeeInfo.OnboardingDepartment;
}
employee.College = employeeInfo.GraduatedSchool;
employee.BirthDate = ParseDate(employeeInfo.Birthday);
employee.ContactAddress = employeeInfo.CurrentAddress;
employee.EntryDate = contractStartDate;
employee.ContractStartDate = contractStartDate;
employee.Remark = employeeInfo.OtherRemarks;
employee.ExtraInfoJson = global::System.Text.Json.JsonSerializer.Serialize(employeeInfo);
employee.Status = CommonStatusConst.ENABLE;
employee.CreateTime = DateTime.Now;
// 员工档案归属于原任务创建人,而不是执行审核的人。
employee.CreateUserId = task.CreateUserId;
employee.CreateUser = task.CreateUser;
employee.UpdateTime = null;
employee.UpdateUserId = null;
employee.UpdateUser = null;
employee.IsDelete = false;
// 员工档案、任务状态和审核记录必须同时成功,避免出现“已通过但没有员工档案”的中间状态。
await _db.Ado.BeginTranAsync();
try
{
var updatedRows = await _db.Updateable<ObOnboardingTask>()
.SetColumns(item => new ObOnboardingTask
{
EmployeeId = employee.Id,
TaskStatus = OnboardingTaskStatus.Approved,
AuditedAt = DateTime.Now
})
.Where(item => item.Id == task.Id
&& item.TaskStatus == OnboardingTaskStatus.Reviewing
&& item.EmployeeId == null)
.ExecuteCommandAsync();
if (updatedRows != 1)
throw Oops.Bah("该入职任务已被其他审核人处理,请刷新后查看");
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;
await _db.Updateable(task).ExecuteCommandAsync();
await WriteAudit(task, OnboardingAuditAction.Approve, input.AuditOpinion);
await _db.Ado.CommitTranAsync();
}
@@ -78,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))
{
@@ -85,10 +177,34 @@ public class OnboardingAuditService : IOnboardingAuditService
}
var task = await GetAuditableTask(input.TaskId);
task.TaskStatus = OnboardingTaskStatus.Rejected;
task.RejectReason = input.AuditOpinion;
await _db.Updateable(task).ExecuteCommandAsync();
await WriteAudit(task, OnboardingAuditAction.Reject, input.AuditOpinion);
await _db.Ado.BeginTranAsync();
try
{
var auditedAt = DateTime.Now;
var updatedRows = await _db.Updateable<ObOnboardingTask>()
.SetColumns(item => new ObOnboardingTask
{
TaskStatus = OnboardingTaskStatus.Rejected,
AuditedAt = auditedAt,
RejectReason = input.AuditOpinion
})
.Where(item => item.Id == task.Id
&& item.TaskStatus == OnboardingTaskStatus.Reviewing)
.ExecuteCommandAsync();
if (updatedRows != 1)
throw Oops.Bah("该入职任务已被其他审核人处理,请刷新后查看");
task.TaskStatus = OnboardingTaskStatus.Rejected;
task.AuditedAt = auditedAt;
task.RejectReason = input.AuditOpinion;
await WriteAudit(task, OnboardingAuditAction.Reject, input.AuditOpinion);
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
}
private async Task<ObOnboardingTask> GetAuditableTask(long taskId)
@@ -100,30 +216,66 @@ public class OnboardingAuditService : IOnboardingAuditService
throw Oops.Bah("当前任务不在审核中");
}
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds")
?? throw Oops.Bah("请配置入职审核人");
using var configJson = global::System.Text.Json.JsonDocument.Parse(config.ConfigValue);
// 审核人由全局配置与公司专属配置合并得出,不依赖独立业务表。
var reviewerIds = new List<long>();
if (configJson.RootElement.TryGetProperty("all", out var allReviewers))
{
reviewerIds.AddRange(allReviewers.EnumerateArray().Select(item => item.GetInt64()));
}
if (configJson.RootElement.TryGetProperty("company", out var companyReviewers)
&& companyReviewers.TryGetProperty(task.CompanyId.ToString(), out var reviewers))
{
reviewerIds.AddRange(reviewers.EnumerateArray().Select(item => item.GetInt64()));
}
if (!reviewerIds.Contains(UserManager.UserId))
{
throw Oops.Bah("您不是该入职任务的审核人");
}
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return task;
}
/// <summary>
/// 从任务签署材料参数中读取唯一的合同开始日期,作为正式员工入职日期。
/// </summary>
private async Task<DateTime> ResolveContractStartDate(long taskId)
{
var taskContractIds = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == taskId)
.Select(item => item.Id)
.ToListAsync();
if (taskContractIds.Count == 0)
throw Oops.Bah("入职任务没有签署材料,无法确定入职日期");
var startDateValues = await _db.Queryable<ObTaskContractVariable>()
.Where(item => taskContractIds.Contains(item.TaskContractId)
&& item.VariableCode == "hr.ContractStartDate")
.Select(item => item.VariableValue)
.ToListAsync();
var normalizedValues = startDateValues
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item => item.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalizedValues.Count == 0)
throw Oops.Bah("签署材料参数“合同开始日期”尚未填写,无法生成员工档案");
var parsedDates = new List<DateTime>();
foreach (var value in normalizedValues)
{
if (!DateTime.TryParse(value, out var parsedDate))
throw Oops.Bah("签署材料参数“合同开始日期”格式不正确");
parsedDates.Add(parsedDate.Date);
}
var distinctDates = parsedDates.Distinct().ToList();
if (distinctDates.Count > 1)
throw Oops.Bah("多个签署材料的合同开始日期不一致,请统一后再审核通过");
return distinctDates[0];
}
/// <summary>
/// 将登记表中的日期文本转换为正式员工档案日期。
/// </summary>
private static DateTime? ParseDate(string value)
{
return DateTime.TryParse(value, out var result)
? result.Date
: null;
}
private Task WriteAudit(ObOnboardingTask task, string action, string opinion)
{
return _db.Insertable(new ObTaskAudit
@@ -0,0 +1,25 @@
using SqlSugar;
namespace SimpleAdmin.Application;
/// <summary>版本盖章配置局部更新,避免覆盖另一个开关、版本状态或历史任务快照。</summary>
internal static class ContractTemplateVersionSealConfiguration
{
internal static IUpdateable<ObContractTemplateVersion> BuildUpdate(
ISqlSugarClient db,
OnboardingContractVersionSealInput input)
{
if (input.Id <= 0)
throw Oops.Bah("版本Id不能为空");
if (!input.CompanySealEnabled.HasValue && !input.AcrossPageSealEnabled.HasValue)
throw Oops.Bah("请指定需要修改的盖章开关");
var update = db.Updateable<ObContractTemplateVersion>()
.Where(item => item.Id == input.Id);
if (input.CompanySealEnabled.HasValue)
update = update.SetColumns(item => item.CompanySealEnabled == input.CompanySealEnabled);
if (input.AcrossPageSealEnabled.HasValue)
update = update.SetColumns(item => item.AcrossPageSealEnabled == input.AcrossPageSealEnabled);
return update;
}
}
@@ -0,0 +1,21 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 签署材料模板版本的时间有效性规则。
/// </summary>
internal static class ContractTemplateVersionValidity
{
internal static void ValidateRange(DateTime? effectiveAt, DateTime? expiresAt)
{
if (effectiveAt.HasValue && expiresAt.HasValue && expiresAt.Value <= effectiveAt.Value)
throw Oops.Bah("失效时间必须晚于生效时间");
}
internal static bool IsAvailable(ObContractTemplateVersion version, DateTime now)
{
return version != null
&& version.VersionStatus == ContractTemplateVersionStatus.Enabled
&& (!version.EffectiveDate.HasValue || version.EffectiveDate.Value <= now)
&& (!version.ExpiryDate.HasValue || version.ExpiryDate.Value > now);
}
}
@@ -0,0 +1,13 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务数据操作权限校验。
/// </summary>
public interface IOnboardingDataPermissionService : ITransient
{
Task<bool> CanManage(long? createUserId, string manageAllPermissionCode);
Task EnsureCanManage(long? createUserId, string manageAllPermissionCode, string dataName);
Task EnsureCanManage(IEnumerable<long?> createUserIds, string manageAllPermissionCode, string dataName);
}
@@ -0,0 +1,29 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 统一校验入职业务中的可编辑编码,避免非法字符或超长内容落到数据库后才报 500。
/// </summary>
internal static class OnboardingCodeValidator
{
/// <summary>
/// 规范化并校验业务编码。编码必须以字母开头,可包含字母、数字、点、下划线和短横线。
/// </summary>
public static string Normalize(string code, string fieldName, int maxLength)
{
var normalized = code?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
throw Oops.Bah($"{fieldName}不能为空");
if (normalized.Length > maxLength)
throw Oops.Bah($"{fieldName}长度不能超过 {maxLength} 个字符");
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
normalized,
@"^[A-Za-z][A-Za-z0-9._-]*$"))
{
throw Oops.Bah($"{fieldName}必须以字母开头,只能包含字母、数字、点、下划线和短横线");
}
return normalized;
}
}
@@ -0,0 +1,169 @@
using System.Data.Common;
using System.Security.Cryptography;
using System.Text;
namespace SimpleAdmin.Application;
/// <summary>
/// 所有修改同一任务的入口共用 MySQL 命名锁,适用于同一主库上的多个后端实例。
/// 锁使用独立、非池化连接,不在第三方请求期间持有业务数据库事务。
/// </summary>
internal static class OnboardingConcurrency
{
public static Task RunTask(SqlSugarScope db, long taskId, Func<Task> action,
bool transactional = false, bool protectIdentity = false)
=> RunTask(db, taskId, async () => { await action(); return true; },
transactional, protectIdentity);
public static Task<T> RunTask<T>(SqlSugarScope db, long taskId, Func<Task<T>> action,
bool transactional = false, bool protectIdentity = false)
=> Run(db, $"task:{taskId}", () => protectIdentity
? RunPerson(db, taskId, () => InTransaction(db, action, transactional))
: InTransaction(db, action, transactional));
public static Task RunEmployee(SqlSugarScope db, long employeeId, Func<Task> action)
=> Run(db, $"employee:{employeeId}", async () => { await action(); return true; });
public static Task RunPublic(SqlSugarScope db, string token, Func<Task> action,
bool transactional = false, bool protectIdentity = false)
=> RunPublic(db, token, async () => { await action(); return true; },
transactional, protectIdentity);
public static async Task<T> RunPublic<T>(SqlSugarScope db, string token, Func<Task<T>> action,
bool transactional = false, bool protectIdentity = false)
{
if (string.IsNullOrWhiteSpace(token))
throw Oops.Bah("入职链接 Token 不能为空");
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
var link = await db.Queryable<ObTaskLink>().FirstAsync(item => item.TokenHash == hash)
?? throw Oops.Bah("链接不存在");
// 此处只定位锁;被保护的方法必须在锁内重新检查链接、任务状态和归属。
return await RunTask(db, link.TaskId, action, transactional, protectIdentity);
}
public static Task RunContract(SqlSugarScope db, long contractId, Func<Task> action,
bool transactional = false)
=> RunContract(db, contractId, async () => { await action(); return true; }, transactional);
public static async Task<T> RunContract<T>(SqlSugarScope db, long contractId, Func<Task<T>> action,
bool transactional = false)
{
var contract = await db.Queryable<ObTaskContract>().InSingleAsync(contractId)
?? throw Oops.Bah("任务合同不存在");
return await RunTask(db, contract.TaskId, action, transactional);
}
public static async Task RunMaterial(SqlSugarScope db, long materialId, Func<Task> action)
{
var material = await db.Queryable<ObTaskMaterial>().InSingleAsync(materialId)
?? throw Oops.Bah("任务资料不存在");
await RunTask(db, material.TaskId, action, transactional: true);
}
private static async Task<T> RunPerson<T>(SqlSugarScope db, long taskId, Func<Task<T>> action)
{
var info = await db.Queryable<ObTaskEmployeeInfo>().FirstAsync(item => item.TaskId == taskId)
?? throw Oops.Bah("请先完整填写基本信息");
// 统一顺序:任务 -> 身份证 -> 手机;跨任务提交/建档的查重与写入也串行。
return await Run(db, "identity:" + info.IdCardNumber?.Trim().ToUpperInvariant(),
() => Run(db, "phone:" + info.Phone?.Trim(), action));
}
private static async Task<T> InTransaction<T>(SqlSugarScope db, Func<Task<T>> action, bool enabled)
{
if (!enabled || db.Ado.Transaction != null)
return await action();
await db.Ado.BeginTranAsync();
try
{
var result = await action();
await db.Ado.CommitTranAsync();
return result;
}
catch
{
await db.Ado.RollbackTranAsync();
throw;
}
}
private static Task<T> Run<T>(SqlSugarScope db, string resource, Func<Task<T>> action)
{
if (db.CurrentConnectionConfig.DbType != global::SqlSugar.DbType.MySql)
throw Oops.Bah("入职并发保护需要 MySQL 数据库");
// 使用已选择的业务主库,不能在只读副本/不同主库上分别申请锁。
var database = db.Ado.Connection.Database;
var key = Convert.ToHexString(SHA256.HashData(
Encoding.UTF8.GetBytes($"onboardx:{database}:{resource}")));
return OnboardingOperationGate.Run(key, () => Acquire(db, key), action);
}
private static async Task<IAsyncDisposable> Acquire(SqlSugarScope db, string key)
{
var connection = (DbConnection)Activator.CreateInstance(db.Ado.Connection.GetType());
var builder = new DbConnectionStringBuilder
{
ConnectionString = db.CurrentConnectionConfig.ConnectionString
};
// 确保异常关闭时会真正关闭持锁会话,不把命名锁留在连接池里。
builder["Pooling"] = false;
connection.ConnectionString = builder.ConnectionString;
try
{
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT GET_LOCK(@lockName, 0)";
command.CommandTimeout = 5;
AddKey(command, key);
var result = await command.ExecuteScalarAsync();
if (result == null || result == DBNull.Value || Convert.ToInt32(result) != 1)
throw Oops.Bah("当前数据正在处理,请稍后刷新重试");
return new NamedLock(connection, key);
}
catch
{
await connection.DisposeAsync();
throw;
}
}
private static void AddKey(DbCommand command, string key)
{
var parameter = command.CreateParameter();
parameter.ParameterName = "@lockName";
parameter.Value = key;
command.Parameters.Add(parameter);
}
private sealed class NamedLock : IAsyncDisposable
{
private readonly DbConnection _connection;
private readonly string _key;
public NamedLock(DbConnection connection, string key)
{
_connection = connection;
_key = key;
}
public async ValueTask DisposeAsync()
{
try
{
await using var command = _connection.CreateCommand();
command.CommandText = "SELECT RELEASE_LOCK(@lockName)";
command.CommandTimeout = 5;
AddKey(command, _key);
await command.ExecuteScalarAsync();
}
catch
{
// 非池化物理连接关闭会释放会话锁;释放错误不覆盖业务原异常。
}
finally
{
await _connection.DisposeAsync();
}
}
}
}
@@ -0,0 +1,50 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务数据操作权限校验。
/// 普通人事可查看全部业务数据,但只能操作本人创建的数据;拥有模块“管理全部”按钮权限的人员可操作全部数据。
/// </summary>
public class OnboardingDataPermissionService : IOnboardingDataPermissionService
{
private readonly ISysUserService _sysUserService;
public OnboardingDataPermissionService(ISysUserService sysUserService)
{
_sysUserService = sysUserService;
}
public async Task<bool> CanManage(long? createUserId, string manageAllPermissionCode)
{
if (UserManager.SuperAdmin)
return true;
if (createUserId.HasValue && createUserId.Value == UserManager.UserId)
return true;
var buttonCodes = await _sysUserService.GetButtonCodeList(UserManager.UserId);
return buttonCodes.Contains(manageAllPermissionCode);
}
public async Task EnsureCanManage(long? createUserId, string manageAllPermissionCode, string dataName)
{
if (!await CanManage(createUserId, manageAllPermissionCode))
throw Oops.Bah($"只能操作本人创建的{dataName}");
}
public async Task EnsureCanManage(
IEnumerable<long?> createUserIds,
string manageAllPermissionCode,
string dataName)
{
var owners = createUserIds.Distinct().ToList();
if (owners.Count == 0)
return;
if (UserManager.SuperAdmin || owners.All(id => id.HasValue && id.Value == UserManager.UserId))
return;
var buttonCodes = await _sysUserService.GetButtonCodeList(UserManager.UserId);
if (!buttonCodes.Contains(manageAllPermissionCode))
throw Oops.Bah($"所选数据中包含他人创建的{dataName},不能操作");
}
}
@@ -0,0 +1,35 @@
using System.Threading;
namespace SimpleAdmin.Application;
/// <summary>
/// 使同一异步调用链可重入,独立请求仍须取得外部互斥锁。
/// 不在 Acquire 中设置 AsyncLocal,避免值不能流回调用方的问题。
/// </summary>
internal static class OnboardingOperationGate
{
private sealed record Frame(string Key, Frame Parent);
private static readonly AsyncLocal<Frame> Held = new();
internal static async Task<T> Run<T>(
string key,
Func<Task<IAsyncDisposable>> acquire,
Func<Task<T>> action)
{
for (var frame = Held.Value; frame != null; frame = frame.Parent)
if (frame.Key == key)
return await action();
await using var lease = await acquire();
var previous = Held.Value;
Held.Value = new Frame(key, previous);
try
{
return await action();
}
finally
{
Held.Value = previous;
}
}
}
@@ -0,0 +1,13 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务按钮权限编码。
/// </summary>
public static class OnboardingPermissionCodes
{
public const string SetupManageAll = "onboardingSetupManageAll";
public const string EntryConfigManageAll = "onboardingEntryConfigManageAll";
public const string ContractManageAll = "onboardingContractManageAll";
public const string TaskManageAll = "onboardingTaskManageAll";
public const string EmployeeManageAll = "onboardingEmployeeManageAll";
}
@@ -5,6 +5,11 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingCompanyPageInput : BasePageInput
{
/// <summary>
/// 公司业务编码。
/// </summary>
public virtual string CompanyCode { get; set; }
/// <summary>
/// 公司名称。
/// </summary>
@@ -25,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,17 +5,23 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingCompanyService
{
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingCompanyService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询入职业务公司。
/// </summary>
public async Task<SqlSugarPagedList<ObCompany>> Page(OnboardingCompanyPageInput input)
{
return await Context.Queryable<ObCompany>()
.WhereIF(
!string.IsNullOrWhiteSpace(input.CompanyName),
it => it.CompanyName.Contains(input.CompanyName) || it.CompanyCode.Contains(input.CompanyName))
.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);
}
@@ -44,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>());
}
@@ -53,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>());
}
@@ -66,11 +78,36 @@ 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)
throw Oops.Bah("公司下仍有岗位,不能删除");
var hasDepartments = await Context.Queryable<ObDepartment>()
.AnyAsync(item => input.Ids.Contains(item.CompanyId));
if (hasDepartments)
throw Oops.Bah("公司下仍有部门,不能删除");
var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplate>()
.AnyAsync(item => input.Ids.Contains(item.CompanyId));
if (hasOnboardingTemplates)
throw Oops.Bah("公司已被入职模板引用,不能删除");
var hasContractTemplates = await Context.Queryable<ObContractTemplate>()
.AnyAsync(item => item.CompanyId.HasValue && input.Ids.Contains(item.CompanyId.Value));
if (hasContractTemplates)
throw Oops.Bah("公司已被合同模板引用,不能删除");
var hasTasks = await Context.Queryable<ObOnboardingTask>()
.AnyAsync(it => input.Ids.Contains(it.CompanyId));
if (hasTasks)
@@ -84,6 +121,36 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
}
private static void NormalizeAndValidateQiyuesuoConfig(ObCompany company)
{
company.CompanyCode = OnboardingCodeValidator.Normalize(company.CompanyCode, "公司编码", 50);
company.CompanyName = company.CompanyName?.Trim();
company.LegalName = company.LegalName?.Trim();
if (string.IsNullOrWhiteSpace(company.CompanyName))
throw Oops.Bah("公司名称不能为空");
company.QiyuesuoCategoryId = company.QiyuesuoCategoryId?.Trim();
company.QiyuesuoSealId = company.QiyuesuoSealId?.Trim();
var hasCategoryId = !string.IsNullOrWhiteSpace(company.QiyuesuoCategoryId);
var hasSealId = !string.IsNullOrWhiteSpace(company.QiyuesuoSealId);
if (hasCategoryId != hasSealId)
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写");
if (!hasCategoryId)
return;
if (!company.QiyuesuoCategoryId.All(char.IsDigit)
|| !company.QiyuesuoSealId.All(char.IsDigit))
{
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写纯数字");
}
if (!long.TryParse(company.QiyuesuoCategoryId, out _)
|| !long.TryParse(company.QiyuesuoSealId, out _))
{
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 超出有效数字范围(最大支持19位长整数)");
}
}
private async Task EnsureCodeUnique(string code, long id)
{
var exists = await Context.Queryable<ObCompany>()
@@ -11,3 +11,63 @@ public class OnboardingGenerateContractInput
[IdNotNull(ErrorMessage = "任务合同Id不能为空")]
public long TaskContractId { get; set; }
}
/// <summary>
/// 人事在创建入职任务前下载合同预览的参数。
/// </summary>
public class OnboardingContractPreviewInput
{
/// <summary>
/// 入职业务公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "公司Id不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 入职业务部门 Id。
/// </summary>
[IdNotNull(ErrorMessage = "部门Id不能为空")]
public long DepartmentId { get; set; }
/// <summary>
/// 入职岗位 Id。
/// </summary>
[IdNotNull(ErrorMessage = "岗位Id不能为空")]
public long PositionId { get; set; }
/// <summary>
/// 当前选择的入职模板 Id。
/// </summary>
[IdNotNull(ErrorMessage = "入职模板Id不能为空")]
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 本次需要预览的合同模板版本 Id;批量预览时无需传入。
/// </summary>
public long? ContractTemplateVersionId { get; set; }
/// <summary>
/// 人事当前在创建表单中填写的合同变量。
/// </summary>
public List<OnboardingInitialContractVariableInput> ContractVariables
{
get;
set;
} = new();
}
/// <summary>
/// 创建任务前生成的临时 DOCX 预览文件。
/// </summary>
public class OnboardingContractPreviewOutput
{
/// <summary>
/// DOCX 文件内容,仅通过当前请求返回,不保存到文件表或 OSS。
/// </summary>
public byte[] FileBytes { get; set; }
/// <summary>
/// 浏览器下载时使用的文件名。
/// </summary>
public string FileName { get; set; }
}
@@ -5,8 +5,25 @@ namespace SimpleAdmin.Application;
/// </summary>
public interface IOnboardingContractDocumentService : ITransient
{
/// <summary>
/// 创建入职任务前,根据人事当前填写的参数生成临时 DOCX 预览。
/// </summary>
Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
OnboardingContractPreviewInput input);
/// <summary>
/// 创建入职任务前,按模板顺序生成全部合同预览并打包为 ZIP。
/// </summary>
Task<OnboardingContractPreviewOutput> PreviewAllBeforeCreate(
OnboardingContractPreviewInput input);
/// <summary>
/// 根据已审核通过的任务合同生成并保存 DOCX 文件。
/// </summary>
Task<long> Generate(OnboardingGenerateContractInput input);
/// <summary>
/// 员工公开入口已验证一次性任务链接后,生成该链接所属的合同文件。
/// </summary>
Task<long> GenerateForPublicAccess(long taskContractId, long taskId);
}
@@ -1,32 +1,350 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职合同文件生成业务能力。
/// 实现入职合同预览和归档 DOCX 文件生成能力。
/// </summary>
public class OnboardingContractDocumentService : IOnboardingContractDocumentService
{
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 文件。
/// 在任务创建前生成指定合同的临时 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,
"入职任务");
if (task.TaskStatus != OnboardingTaskStatus.Approved)
throw Oops.Bah("仅审核通过的任务可生成归档合同");
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.Archived;
if (!statusAllowsGeneration)
throw Oops.Bah("当前任务状态不能生成合同");
if (contract.GeneratedDocxFileId.HasValue)
return contract.GeneratedDocxFileId.Value;
@@ -34,72 +352,106 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
?? throw Oops.Bah("合同版本不存在");
var source = await _db.Queryable<SysFile>().InSingleAsync(version.SourceDocxFileId)
?? throw Oops.Bah("合同源文件不存在");
var variables = await _db.Queryable<ObTaskContractVariable>()
.Where(x => x.TaskContractId == contract.Id)
var definitions = await _db.Queryable<ObTemplateVariable>()
.Where(item => item.TemplateVersionId == version.Id)
.ToListAsync();
var variables = await _db.Queryable<ObTaskContractVariable>()
.Where(item => item.TaskContractId == contract.Id)
.ToListAsync();
var duplicateVariable = variables
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateVariable != null)
{
throw Oops.Bah(
$"合同变量快照“{duplicateVariable.Key}”存在重复数据,请联系管理员修复后重试");
}
var missing = definitions
.Where(item => item.Required)
.FirstOrDefault(definition => string.IsNullOrWhiteSpace(
variables.FirstOrDefault(variable => variable.VariableCode == definition.VariableCode)?.VariableValue));
if (missing != null)
throw Oops.Bah($"合同“{contract.ContractName}”的变量“{missing.VariableName}”尚未填写,暂时不能预览合同");
var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id })
?? throw Oops.Bah("无法读取合同源文件");
await using var sourceStream = sourceResult.FileStream;
using var sourceBytes = new MemoryStream();
await sourceStream.CopyToAsync(sourceBytes);
// 合同只读取任务变量快照,确保模板或员工档案后续修改不会影响已生成文件。
var rendered = ReplaceDocxText(
var rendered = DocxTemplateProcessor.Render(
sourceBytes.ToArray(),
variables.ToDictionary(x => x.VariableCode, x => x.VariableValue ?? string.Empty));
var name = $"{task.TaskNo}_{contract.ContractName}.docx";
var fileId = await _fileService.StoreGeneratedFile(source.Engine, name, rendered);
variables.ToDictionary(
item => item.VariableCode,
item => item.VariableValue ?? string.Empty,
StringComparer.OrdinalIgnoreCase));
var generatedAt = DateTime.Now;
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,
rendered);
contract.GeneratedDocxFileId = fileId;
contract.GeneratedAt = DateTime.Now;
contract.GeneratedAt = generatedAt;
contract.ContractStatus = task.TaskStatus == OnboardingTaskStatus.Approved
? TaskContractStatus.OfflinePendingSign
: TaskContractStatus.PendingConfirm;
await _db.Updateable(contract).ExecuteCommandAsync();
return fileId;
}
private static byte[] ReplaceDocxText(byte[] docx, Dictionary<string, string> values)
/// <summary>
/// 判断变量是否允许人事在发起任务前填写。
/// </summary>
private static bool IsHrEditableDefinition(ObTemplateVariable definition)
{
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
global::System.IO.Compression.ZipArchiveMode.Read);
using var output = new MemoryStream();
using var target = new global::System.IO.Compression.ZipArchive(
output,
global::System.IO.Compression.ZipArchiveMode.Create,
true);
return definition.HrEditable
&& (definition.VariableType == "hr"
|| definition.VariableType == ContractVariableType.Contract
|| definition.VariableType == ContractVariableType.Company
|| definition.VariableCode.StartsWith(
"hr.",
StringComparison.OrdinalIgnoreCase));
}
// DOCX 本质是 ZIP 包,仅替换正文、页眉和页脚 XML 中的文本节点,其他资源原样复制。
foreach (var entry in archive.Entries)
{
var next = target.CreateEntry(entry.FullName, global::System.IO.Compression.CompressionLevel.Optimal);
using var reader = entry.Open();
using var writer = next.Open();
/// <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";
}
var isWordTextXml = entry.FullName == "word/document.xml"
|| ((entry.FullName.StartsWith("word/header") || entry.FullName.StartsWith("word/footer"))
&& entry.FullName.EndsWith(".xml"));
if (!isWordTextXml)
{
reader.CopyTo(writer);
continue;
}
/// <summary>
/// 创建任务前预览合同所需的已校验数据。
/// </summary>
private sealed class ContractPreviewContext
{
public ObCompany Company { get; init; }
using var textReader = new StreamReader(reader);
var document = global::System.Xml.Linq.XDocument.Parse(textReader.ReadToEnd());
var word = global::System.Xml.Linq.XNamespace.Get("http://schemas.openxmlformats.org/wordprocessingml/2006/main");
public List<ObOnboardingTemplateContract> ContractConfigs { get; init; }
foreach (var node in document.Descendants(word + "t"))
{
foreach (var item in values)
node.Value = node.Value.Replace("{{" + item.Key + "}}", item.Value ?? string.Empty);
}
public List<ObContractTemplate> ContractTemplates { get; init; }
using var textWriter = new StreamWriter(writer);
textWriter.Write(document.ToString(global::System.Xml.Linq.SaveOptions.DisableFormatting));
}
return output.ToArray();
public List<ObContractTemplateVersion> Versions { get; init; }
}
}
@@ -9,5 +9,5 @@ public interface IOnboardingContractSnapshotService : ITransient
/// <summary>
/// 根据任务、公司和候选人资料构建或刷新合同变量快照。
/// </summary>
Task Build(long taskId, ObTaskEmployeeInfo employeeInfo = null);
Task Build(long taskId, ObTaskEmployeeInfo employeeInfo = null, bool invalidateGeneratedPreview = false);
}
@@ -1,32 +1,57 @@
namespace SimpleAdmin.Application;
/// <summary>冻结任务合同变量;DOCX 生成阶段只读取本快照,不再读取模板实时值。</summary>
/// <summary>
/// 实现任务合同变量快照业务能力。
/// 构建任务合同变量快照,合同生成阶段只读取本快照。
/// </summary>
public class OnboardingContractSnapshotService : IOnboardingContractSnapshotService
{
private readonly SqlSugarScope _db;
public OnboardingContractSnapshotService() => _db = DbContext.DB;
public OnboardingContractSnapshotService()
{
_db = DbContext.DB;
}
/// <summary>
/// 根据任务、公司和候选人资料构建或刷新合同变量快照。
/// 根据任务、公司、员工固定信息和动态补充字段构建合同变量快照。
/// </summary>
public async Task Build(long taskId, ObTaskEmployeeInfo employeeInfo = null)
public async Task Build(
long taskId,
ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false)
{
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId) ?? throw Oops.Bah("入职任务不存在");
var company = await _db.Queryable<ObCompany>().InSingleAsync(task.CompanyId) ?? throw Oops.Bah("入职公司不存在");
employeeInfo ??= await _db.Queryable<ObTaskEmployeeInfo>().FirstAsync(x => x.TaskId == taskId);
var contracts = await _db.Queryable<ObTaskContract>().Where(x => x.TaskId == taskId).ToListAsync();
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("入职任务不存在");
var company = await _db.Queryable<ObCompany>().InSingleAsync(task.CompanyId)
?? throw Oops.Bah("入职公司不存在");
employeeInfo ??= await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == taskId);
// 员工补充合同变量统一从 CustomVariablesJson 读取,不再访问已移除的旧动态字段表。
var dynamicValueMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == taskId)
.ToListAsync();
foreach (var contract in contracts)
{
if (contract.GeneratedDocxFileId.HasValue || contract.GeneratedPdfFileId.HasValue) throw Oops.Bah("合同已生成,不能重建变量快照");
var definitions = await _db.Queryable<ObTemplateVariable>().Where(x => x.TemplateVersionId == contract.TemplateVersionId).OrderBy(x => x.SortCode).ToListAsync();
var oldRows = await _db.Queryable<ObTaskContractVariable>().Where(x => x.TaskContractId == contract.Id).ToListAsync();
var definitions = await _db.Queryable<ObTemplateVariable>()
.Where(item => item.TemplateVersionId == contract.TemplateVersionId)
.OrderBy(item => item.SortCode)
.ToListAsync();
var oldRows = await _db.Queryable<ObTaskContractVariable>()
.Where(item => item.TaskContractId == contract.Id)
.ToListAsync();
var rows = definitions.Select(definition =>
{
var old = oldRows.FirstOrDefault(x => x.VariableCode == definition.VariableCode);
// 人事手工填写的工资、补贴等合同参数优先级最高,刷新自动变量时不能覆盖。
var old = oldRows.FirstOrDefault(item => item.VariableCode == definition.VariableCode);
var preserveManual = old?.IsManualOverride == true;
return new ObTaskContractVariable
{
@@ -36,54 +61,188 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
VariableName = definition.VariableName,
VariableType = definition.VariableType,
DataType = definition.DataType,
VariableValue = preserveManual ? old.VariableValue : ResolveValue(definition, employeeInfo, company, task),
SourceType = preserveManual ? ContractVariableSourceType.HrInput : GetSourceType(definition.VariableType),
SortCode = definition.SortCode ?? 100,
VariableValue = preserveManual
? old.VariableValue
: ResolveValue(definition, employeeInfo, dynamicValueMap, company, task),
SourceType = preserveManual
? ContractVariableSourceType.HrInput
: GetSourceType(definition.VariableType),
IsManualOverride = preserveManual
};
}).ToList();
await _db.Deleteable<ObTaskContractVariable>().Where(x => x.TaskContractId == contract.Id).ExecuteCommandAsync();
if (rows.Count > 0) await _db.Insertable(rows).ExecuteCommandAsync();
// 仅当参与合同渲染的变量编码或变量值发生变化时,才作废已生成文件。
// 重复保存完全相同的员工资料不应导致员工刚查看的合同突然失效。
var snapshotChanged = oldRows.Count != rows.Count
|| rows.Any(row =>
{
var old = oldRows.FirstOrDefault(item =>
item.VariableCode == row.VariableCode);
return old == null
|| !string.Equals(
old.VariableValue,
row.VariableValue,
StringComparison.Ordinal);
});
await _db.Deleteable<ObTaskContractVariable>()
.Where(item => item.TaskContractId == contract.Id)
.ExecuteCommandAsync();
if (rows.Count > 0)
await _db.Insertable(rows).ExecuteCommandAsync();
if (invalidateGeneratedPreview
&& snapshotChanged
&& task.TaskStatus is OnboardingTaskStatus.PendingFill or OnboardingTaskStatus.Rejected)
{
contract.GeneratedDocxFileId = null;
contract.GeneratedPdfFileId = null;
contract.GeneratedAt = null;
contract.ReadAt = null;
contract.ConfirmedAt = null;
contract.ContractStatus = TaskContractStatus.PendingGenerate;
await _db.Updateable(contract).ExecuteCommandAsync();
}
}
}
private static string GetSourceType(string variableType) => variableType switch
private static string GetSourceType(string variableType)
{
ContractVariableType.Employee => ContractVariableSourceType.Employee,
ContractVariableType.Company => ContractVariableSourceType.Company,
ContractVariableType.System => ContractVariableSourceType.System,
_ => ContractVariableSourceType.HrInput
};
return variableType switch
{
ContractVariableType.Employee => ContractVariableSourceType.Employee,
ContractVariableType.Company => ContractVariableSourceType.Company,
ContractVariableType.System => ContractVariableSourceType.System,
_ => ContractVariableSourceType.HrInput
};
}
private static string ResolveValue(ObTemplateVariable definition, ObTaskEmployeeInfo info, ObCompany company, ObOnboardingTask task)
internal static string ResolveValue(
ObTemplateVariable definition,
ObTaskEmployeeInfo employeeInfo,
IReadOnlyDictionary<string, string> dynamicValues,
ObCompany company,
ObOnboardingTask task,
bool useEmployeePlaceholder = false)
{
var sourceField = GetSourceField(definition);
if (definition.VariableType == ContractVariableType.Employee)
return GetPropertyValue(info, definition.EmployeeField) ?? definition.DefaultValue;
{
var fixedValue = GetPropertyValue(employeeInfo, ResolveEmployeeField(sourceField), definition.FormatRule);
if (!string.IsNullOrWhiteSpace(fixedValue))
return fixedValue;
// 检查员工自定义补充变量 JSON
if (!string.IsNullOrWhiteSpace(employeeInfo?.CustomVariablesJson))
{
try
{
using var doc = global::System.Text.Json.JsonDocument.Parse(employeeInfo.CustomVariablesJson);
if (doc.RootElement.TryGetProperty(sourceField, out var prop)
|| doc.RootElement.TryGetProperty(definition.VariableCode, out prop))
{
var str = prop.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;
}
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah("员工补充变量数据格式异常,请重新保存入职登记表后再生成合同");
}
}
if (dynamicValues.TryGetValue(sourceField, out var dynamicValue))
return dynamicValue;
return useEmployeePlaceholder
? $"【待员工填写:{definition.VariableName ?? definition.VariableCode}】"
: definition.DefaultValue;
}
if (definition.VariableType == ContractVariableType.Company)
return GetPropertyValue(company, ResolveCompanyField(definition)) ?? definition.DefaultValue;
if (definition.VariableType == ContractVariableType.System && string.Equals(definition.VariableCode, "TASK_NO", StringComparison.OrdinalIgnoreCase))
return task.TaskNo;
return GetPropertyValue(company, ResolveCompanyField(sourceField), definition.FormatRule) ?? definition.DefaultValue;
if (definition.VariableType == ContractVariableType.System)
{
return sourceField.ToUpperInvariant() switch
{
"TASKNO" or "TASK_NO" or "CONTRACTNO" or "CONTRACT_NO" => task.TaskNo,
"CURRENTDATE" or "CURRENT_DATE" or "SIGNDATE" or "SIGN_DATE" => DateTime.Now.ToString("yyyy年MM月dd日"),
_ => definition.DefaultValue
};
}
return definition.DefaultValue;
}
private static string GetPropertyValue(object source, string propertyName)
private static string GetSourceField(ObTemplateVariable definition)
{
if (source == null || string.IsNullOrWhiteSpace(propertyName)) return null;
return source.GetType().GetProperty(propertyName, global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.Instance | global::System.Reflection.BindingFlags.IgnoreCase)?.GetValue(source)?.ToString();
if (!string.IsNullOrWhiteSpace(definition.EmployeeField))
return definition.EmployeeField;
var separatorIndex = definition.VariableCode?.IndexOf('.') ?? -1;
return separatorIndex >= 0
? definition.VariableCode[(separatorIndex + 1)..]
: definition.VariableCode;
}
private static string ResolveCompanyField(ObTemplateVariable definition)
private static string GetPropertyValue(object source, string propertyName, string formatRule = null)
{
if (!string.IsNullOrWhiteSpace(definition.EmployeeField)) return definition.EmployeeField;
return definition.VariableCode.ToUpperInvariant() switch
if (source == null || string.IsNullOrWhiteSpace(propertyName))
return null;
var value = source.GetType()
.GetProperty(
propertyName,
global::System.Reflection.BindingFlags.Public
| global::System.Reflection.BindingFlags.Instance
| global::System.Reflection.BindingFlags.IgnoreCase)
?.GetValue(source);
if (value == null)
return null;
if (value is DateTime dateTime)
return dateTime.ToString(string.IsNullOrWhiteSpace(formatRule) ? "yyyy年MM月dd日" : formatRule);
if (value is DateTimeOffset dateTimeOffset)
return dateTimeOffset.ToString(string.IsNullOrWhiteSpace(formatRule) ? "yyyy年MM月dd日" : formatRule);
if (!string.IsNullOrWhiteSpace(formatRule) && value is IFormattable formattable)
return formattable.ToString(formatRule, null);
return value.ToString();
}
private static string ResolveEmployeeField(string sourceField)
{
return sourceField.ToUpperInvariant() switch
{
"COMPANY_CODE" => nameof(ObCompany.CompanyCode),
"COMPANY_NAME" => nameof(ObCompany.CompanyName),
"LEGAL_NAME" or "COMPANY_LEGAL_NAME" => nameof(ObCompany.LegalName),
"UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
"LEGAL_REPRESENTATIVE" => nameof(ObCompany.LegalRepresentative),
"REGISTERED_ADDRESS" => nameof(ObCompany.RegisteredAddress),
"CONTACT_PHONE" => nameof(ObCompany.ContactPhone),
_ => definition.VariableCode
"FULLNAME" or "FULL_NAME" or "NAME" => nameof(ObTaskEmployeeInfo.EmployeeName),
"MOBILE" or "MOBILEPHONE" or "MOBILE_PHONE" => nameof(ObTaskEmployeeInfo.Phone),
"IDNUMBER" or "ID_NUMBER" or "IDENTITYNUMBER" => nameof(ObTaskEmployeeInfo.IdCardNumber),
_ => sourceField
};
}
private static string ResolveCompanyField(string sourceField)
{
return sourceField.ToUpperInvariant() switch
{
// 内置合同变量 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),
"LEGALREPRESENTATIVE" or "LEGAL_REPRESENTATIVE" => nameof(ObCompany.LegalRepresentative),
"ADDRESS" or "COMPANYADDRESS" or "COMPANY_ADDRESS"
or "REGISTEREDADDRESS" or "REGISTERED_ADDRESS" => nameof(ObCompany.RegisteredAddress),
"PHONE" or "COMPANYPHONE" or "COMPANY_PHONE"
or "CONTACTPHONE" or "CONTACT_PHONE" => nameof(ObCompany.ContactPhone),
_ => sourceField
};
}
}
@@ -0,0 +1,264 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 负责从 DOCX 中识别占位符并在不依赖 Word 进程的情况下替换文本节点。
/// </summary>
public static class DocxTemplateProcessor
{
private static readonly global::System.Text.RegularExpressions.Regex PlaceholderRegex = new(
@"\{\{\s*(?<code>[A-Za-z][A-Za-z0-9_.-]*)\s*\}\}",
global::System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly global::System.Xml.Linq.XNamespace WordNamespace =
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
/// <summary>
/// 扫描正文、表格、页眉和页脚,返回去重后的变量编码。
/// </summary>
public static List<string> ExtractVariableCodes(byte[] docx)
{
ValidateDocxHeader(docx);
try
{
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
global::System.IO.Compression.ZipArchiveMode.Read);
if (archive.GetEntry("word/document.xml") == null)
throw Oops.Bah("上传文件不是有效的 DOCX 文档,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
{
using var stream = entry.Open();
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}}。");
}
if (text.Contains("{{{", StringComparison.Ordinal)
|| text.Contains("}}}", StringComparison.Ordinal))
{
throw Oops.Bah(
"DOCX 占位符格式错误:检测到多余的花括号。占位符必须使用成对的英文半角双花括号,例如 {{employee.EmployeeName}}。");
}
var searchStart = 0;
while (searchStart < text.Length)
{
var openIndex = text.IndexOf("{{", searchStart, StringComparison.Ordinal);
var closeIndex = text.IndexOf("}}", searchStart, StringComparison.Ordinal);
if (closeIndex >= 0 && (openIndex < 0 || closeIndex < openIndex))
{
throw Oops.Bah(
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, closeIndex)} 缺少对应的起始符“{{{{”。正确格式示例:{{{{employee.EmployeeName}}}}。");
}
if (openIndex < 0)
return;
closeIndex = text.IndexOf("}}", openIndex + 2, StringComparison.Ordinal);
if (closeIndex < 0)
{
throw Oops.Bah(
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, openIndex)} 缺少结束符“}}}}”。正确格式示例:{{{{employee.EmployeeName}}}}。");
}
var placeholder = text.Substring(openIndex, closeIndex + 2 - openIndex);
var match = PlaceholderRegex.Match(placeholder);
if (!match.Success || match.Length != placeholder.Length)
{
throw Oops.Bah(
$"DOCX 占位符格式错误:“{placeholder}”。变量编码只能包含英文字母、数字、下划线、点和短横线,并且必须使用成对的英文半角双花括号。");
}
searchStart = closeIndex + 2;
}
}
private static string BuildPlaceholderPreview(string text, int markerIndex)
{
const int maxLength = 60;
var preview = text[markerIndex..].Trim();
if (preview.Length > maxLength)
preview = preview[..maxLength] + "…";
return $"“{preview}”";
}
/// <summary>
/// 使用任务变量快照替换 DOCX 中的占位符,支持占位符跨多个 Word 文本节点。
/// </summary>
public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values)
{
ValidateDocxHeader(docx);
using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive(
input,
global::System.IO.Compression.ZipArchiveMode.Read);
using var output = new MemoryStream();
using var target = new global::System.IO.Compression.ZipArchive(
output,
global::System.IO.Compression.ZipArchiveMode.Create,
true);
foreach (var entry in archive.Entries)
{
var next = target.CreateEntry(
entry.FullName,
global::System.IO.Compression.CompressionLevel.Optimal);
using var sourceStream = entry.Open();
using var targetStream = next.Open();
if (!IsWordTextXml(entry.FullName))
{
sourceStream.CopyTo(targetStream);
continue;
}
var document = global::System.Xml.Linq.XDocument.Load(sourceStream);
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
ReplaceParagraphVariables(paragraph, values);
document.Save(targetStream, global::System.Xml.Linq.SaveOptions.DisableFormatting);
}
target.Dispose();
return output.ToArray();
}
private static void ReplaceParagraphVariables(
global::System.Xml.Linq.XElement paragraph,
IReadOnlyDictionary<string, string> values)
{
var replacements = 0;
while (replacements < 1000)
{
var nodes = paragraph.Descendants(WordNamespace + "t").ToList();
var text = string.Concat(nodes.Select(node => node.Value));
var match = PlaceholderRegex.Matches(text)
.Cast<global::System.Text.RegularExpressions.Match>()
.LastOrDefault(item => values.ContainsKey(item.Groups["code"].Value.Trim()));
if (match == null)
return;
var code = match.Groups["code"].Value.Trim();
ReplaceRange(nodes, match.Index, match.Length, values[code] ?? string.Empty);
replacements++;
}
throw Oops.Bah("合同模板单个段落中的变量数量异常");
}
private static void ReplaceRange(
List<global::System.Xml.Linq.XElement> nodes,
int start,
int length,
string replacement)
{
var end = start + length;
var offset = 0;
global::System.Xml.Linq.XElement startNode = null;
global::System.Xml.Linq.XElement endNode = null;
var startIndex = 0;
var endIndex = 0;
foreach (var node in nodes)
{
var nextOffset = offset + node.Value.Length;
if (startNode == null && start >= offset && start < nextOffset)
{
startNode = node;
startIndex = start - offset;
}
if (end > offset && end <= nextOffset)
{
endNode = node;
endIndex = end - offset;
break;
}
offset = nextOffset;
}
if (startNode == null || endNode == null)
return;
if (ReferenceEquals(startNode, endNode))
{
startNode.Value = startNode.Value[..startIndex]
+ replacement
+ startNode.Value[endIndex..];
return;
}
var startNodePosition = nodes.IndexOf(startNode);
var endNodePosition = nodes.IndexOf(endNode);
startNode.Value = startNode.Value[..startIndex] + replacement;
for (var index = startNodePosition + 1; index < endNodePosition; index++)
nodes[index].Value = string.Empty;
endNode.Value = endNode.Value[endIndex..];
}
private static bool IsWordTextXml(string entryName)
{
return entryName == "word/document.xml"
|| ((entryName.StartsWith("word/header", StringComparison.OrdinalIgnoreCase)
|| entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase))
&& entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
}
/// <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)”后重新上传");
}
}
@@ -1,73 +0,0 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 合同适用范围分页查询参数。
/// </summary>
public class OnboardingContractScopePageInput : BasePageInput
{
/// <summary>
/// 入职业务公司 Id。
/// </summary>
public long? CompanyId { get; set; }
/// <summary>
/// 岗位 Id;为空时表示公司级配置。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 合同模板 Id。
/// </summary>
public long? TemplateId { get; set; }
}
/// <summary>
/// 新增合同适用范围参数。
/// </summary>
public class OnboardingContractScopeAddInput
{
/// <summary>
/// 入职业务公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "公司Id不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 岗位 Id;为空时表示公司级配置。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 合同模板 Id。
/// </summary>
[IdNotNull(ErrorMessage = "模板Id不能为空")]
public long TemplateId { get; set; }
/// <summary>
/// 合同模板版本 Id。
/// </summary>
[IdNotNull(ErrorMessage = "模板版本Id不能为空")]
public long TemplateVersionId { get; set; }
/// <summary>
/// 是否必填、必传或必签。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 显示排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 编辑合同适用范围参数。
/// </summary>
public class OnboardingContractScopeEditInput : OnboardingContractScopeAddInput
{
/// <summary>
/// 业务数据 Id。
/// </summary>
[IdNotNull(ErrorMessage = "适用范围Id不能为空")]
public long Id { get; set; }
}
@@ -5,6 +5,21 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingContractTemplatePageInput : BasePageInput
{
/// <summary>
/// 所属公司 Id。
/// </summary>
public long? CompanyId { get; set; }
/// <summary>
/// 所属部门 Id。
/// </summary>
public long? DepartmentId { get; set; }
/// <summary>
/// 适用岗位 Id。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 合同模板名称。
/// </summary>
@@ -26,6 +41,23 @@ public class OnboardingContractTemplatePageInput : BasePageInput
/// </summary>
public class OnboardingContractTemplateAddInput
{
/// <summary>
/// 所属公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "所属公司不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 所属部门 Id。
/// </summary>
[IdNotNull(ErrorMessage = "所属部门不能为空")]
public long DepartmentId { get; set; }
/// <summary>
/// 合同模板适用的岗位 Id 列表。
/// </summary>
public List<long> PositionIds { get; set; } = new();
/// <summary>
/// 合同模板业务编码。
/// </summary>
@@ -66,3 +98,27 @@ public class OnboardingContractTemplateEditInput : OnboardingContractTemplateAdd
[IdNotNull(ErrorMessage = "模板Id不能为空")]
public long Id { get; set; }
}
/// <summary>
/// 查询公司、部门及岗位可选合同模板参数。
/// </summary>
public class OnboardingContractTemplateAvailableInput
{
/// <summary>
/// 所属公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "所属公司不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 所属部门 Id。
/// </summary>
[IdNotNull(ErrorMessage = "所属部门不能为空")]
public long DepartmentId { get; set; }
/// <summary>
/// 入职模板已选择的岗位 Id 列表。
/// 返回结果必须同时适用于这些岗位。
/// </summary>
public List<long> PositionIds { get; set; } = new();
}
@@ -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; }
}
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义合同模板、版本、变量和适用范围业务能力。
/// 定义合同模板、DOCX 版本和自动识别变量的管理能力。
/// </summary>
public interface IOnboardingContractTemplateService : ITransient
{
@@ -9,59 +9,64 @@ public interface IOnboardingContractTemplateService : ITransient
/// 分页查询合同模板主档案。
/// </summary>
Task<SqlSugarPagedList<ObContractTemplate>> Page(OnboardingContractTemplatePageInput input);
/// <summary>
/// 查询合同模板详情。
/// </summary>
Task<ObContractTemplate> Detail(BaseIdInput input);
/// <summary>
/// 查询同时适用于指定公司、部门和全部已选岗位的启用合同模板。
/// </summary>
Task<List<ObContractTemplate>> ListAvailable(OnboardingContractTemplateAvailableInput input);
/// <summary>
/// 新增合同模板主档案。
/// </summary>
Task Add(OnboardingContractTemplateAddInput input);
/// <summary>
/// 编辑合同模板主档案。
/// </summary>
Task Edit(OnboardingContractTemplateEditInput input);
/// <summary>
/// 删除未被版本、适用范围或任务合同引用的模板。
/// 删除未被版本、入职模板或任务合同引用的模板。
/// </summary>
Task Delete(BaseIdListInput input);
/// <summary>
/// 分页查询指定合同模板的版本。
/// </summary>
Task<SqlSugarPagedList<ObContractTemplateVersion>> VersionPage(OnboardingContractVersionPageInput input);
/// <summary>
/// 新增合同模板 DOCX 版本。
/// 新增合同模板 DOCX 版本并自动识别变量。
/// </summary>
Task AddVersion(OnboardingContractVersionAddInput input);
/// <summary>
/// 启用或停用合同模板版本。
/// </summary>
Task SetVersionStatus(OnboardingContractVersionStatusInput input);
/// <summary>
/// 分页查询指定模板版本的变量定义。
/// 修改合同模板版本的盖章配置,仅影响后续新建任务。
/// </summary>
Task SetVersionSeals(OnboardingContractVersionSealInput input);
/// <summary>
/// 删除未被入职模板或任务使用的合同版本。
/// </summary>
Task DeleteVersion(BaseIdInput input);
/// <summary>
/// 分页查询指定模板版本自动识别出的变量。
/// </summary>
Task<SqlSugarPagedList<ObTemplateVariable>> VariablePage(OnboardingTemplateVariablePageInput input);
/// <summary>
/// 为草稿模板版本新增变量。
/// </summary>
Task AddVariable(OnboardingTemplateVariableAddInput input);
/// <summary>
/// 编辑草稿模板版本中的变量。
/// 编辑并确认模板版本中的变量定义。
/// </summary>
Task EditVariable(OnboardingTemplateVariableEditInput input);
/// <summary>
/// 删除草稿模板版本中的变量。
/// </summary>
Task DeleteVariable(BaseIdListInput input); Task<SqlSugarPagedList<ObContractScope>> ScopePage(OnboardingContractScopePageInput input);
/// <summary>
/// 新增公司或岗位的合同适用范围。
/// </summary>
Task AddScope(OnboardingContractScopeAddInput input);
/// <summary>
/// 编辑合同适用范围。
/// </summary>
Task EditScope(OnboardingContractScopeEditInput input);
/// <summary>
/// 删除合同适用范围。
/// </summary>
Task DeleteScope(BaseIdListInput input);}
}
@@ -5,20 +5,47 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingContractTemplateService : DbRepository<ObContractTemplate>, IOnboardingContractTemplateService
{
private readonly IFileService _fileService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingContractTemplateService(
IFileService fileService,
IOnboardingDataPermissionService dataPermission)
{
_fileService = fileService;
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询合同模板主档案。
/// </summary>
public async Task<SqlSugarPagedList<ObContractTemplate>> Page(OnboardingContractTemplatePageInput input)
{
return await Context.Queryable<ObContractTemplate>()
var query = Context.Queryable<ObContractTemplate>()
.WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value)
.WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value)
.WhereIF(
!string.IsNullOrWhiteSpace(input.TemplateName),
it => it.TemplateName.Contains(input.TemplateName) || it.TemplateCode.Contains(input.TemplateName))
.WhereIF(!string.IsNullOrWhiteSpace(input.ContractType), it => it.ContractType == input.ContractType)
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status);
if (input.PositionId.HasValue)
{
var templateIds = await Context.Queryable<ObContractTemplatePosition>()
.Where(item => item.PositionId == input.PositionId.Value)
.Select(item => item.ContractTemplateId)
.ToListAsync();
query = query.Where(item => templateIds.Contains(item.Id));
}
var page = await query
.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());
return page;
}
/// <summary>
@@ -26,7 +53,61 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary>
public async Task<ObContractTemplate> Detail(BaseIdInput input)
{
return await GetByIdAsync(input.Id) ?? throw Oops.Bah("合同模板不存在");
var template = await GetByIdAsync(input.Id) ?? throw Oops.Bah("合同模板不存在");
await PopulateScopeDisplay(new List<ObContractTemplate> { template });
return template;
}
/// <summary>
/// 查询同时覆盖全部已选岗位的启用合同模板。
/// </summary>
public async Task<List<ObContractTemplate>> ListAvailable(
OnboardingContractTemplateAvailableInput input)
{
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>();
if (input.PositionIds.Count == 0)
return new List<ObContractTemplate>();
await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds);
var candidates = await Context.Queryable<ObContractTemplate>()
.Where(item => item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == "enable")
.OrderBy(item => item.TemplateName)
.ToListAsync();
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();
var availableIds = relations
.GroupBy(item => item.ContractTemplateId)
.Where(group => input.PositionIds.All(positionId =>
group.Any(item => item.PositionId == positionId)))
.Select(group => group.Key)
.ToHashSet();
var availableTemplates = candidates
.Where(item => availableIds.Contains(item.Id))
.ToList();
await PopulateScopeDisplay(availableTemplates);
return availableTemplates;
}
/// <summary>
@@ -34,8 +115,24 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary>
public async Task Add(OnboardingContractTemplateAddInput input)
{
NormalizeInput(input);
await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds);
await EnsureCodeUnique(input.TemplateCode, 0);
await InsertAsync(input.Adapt<ObContractTemplate>());
var templateId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var entity = BuildEntity(templateId, input);
await Context.Ado.BeginTranAsync();
try
{
await Context.Insertable(entity).ExecuteCommandAsync();
await InsertPositionRelations(templateId, input.PositionIds);
await Context.Ado.CommitTranAsync();
}
catch
{
await Context.Ado.RollbackTranAsync();
throw;
}
}
/// <summary>
@@ -43,35 +140,131 @@ 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, template.Status);
await EnsureCodeUnique(input.TemplateCode, input.Id);
await UpdateAsync(input.Adapt<ObContractTemplate>());
var entity = BuildEntity(input.Id, input);
await Context.Ado.BeginTranAsync();
try
{
await Context.Updateable(entity)
.IgnoreColumns(ignoreAllNullColumns: true)
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplatePosition>()
.Where(item => item.ContractTemplateId == input.Id)
.ExecuteCommandAsync();
await InsertPositionRelations(input.Id, input.PositionIds);
await Context.Ado.CommitTranAsync();
}
catch
{
await Context.Ado.RollbackTranAsync();
throw;
}
}
/// <summary>
/// 删除未被版本、适用范围或任务合同引用的模板。
/// 删除未被入职模板或任务合同引用的模板(级联删除未使用的版本和变量)。
/// </summary>
public async Task Delete(BaseIdListInput input)
{
if (input.Ids.Count == 0)
return;
var hasVersions = await Context.Queryable<ObContractTemplateVersion>()
.AnyAsync(it => input.Ids.Contains(it.TemplateId));
if (hasVersions)
throw Oops.Bah("模板已有版本,不能删除");
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 hasScopes = await Context.Queryable<ObContractScope>()
.AnyAsync(it => input.Ids.Contains(it.TemplateId));
if (hasScopes)
throw Oops.Bah("模板已配置适用范围,不能删除");
var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => input.Ids.Contains(it.ContractTemplateId));
if (hasOnboardingTemplates)
throw Oops.Bah("模板已被入职模板引用,不能删除,请先在入职模板中移除关联");
var hasContracts = await Context.Queryable<ObTaskContract>()
.AnyAsync(it => input.Ids.Contains(it.TemplateId));
if (hasContracts)
throw Oops.Bah("模板已生成入职合同,不能删除");
throw Oops.Bah("模板已生成入职任务合同,不能删除,可改为停用");
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
var versionIds = await Context.Queryable<ObContractTemplateVersion>()
.Where(it => input.Ids.Contains(it.TemplateId))
.Select(it => it.Id)
.ToListAsync();
await Context.Ado.BeginTranAsync();
try
{
if (versionIds.Count > 0)
{
await Context.Deleteable<ObTemplateVariable>()
.Where(it => versionIds.Contains(it.TemplateVersionId))
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplateVersion>()
.In(versionIds)
.ExecuteCommandAsync();
}
await Context.Deleteable<ObContractTemplatePosition>()
.Where(item => input.Ids.Contains(item.ContractTemplateId))
.ExecuteCommandAsync();
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
await Context.Ado.CommitTranAsync();
}
catch
{
await Context.Ado.RollbackTranAsync();
throw;
}
}
/// <summary>
/// 删除未被入职模板或任务使用的合同版本。
/// </summary>
public async Task DeleteVersion(BaseIdInput input)
{
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("合同版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
var isUsedInOnboardingTemplate = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => it.ContractTemplateVersionId == version.Id);
if (isUsedInOnboardingTemplate)
throw Oops.Bah("该版本已被入职模板引用,不能删除,请先在入职模板中移除关联");
var isUsedInTask = await Context.Queryable<ObTaskContract>()
.AnyAsync(it => it.TemplateVersionId == version.Id);
if (isUsedInTask)
throw Oops.Bah("该版本已用于实际入职任务,不能删除,可改为停用");
await Context.Ado.BeginTranAsync();
try
{
await Context.Deleteable<ObTemplateVariable>()
.Where(it => it.TemplateVersionId == version.Id)
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplateVersion>()
.In(version.Id)
.ExecuteCommandAsync();
await Context.Ado.CommitTranAsync();
}
catch
{
await Context.Ado.RollbackTranAsync();
throw;
}
}
/// <summary>
@@ -79,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>
@@ -91,19 +294,75 @@ 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源文件不存在");
?? throw Oops.Bah("DOCX 源文件不存在");
if (!string.Equals(file.Suffix, "docx", StringComparison.OrdinalIgnoreCase))
throw Oops.Bah("合同模板仅支持DOCX文件");
throw Oops.Bah("合同模板仅支持 DOCX 文件");
var versionExists = await Context.Queryable<ObContractTemplateVersion>()
.AnyAsync(it => it.TemplateId == input.TemplateId && it.VersionNo == input.VersionNo);
.AnyAsync(item => item.TemplateId == input.TemplateId && item.VersionNo == input.VersionNo);
if (versionExists)
throw Oops.Bah("该模板版本号已存在");
await Context.Insertable(input.Adapt<ObContractTemplateVersion>()).ExecuteCommandAsync();
var sourceResult = await _fileService.Download(new BaseIdInput { Id = file.Id })
?? throw Oops.Bah("无法读取 DOCX 源文件");
await using var sourceStream = sourceResult.FileStream;
using var sourceBytes = new MemoryStream();
await sourceStream.CopyToAsync(sourceBytes);
var bytes = sourceBytes.ToArray();
var variableCodes = DocxTemplateProcessor.ExtractVariableCodes(bytes);
// 严格比对变量字典库,防止手误和非法占位符
var dictList = await Context.Queryable<ObVariableDefinition>().ToListAsync();
var duplicateDefinition = dictList
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateDefinition != null)
{
throw Oops.Bah(
$"变量字典编码“{duplicateDefinition.Key}”存在重复数据,请先清理后再导入合同版本");
}
var dictMap = dictList.ToDictionary(it => it.VariableCode, it => it, StringComparer.OrdinalIgnoreCase);
var undefinedCodes = variableCodes.Where(code => !dictMap.ContainsKey(code)).ToList();
if (undefinedCodes.Count > 0)
{
throw Oops.Bah($"DOCX 模板包含未在字典库中注册的占位符:{string.Join(",", undefinedCodes)}。请检查拼写或先在【变量字典】中添加该变量。");
}
var versionId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var version = input.Adapt<ObContractTemplateVersion>();
version.Id = versionId;
version.VersionStatus = ContractTemplateVersionStatus.Draft;
version.FileHash = string.IsNullOrWhiteSpace(input.FileHash)
? Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(bytes))
: input.FileHash;
var variables = variableCodes.Select((code, index) =>
{
var def = dictMap.TryGetValue(code, out var d) ? d : null;
return BuildDetectedVariable(versionId, code, def, index);
}).ToList();
await Context.Ado.BeginTranAsync();
try
{
await Context.Insertable(version).ExecuteCommandAsync();
if (variables.Count > 0)
await Context.Insertable(variables).ExecuteCommandAsync();
await Context.Ado.CommitTranAsync();
}
catch
{
await Context.Ado.RollbackTranAsync();
throw;
}
}
/// <summary>
@@ -119,17 +378,48 @@ 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)
{
var hasVariables = await Context.Queryable<ObTemplateVariable>()
.AnyAsync(it => it.TemplateVersionId == input.Id);
if (!hasVariables)
throw Oops.Bah("请先配置模板变量后再启用");
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);
if (hasUnresolvedVariables)
throw Oops.Bah("仍有未识别来源的合同变量,请逐项确认后再启用");
}
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>
@@ -143,19 +433,8 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
.OrderBy(it => it.VariableCode)
.ToPagedListAsync(input.PageNum, input.PageSize);
}
/// <summary>
/// 为草稿模板版本新增变量。
/// </summary>
public async Task AddVariable(OnboardingTemplateVariableAddInput input)
{
await EnsureVersionDraft(input.TemplateVersionId);
await EnsureVariableUnique(input.TemplateVersionId, input.VariableCode, 0);
await Context.Insertable(input.Adapt<ObTemplateVariable>()).ExecuteCommandAsync();
}
/// <summary>
/// 编辑草稿模板版本中的变量。
/// 编辑模板版本中自动识别出的变量,不允许修改 DOCX 占位符编码。
/// </summary>
public async Task EditVariable(OnboardingTemplateVariableEditInput input)
{
@@ -163,77 +442,85 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
?? throw Oops.Bah("模板变量不存在");
if (variable.TemplateVersionId != input.TemplateVersionId)
throw Oops.Bah("不允许变更变量所属版本");
if (!string.Equals(variable.VariableCode, input.VariableCode, StringComparison.Ordinal))
throw Oops.Bah("DOCX 占位符编码由系统识别,不能修改");
await EnsureVersionDraft(input.TemplateVersionId);
await EnsureVariableUnique(input.TemplateVersionId, input.VariableCode, input.Id);
await Context.Updateable(input.Adapt<ObTemplateVariable>()).ExecuteCommandAsync();
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)
throw Oops.Bah("该版本已生成实际入职任务合同,变量配置已锁定");
var entity = input.Adapt<ObTemplateVariable>();
entity.VariableCode = variable.VariableCode;
await Context.Updateable(entity).ExecuteCommandAsync();
}
/// <summary>
/// 删除草稿模板版本中的变量。
/// </summary>
public async Task DeleteVariable(BaseIdListInput input)
private async Task EnsureTemplateCanManage(long templateId)
{
if (input.Ids.Count == 0)
return;
var versionIds = await Context.Queryable<ObTemplateVariable>()
.Where(it => input.Ids.Contains(it.Id))
.Select(it => it.TemplateVersionId)
.ToListAsync();
foreach (var versionId in versionIds.Distinct())
await EnsureVersionDraft(versionId);
await Context.Deleteable<ObTemplateVariable>().In(input.Ids).ExecuteCommandAsync();
var template = await Context.Queryable<ObContractTemplate>().InSingleAsync(templateId)
?? throw Oops.Bah("合同模板不存在");
await _dataPermission.EnsureCanManage(
template.CreateUserId,
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
}
/// <summary>
/// 分页查询合同适用范围。
/// </summary>
public async Task<SqlSugarPagedList<ObContractScope>> ScopePage(OnboardingContractScopePageInput input)
private static ObTemplateVariable BuildDetectedVariable(long versionId, string code, ObVariableDefinition def, int index)
{
return await Context.Queryable<ObContractScope>()
.WhereIF(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value)
.WhereIF(input.PositionId.HasValue, it => it.PositionId == input.PositionId.Value)
.WhereIF(input.TemplateId.HasValue, it => it.TemplateId == input.TemplateId.Value)
.OrderBy(it => it.SortCode)
.ToPagedListAsync(input.PageNum, input.PageSize);
var separatorIndex = code.IndexOf('.');
var prefix = separatorIndex > 0 ? code[..separatorIndex].ToLowerInvariant() : string.Empty;
var sourceField = separatorIndex > 0 ? code[(separatorIndex + 1)..] : code;
var variableType = prefix switch
{
"employee" => ContractVariableType.Employee,
"company" => ContractVariableType.Company,
"hr" => ContractVariableType.Contract,
"system" => ContractVariableType.System,
_ => ContractVariableType.Unresolved
};
var varName = def != null && !string.IsNullOrWhiteSpace(def.VariableName) ? def.VariableName : sourceField;
var dataType = def != null && !string.IsNullOrWhiteSpace(def.DataType) ? def.DataType : InferDataType(sourceField);
return new ObTemplateVariable
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TemplateVersionId = versionId,
VariableCode = code,
VariableName = varName,
VariableType = variableType,
DataType = dataType,
EmployeeField = variableType is ContractVariableType.Employee or ContractVariableType.Company
? sourceField
: null,
DefaultValue = def?.DefaultValue,
Required = def?.Required ?? true,
HrEditable = variableType == ContractVariableType.Contract,
// 合同版本首次识别时继承全局变量字典排序;未登记排序时才使用识别顺序兜底。
SortCode = def?.SortCode ?? (index + 1) * 10,
Remark = def?.Remark
};
}
/// <summary>
/// 新增公司或岗位的合同适用范围。
/// </summary>
public async Task AddScope(OnboardingContractScopeAddInput input)
private static string InferDataType(string sourceField)
{
await EnsureScopeValid(input.CompanyId, input.PositionId, input.TemplateId, input.TemplateVersionId);
await EnsureScopeUnique(input.CompanyId, input.PositionId, input.TemplateVersionId, 0);
await Context.Insertable(input.Adapt<ObContractScope>()).ExecuteCommandAsync();
}
var normalized = sourceField.ToLowerInvariant();
if (normalized.Contains("date") || normalized.EndsWith("at"))
return "date";
if (normalized.Contains("salary")
|| normalized.Contains("amount")
|| normalized.Contains("wage")
|| normalized.Contains("copies"))
{
return "decimal";
}
/// <summary>
/// 编辑合同适用范围。
/// </summary>
public async Task EditScope(OnboardingContractScopeEditInput input)
{
var scopeExists = await Context.Queryable<ObContractScope>().AnyAsync(it => it.Id == input.Id);
if (!scopeExists)
throw Oops.Bah("合同适用范围不存在");
await EnsureScopeValid(input.CompanyId, input.PositionId, input.TemplateId, input.TemplateVersionId);
await EnsureScopeUnique(input.CompanyId, input.PositionId, input.TemplateVersionId, input.Id);
await Context.Updateable(input.Adapt<ObContractScope>()).ExecuteCommandAsync();
}
/// <summary>
/// 删除合同适用范围。
/// </summary>
public async Task DeleteScope(BaseIdListInput input)
{
if (input.Ids.Count == 0)
return;
await Context.Deleteable<ObContractScope>().In(input.Ids).ExecuteCommandAsync();
return "string";
}
private async Task EnsureCodeUnique(string code, long id)
@@ -244,51 +531,236 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
throw Oops.Bah("模板编码已存在");
}
private async Task EnsureVersionDraft(long versionId)
/// <summary>
/// 标准化合同模板保存参数并执行基础业务校验。
/// </summary>
private static void NormalizeInput(OnboardingContractTemplateAddInput input)
{
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(versionId)
?? throw Oops.Bah("模板版本不存在");
if (version.VersionStatus != ContractTemplateVersionStatus.Draft)
throw Oops.Bah("只有草稿版本可维护变量");
}
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>();
private async Task EnsureVariableUnique(long versionId, string code, long id)
{
var exists = await Context.Queryable<ObTemplateVariable>()
.AnyAsync(it => it.TemplateVersionId == versionId && it.VariableCode == code && it.Id != id);
if (exists)
throw Oops.Bah("该版本变量编码已存在");
}
private async Task EnsureScopeValid(long companyId, long? positionId, long templateId, long versionId)
{
var companyExists = await Context.Queryable<ObCompany>()
.AnyAsync(it => it.Id == companyId && it.Status == "enable");
if (!companyExists)
throw Oops.Bah("所属公司不存在或未启用");
if (positionId.HasValue)
if (string.IsNullOrWhiteSpace(input.TemplateName)
|| string.IsNullOrWhiteSpace(input.ContractType))
{
var positionExists = await Context.Queryable<ObPosition>()
.AnyAsync(it => it.Id == positionId.Value && it.CompanyId == companyId && it.Status == "enable");
if (!positionExists)
throw Oops.Bah("岗位不存在、未启用或不属于该公司");
throw Oops.Bah("请填写模板编码、模板名称和合同类型");
}
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(versionId)
?? throw Oops.Bah("合同版本不存在");
if (version.TemplateId != templateId || version.VersionStatus != ContractTemplateVersionStatus.Enabled)
throw Oops.Bah("只能配置该模板的已启用合同版本");
if (input.PositionIds.Count == 0)
throw Oops.Bah("请至少选择一个适用岗位");
if (input.Status != "enable" && input.Status != "disable")
throw Oops.Bah("合同模板状态不正确");
}
private async Task EnsureScopeUnique(long companyId, long? positionId, long versionId, long id)
/// <summary>
/// 校验公司、部门、岗位属于同一条启用的业务层级。
/// </summary>
private async Task ValidateBusinessScope(
long companyId,
long departmentId,
List<long> positionIds)
{
var exists = await Context.Queryable<ObContractScope>()
.AnyAsync(it => it.CompanyId == companyId
&& it.PositionId == positionId
&& it.TemplateVersionId == versionId
&& it.Id != id);
if (exists)
throw Oops.Bah("该公司、岗位和合同版本的适用范围已存在");
var companyEnabled = await Context.Queryable<ObCompany>()
.AnyAsync(item => item.Id == companyId && item.Status == "enable");
if (!companyEnabled)
throw Oops.Bah("所属公司不存在或未启用");
var departmentEnabled = await Context.Queryable<ObDepartment>()
.AnyAsync(item => item.Id == departmentId
&& item.CompanyId == companyId
&& item.Status == "enable");
if (!departmentEnabled)
throw Oops.Bah("所属部门不存在、未启用或不属于该公司");
var positionCount = await Context.Queryable<ObPosition>()
.CountAsync(item => positionIds.Contains(item.Id)
&& item.CompanyId == companyId
&& item.DepartmentId == departmentId
&& item.Status == "enable");
if (positionCount != positionIds.Count)
throw Oops.Bah("存在未启用或不属于该公司、部门的岗位");
}
/// <summary>
/// 防止修改合同模板归属后破坏已经保存的入职模板配置。
/// </summary>
private async Task ValidateReferencedOnboardingTemplates(
OnboardingContractTemplateEditInput input,
string currentStatus)
{
var relations = await Context.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.ContractTemplateId == input.Id)
.ToListAsync();
if (relations.Count == 0)
return;
var onboardingTemplateIds = relations
.Select(item => item.OnboardingTemplateId)
.Distinct()
.ToList();
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();
foreach (var template in onboardingTemplates)
{
var positionIds = positionRelations
.Where(item => item.OnboardingTemplateId == template.Id)
.Select(item => item.PositionId)
.Distinct()
.ToList();
var departmentMatches = !template.DepartmentId.HasValue
|| template.DepartmentId.Value == input.DepartmentId;
var scopeMatches = template.CompanyId == input.CompanyId
&& departmentMatches
&& positionIds.All(input.PositionIds.Contains);
if (!scopeMatches)
{
throw Oops.Bah(
$"合同模板仍被入职模板“{template.TemplateName}”引用,当前归属调整会使其失效,请先调整入职模板");
}
}
}
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>
private static ObContractTemplate BuildEntity(
long templateId,
OnboardingContractTemplateAddInput input)
{
return new ObContractTemplate
{
Id = templateId,
CompanyId = input.CompanyId,
DepartmentId = input.DepartmentId,
TemplateCode = input.TemplateCode,
TemplateName = input.TemplateName,
ContractType = input.ContractType,
Description = input.Description,
Status = input.Status
};
}
/// <summary>
/// 保存合同模板适用岗位关系。
/// </summary>
private async Task InsertPositionRelations(long templateId, List<long> positionIds)
{
var relations = positionIds.Select(positionId => new ObContractTemplatePosition
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
ContractTemplateId = templateId,
PositionId = positionId
}).ToList();
await Context.Insertable(relations).ExecuteCommandAsync();
}
/// <summary>
/// 回填合同模板的公司、部门和适用岗位展示信息。
/// </summary>
private async Task PopulateScopeDisplay(List<ObContractTemplate> templates)
{
if (templates.Count == 0)
return;
var templateIds = templates.Select(item => item.Id).ToList();
var companyIds = templates
.Where(item => item.CompanyId.HasValue)
.Select(item => item.CompanyId.Value)
.Distinct()
.ToList();
var departmentIds = templates
.Where(item => item.DepartmentId.HasValue)
.Select(item => item.DepartmentId.Value)
.Distinct()
.ToList();
var companies = companyIds.Count == 0
? new List<ObCompany>()
: await Context.Queryable<ObCompany>()
.Where(item => companyIds.Contains(item.Id))
.ToListAsync();
var departments = departmentIds.Count == 0
? new List<ObDepartment>()
: await Context.Queryable<ObDepartment>()
.Where(item => departmentIds.Contains(item.Id))
.ToListAsync();
var relations = await Context.Queryable<ObContractTemplatePosition>()
.Where(item => templateIds.Contains(item.ContractTemplateId))
.ToListAsync();
var positionIds = relations.Select(item => item.PositionId).Distinct().ToList();
var positions = positionIds.Count == 0
? new List<ObPosition>()
: await Context.Queryable<ObPosition>()
.Where(item => positionIds.Contains(item.Id))
.ToListAsync();
foreach (var template in templates)
{
template.CompanyName = companies
.FirstOrDefault(item => item.Id == template.CompanyId)?.CompanyName;
template.DepartmentName = departments
.FirstOrDefault(item => item.Id == template.DepartmentId)?.DepartmentName;
template.PositionIds = relations
.Where(item => item.ContractTemplateId == template.Id)
.Select(item => item.PositionId)
.ToList();
template.PositionNames = template.PositionIds
.Select(positionId => positions.FirstOrDefault(item => item.Id == positionId)?.PositionName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToList();
}
}
}
@@ -0,0 +1,12 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 校验任务合同变量的必填状态和员工动态变量数据类型。
/// </summary>
public interface IOnboardingContractVariableValidationService : ITransient
{
/// <summary>
/// 校验指定任务的全部合同变量快照。
/// </summary>
Task Validate(long taskId);
}
@@ -0,0 +1,187 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 统一校验任务合同变量,确保最终提交的数据可以安全用于合同生成。
/// </summary>
public class OnboardingContractVariableValidationService
: IOnboardingContractVariableValidationService
{
private readonly SqlSugarScope _db;
public OnboardingContractVariableValidationService()
{
_db = DbContext.DB;
}
/// <inheritdoc />
public async Task Validate(long taskId)
{
var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == taskId)
.ToListAsync();
if (contracts.Count == 0)
return;
var contractIds = contracts
.Select(item => item.Id)
.ToList();
var templateVersionIds = contracts
.Select(item => item.TemplateVersionId)
.Distinct()
.ToList();
var variables = await _db.Queryable<ObTaskContractVariable>()
.Where(item => contractIds.Contains(item.TaskContractId))
.ToListAsync();
var definitions = await _db.Queryable<ObTemplateVariable>()
.Where(item => templateVersionIds.Contains(item.TemplateVersionId))
.ToListAsync();
foreach (var contract in contracts)
{
var contractDefinitions = definitions
.Where(item => item.TemplateVersionId == contract.TemplateVersionId)
.OrderBy(item => item.SortCode);
foreach (var definition in contractDefinitions)
{
var variable = variables.FirstOrDefault(item =>
item.TaskContractId == contract.Id
&& item.VariableCode == definition.VariableCode);
var value = variable?.VariableValue?.Trim();
if (definition.Required && string.IsNullOrWhiteSpace(value))
{
throw Oops.Bah(
$"合同“{contract.ContractName}”的必填变量“{definition.VariableName}”尚未填写");
}
if (string.IsNullOrWhiteSpace(value)
|| definition.VariableType != ContractVariableType.Employee)
{
continue;
}
ValidateDataType(contract.ContractName, definition, value);
ValidateOptions(contract.ContractName, definition, value);
}
}
}
private static void ValidateDataType(
string contractName,
ObTemplateVariable definition,
string value)
{
var dataType = definition.DataType?.Trim().ToLowerInvariant() ?? "string";
switch (dataType)
{
case "string":
return;
case "number":
var numberIsValid = decimal.TryParse(
value,
global::System.Globalization.NumberStyles.Number,
global::System.Globalization.CultureInfo.InvariantCulture,
out _);
if (!numberIsValid)
{
throw Oops.Bah(
$"合同“{contractName}”的变量“{definition.VariableName}”必须填写数字");
}
return;
case "date":
var dateIsValid = DateTime.TryParseExact(
value,
"yyyy-MM-dd",
global::System.Globalization.CultureInfo.InvariantCulture,
global::System.Globalization.DateTimeStyles.None,
out _);
if (!dateIsValid)
{
throw Oops.Bah(
$"合同“{contractName}”的变量“{definition.VariableName}”必须填写正确日期");
}
return;
default:
throw Oops.Bah(
$"合同“{contractName}”的变量“{definition.VariableName}”数据类型配置不正确");
}
}
private static void ValidateOptions(
string contractName,
ObTemplateVariable definition,
string value)
{
if (string.IsNullOrWhiteSpace(definition.OptionsJson))
return;
var options = ParseOptions(contractName, definition);
if (options.Contains(value))
return;
throw Oops.Bah(
$"合同“{contractName}”的变量“{definition.VariableName}”必须从配置选项中选择");
}
private static HashSet<string> ParseOptions(
string contractName,
ObTemplateVariable definition)
{
try
{
using var document = global::System.Text.Json.JsonDocument.Parse(
definition.OptionsJson);
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Array)
throw new global::System.Text.Json.JsonException();
var options = document.RootElement
.EnumerateArray()
.Select(GetOptionValue)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToHashSet(StringComparer.Ordinal);
if (options.Count == 0)
throw new global::System.Text.Json.JsonException();
return options;
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah(
$"合同“{contractName}”的变量“{definition.VariableName}”可选项配置不正确");
}
}
private static string GetOptionValue(
global::System.Text.Json.JsonElement option)
{
if (option.ValueKind == global::System.Text.Json.JsonValueKind.String)
return option.GetString()?.Trim();
if (option.ValueKind == global::System.Text.Json.JsonValueKind.Object)
{
if (option.TryGetProperty("value", out var value))
return GetJsonText(value);
if (option.TryGetProperty("label", out var label))
return GetJsonText(label);
}
return GetJsonText(option);
}
private static string GetJsonText(
global::System.Text.Json.JsonElement value)
{
return value.ValueKind switch
{
global::System.Text.Json.JsonValueKind.String => value.GetString()?.Trim(),
global::System.Text.Json.JsonValueKind.Number => value.ToString(),
global::System.Text.Json.JsonValueKind.True => bool.TrueString,
global::System.Text.Json.JsonValueKind.False => bool.FalseString,
_ => null
};
}
}
@@ -0,0 +1,71 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职业务部门分页查询参数。
/// </summary>
public class OnboardingDepartmentPageInput : BasePageInput
{
/// <summary>
/// 入职业务公司 Id。
/// </summary>
public long? CompanyId { get; set; }
/// <summary>
/// 部门编码。
/// </summary>
public virtual string DepartmentCode { get; set; }
/// <summary>
/// 部门名称。
/// </summary>
public virtual string DepartmentName { get; set; }
/// <summary>
/// 启用状态。
/// </summary>
public string Status { get; set; }
}
/// <summary>
/// 新增入职业务部门参数。
/// </summary>
public class OnboardingDepartmentAddInput : ObDepartment
{
/// <summary>
/// 所属公司 Id。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "所属公司不能为空")]
public new long CompanyId { get; set; }
/// <summary>
/// 部门业务编码。
/// </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>
/// 编辑入职业务部门参数。
/// </summary>
public class OnboardingDepartmentEditInput : OnboardingDepartmentAddInput
{
/// <summary>
/// 业务数据 Id。
/// </summary>
[IdNotNull(ErrorMessage = "部门Id不能为空")]
public override long Id { get; set; }
}
@@ -0,0 +1,37 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义入职业务部门业务能力。
/// </summary>
public interface IOnboardingDepartmentService : ITransient
{
/// <summary>
/// 分页查询入职业务部门。
/// </summary>
Task<SqlSugarPagedList<ObDepartment>> Page(OnboardingDepartmentPageInput input);
/// <summary>
/// 查询指定公司下全部已启用部门列表。
/// </summary>
Task<List<ObDepartment>> ListEnabled(long companyId);
/// <summary>
/// 查询部门详情。
/// </summary>
Task<ObDepartment> Detail(BaseIdInput input);
/// <summary>
/// 新增入职业务部门。
/// </summary>
Task Add(OnboardingDepartmentAddInput input);
/// <summary>
/// 编辑入职业务部门。
/// </summary>
Task Edit(OnboardingDepartmentEditInput input);
/// <summary>
/// 批量删除部门。
/// </summary>
Task Delete(BaseIdListInput input);
}
@@ -0,0 +1,197 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职业务部门业务能力。
/// </summary>
public class OnboardingDepartmentService : DbRepository<ObDepartment>, IOnboardingDepartmentService
{
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingDepartmentService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询入职业务部门。
/// </summary>
public async Task<SqlSugarPagedList<ObDepartment>> Page(OnboardingDepartmentPageInput input)
{
var list = await Context.Queryable<ObDepartment>()
.WhereIF(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value)
.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.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentCode", "DepartmentName", "SortCode"))
.OrderBy(it => it.SortCode)
.OrderBy(it => it.DepartmentName)
.ToPagedListAsync(input.PageNum, input.PageSize);
if (list.List != null && list.List.Any())
{
var companyIds = list.List.Select(d => d.CompanyId).Distinct().ToList();
var companies = await Context.Queryable<ObCompany>()
.Where(c => companyIds.Contains(c.Id))
.ToListAsync();
var companyMap = companies.ToDictionary(c => c.Id, c => c.CompanyName);
foreach (var item in list.List)
{
if (companyMap.TryGetValue(item.CompanyId, out var cName))
{
item.CompanyName = cName;
}
}
}
return list;
}
/// <summary>
/// 查询指定公司下全部已启用部门列表。
/// </summary>
public Task<List<ObDepartment>> ListEnabled(long companyId)
{
return Context.Queryable<ObDepartment>()
.Where(it => it.CompanyId == companyId && it.Status == "enable")
.OrderBy(it => it.SortCode)
.OrderBy(it => it.DepartmentName)
.ToListAsync();
}
/// <summary>
/// 查询部门详情。
/// </summary>
public async Task<ObDepartment> Detail(BaseIdInput input)
{
return await GetByIdAsync(input.Id) ?? throw Oops.Bah("部门不存在");
}
/// <summary>
/// 新增入职业务部门。
/// </summary>
public async Task Add(OnboardingDepartmentAddInput input)
{
NormalizeInput(input);
await EnsureCompanyExists(input.CompanyId);
await EnsureCodeUnique(input.CompanyId, input.DepartmentCode, 0);
await InsertAsync(input.Adapt<ObDepartment>());
}
/// <summary>
/// 编辑入职业务部门。
/// </summary>
public async Task Edit(OnboardingDepartmentEditInput input)
{
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);
await UpdateAsync(input.Adapt<ObDepartment>());
}
/// <summary>
/// 删除未被岗位或任务引用的部门。
/// </summary>
public async Task Delete(BaseIdListInput input)
{
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)
throw Oops.Bah("部门下已有岗位,不能删除");
var hasTasks = await Context.Queryable<ObOnboardingTask>()
.AnyAsync(it => it.DepartmentId.HasValue && input.Ids.Contains(it.DepartmentId.Value));
if (hasTasks)
throw Oops.Bah("部门已有入职任务,不能删除");
var hasEmployees = await Context.Queryable<ObEmployee>()
.AnyAsync(it => it.DepartmentId.HasValue && input.Ids.Contains(it.DepartmentId.Value));
if (hasEmployees)
throw Oops.Bah("部门已有正式员工,不能删除");
var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplate>()
.AnyAsync(item => item.DepartmentId.HasValue && input.Ids.Contains(item.DepartmentId.Value));
if (hasOnboardingTemplates)
throw Oops.Bah("部门已被入职模板引用,不能删除");
var hasContractTemplates = await Context.Queryable<ObContractTemplate>()
.AnyAsync(item => item.DepartmentId.HasValue && input.Ids.Contains(item.DepartmentId.Value));
if (hasContractTemplates)
throw Oops.Bah("部门已被合同模板引用,不能删除");
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
}
private async Task EnsureCompanyExists(long companyId)
{
var exists = await Context.Queryable<ObCompany>()
.AnyAsync(it => it.Id == companyId && it.Status == "enable");
if (!exists)
throw Oops.Bah("所属公司不存在、已删除或未启用");
}
/// <summary>
/// 标准化部门输入并拒绝非法状态。
/// </summary>
private static void NormalizeInput(OnboardingDepartmentAddInput input)
{
input.DepartmentCode = OnboardingCodeValidator.Normalize(input.DepartmentCode, "部门编码", 50);
input.DepartmentName = input.DepartmentName?.Trim();
if (string.IsNullOrWhiteSpace(input.DepartmentName))
{
throw Oops.Bah("请填写部门名称");
}
if (input.Status != "enable" && input.Status != "disable")
throw Oops.Bah("部门状态不正确");
}
/// <summary>
/// 已产生下级或业务引用的部门不允许直接变更所属公司。
/// </summary>
private async Task EnsureCompanyCanChange(ObDepartment department, long companyId)
{
if (department.CompanyId == companyId)
return;
var hasPosition = await Context.Queryable<ObPosition>()
.AnyAsync(item => item.DepartmentId == department.Id);
var hasTask = await Context.Queryable<ObOnboardingTask>()
.AnyAsync(item => item.DepartmentId == department.Id);
var hasEmployee = await Context.Queryable<ObEmployee>()
.AnyAsync(item => item.DepartmentId == department.Id);
var hasOnboardingTemplate = await Context.Queryable<ObOnboardingTemplate>()
.AnyAsync(item => item.DepartmentId == department.Id);
var hasContractTemplate = await Context.Queryable<ObContractTemplate>()
.AnyAsync(item => item.DepartmentId == department.Id);
if (hasPosition || hasTask || hasEmployee || hasOnboardingTemplate || hasContractTemplate)
throw Oops.Bah("部门已有岗位、模板、任务或员工引用,不能直接变更所属公司");
}
private async Task EnsureCodeUnique(long companyId, string code, long id)
{
var exists = await Context.Queryable<ObDepartment>()
.AnyAsync(it => it.CompanyId == companyId && it.DepartmentCode == code && it.Id != id);
if (exists)
throw Oops.Bah("该公司下部门编码已存在");
}
}
@@ -0,0 +1,51 @@
using System.ComponentModel.DataAnnotations;
namespace SimpleAdmin.Application;
/// <summary>
/// 对指定入职任务执行电子签操作的参数。
/// </summary>
public class OnboardingElectronicSignTaskInput
{
/// <summary>
/// 入职任务 Id。
/// </summary>
[IdNotNull(ErrorMessage = "入职任务Id不能为空")]
public long TaskId { get; set; }
}
/// <summary>
/// 管理员查看一份员工已签合同的参数。
/// </summary>
public class OnboardingElectronicSignPreviewInput : OnboardingElectronicSignTaskInput
{
/// <summary>
/// 本地电子签文档 Id。
/// </summary>
[IdNotNull(ErrorMessage = "电子签文档Id不能为空")]
public long SignDocumentId { get; set; }
}
/// <summary>
/// 作废入职电子签流程的参数。
/// </summary>
public class OnboardingElectronicSignInvalidInput : OnboardingElectronicSignTaskInput
{
/// <summary>
/// 作废原因。
/// </summary>
[Required(ErrorMessage = "请填写作废原因")]
public string Reason { get; set; }
}
/// <summary>
/// 重新发起入职电子签流程的参数。
/// </summary>
public class OnboardingElectronicSignReinitiateInput : OnboardingElectronicSignTaskInput
{
/// <summary>
/// 管理员确认重新发起的原因,随旧流程保留用于审计。
/// </summary>
[Required(ErrorMessage = "请填写重新发起原因")]
public string Reason { get; set; }
}
@@ -0,0 +1,173 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 管理端查看的入职电子签流程详情。
/// </summary>
public class OnboardingElectronicSignDetailOutput
{
/// <summary>入职任务 Id。</summary>
public long TaskId { get; set; }
/// <summary>入职任务编号。</summary>
public string TaskNo { get; set; }
/// <summary>当前是否允许发起或重试电子签。</summary>
public bool CanInitiate { get; set; }
/// <summary>不允许发起时的原因。</summary>
public string CannotInitiateReason { get; set; }
/// <summary>当前流程是否允许作为一次新签约重新发起。</summary>
public bool CanReinitiate { get; set; }
/// <summary>不允许重新发起时的原因。</summary>
public string CannotReinitiateReason { get; set; }
/// <summary>是否已经存在电子签流程。</summary>
public bool HasProcess { get; set; }
/// <summary>本地电子签流程 Id。</summary>
public long? ProcessId { get; set; }
/// <summary>契约锁合同流程 Id。</summary>
public string ProviderContractId { get; set; }
/// <summary>本系统幂等业务编号。</summary>
public string BizId { get; set; }
/// <summary>电子签流程标题。</summary>
public string Subject { get; set; }
/// <summary>本系统电子签状态。</summary>
public string SignStatus { get; set; }
/// <summary>契约锁原始状态。</summary>
public string ProviderStatus { get; set; }
/// <summary>员工姓名。</summary>
public string EmployeeName { get; set; }
/// <summary>员工手机号。</summary>
public string EmployeeMobile { get; set; }
/// <summary>员工签署页面地址。</summary>
public string SignUrl { get; set; }
/// <summary>员工签署页面到期时间。</summary>
public DateTime? SignUrlExpireAt { get; set; }
/// <summary>员工签署页面当前是否有效。</summary>
public bool SignUrlValid { get; set; }
/// <summary>发起时间。</summary>
public DateTime? StartedAt { get; set; }
/// <summary>发送时间。</summary>
public DateTime? SentAt { get; set; }
/// <summary>签署完成时间。</summary>
public DateTime? CompletedAt { get; set; }
/// <summary>归档完成时间。</summary>
public DateTime? ArchivedAt { get; set; }
/// <summary>完整归档 ZIP 的系统文件 Id。</summary>
public long? ArchiveFileId { get; set; }
/// <summary>最近一次失败原因。</summary>
public string LastError { get; set; }
/// <summary>签约流程包含的文档。</summary>
public List<OnboardingElectronicSignDocumentOutput> Documents { get; set; } = new();
/// <summary>该入职任务已经结束的历史电子签流程。</summary>
public List<OnboardingElectronicSignHistoryOutput> HistoryProcesses { get; set; } = new();
}
/// <summary>
/// 契约锁员工已签文件只读预览结果。
/// </summary>
public class OnboardingElectronicSignPreviewOutput
{
/// <summary>本地电子签文档 Id。</summary>
public long SignDocumentId { get; set; }
/// <summary>契约锁生成的只读浏览页面地址。</summary>
public string PreviewUrl { get; set; }
}
/// <summary>
/// 已被新流程替代的历史电子签流程摘要。
/// </summary>
public class OnboardingElectronicSignHistoryOutput
{
/// <summary>本地电子签流程 Id。</summary>
public long ProcessId { get; set; }
/// <summary>本系统幂等业务编号。</summary>
public string BizId { get; set; }
/// <summary>契约锁合同流程 Id。</summary>
public string ProviderContractId { get; set; }
/// <summary>本系统电子签状态。</summary>
public string SignStatus { get; set; }
/// <summary>契约锁原始状态。</summary>
public string ProviderStatus { get; set; }
/// <summary>员工姓名快照。</summary>
public string EmployeeName { get; set; }
/// <summary>员工手机号快照。</summary>
public string EmployeeMobile { get; set; }
/// <summary>被新流程替代的时间。</summary>
public DateTime? ReissuedAt { get; set; }
/// <summary>管理员填写的重新发起原因。</summary>
public string ReissueReason { get; set; }
/// <summary>流程发起时间。</summary>
public DateTime? StartedAt { get; set; }
/// <summary>流程结束或失败原因。</summary>
public string LastError { get; set; }
}
/// <summary>
/// 电子签流程中的单份合同文档。
/// </summary>
public class OnboardingElectronicSignDocumentOutput
{
/// <summary>本地电子签文档 Id。</summary>
public long Id { get; set; }
/// <summary>任务合同 Id。</summary>
public long TaskContractId { get; set; }
/// <summary>契约锁文档 Id。</summary>
public string ProviderDocumentId { get; set; }
/// <summary>文档名称。</summary>
public string DocumentName { get; set; }
/// <summary>文档顺序。</summary>
public int DocumentSort { get; set; }
/// <summary>发起签约的原始 DOCX 文件 Id。</summary>
public long OriginalDocxFileId { get; set; }
/// <summary>签署完成后的 PDF 文件 Id。</summary>
public long? SignedPdfFileId { get; set; }
/// <summary>当前管理员流程中是否已生成过员工签后预览。</summary>
public bool EmployeeSignedPreviewed { get; set; }
/// <summary>本份文件是否需要公司盖章。</summary>
public bool CompanySealEnabled { get; set; }
/// <summary>本份文件是否需要骑缝章。</summary>
public bool AcrossPageSealEnabled { get; set; }
}
@@ -0,0 +1,43 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义入职电子签流程管理能力。
/// </summary>
public interface IOnboardingElectronicSignService : ITransient
{
/// <summary>查询指定入职任务的电子签详情。</summary>
Task<OnboardingElectronicSignDetailOutput> Detail(BaseIdInput input);
/// <summary>创建或重试电子签流程。</summary>
Task<OnboardingElectronicSignDetailOutput> Initiate(
OnboardingElectronicSignTaskInput input);
/// <summary>保留旧流程并创建一次全新的电子签流程。</summary>
Task<OnboardingElectronicSignDetailOutput> Reinitiate(
OnboardingElectronicSignReinitiateInput input);
/// <summary>主动同步契约锁状态并在完成后归档。</summary>
Task<OnboardingElectronicSignDetailOutput> Sync(
OnboardingElectronicSignTaskInput input);
/// <summary>获取或刷新员工签署地址。</summary>
Task<OnboardingElectronicSignDetailOutput> GetSignLink(
OnboardingElectronicSignTaskInput input);
/// <summary>获取一份员工已签合同的只读浏览页面。</summary>
Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
OnboardingElectronicSignPreviewInput input);
/// <summary>确认全部文件已核对后,由后台执行企业盖章。</summary>
Task<OnboardingElectronicSignDetailOutput> CompanySeal(
OnboardingElectronicSignTaskInput input);
/// <summary>催办当前待签员工。</summary>
Task Notice(OnboardingElectronicSignTaskInput input);
/// <summary>作废尚未结束的签约流程。</summary>
Task Invalid(OnboardingElectronicSignInvalidInput input);
/// <summary>处理契约锁服务器明文回调。</summary>
Task HandleCallback(string payloadJson);
}
@@ -0,0 +1,23 @@
namespace SimpleAdmin.Application;
/// <summary>同步第三方状态时的单向迁移保护;未知/陈旧结果不能覆盖已确认的签署进度。</summary>
internal static class OnboardingSignStateGuard
{
internal static string Resolve(string current, string incoming)
{
if (current == ElectronicSignStatus.Archived && incoming == ElectronicSignStatus.Completed)
return ElectronicSignStatus.Archived;
if (IsTerminal(current) && current != incoming)
throw Oops.Bah("契约锁返回状态与本地终态不一致,已保留本地状态,请核实后重试");
if (current == ElectronicSignStatus.PendingCompanySeal
&& incoming is not (ElectronicSignStatus.PendingCompanySeal
or ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired))
throw Oops.Bah("契约锁状态暂未同步,已保留员工签署结果,请稍后重试");
return incoming;
}
private static bool IsTerminal(string status)
=> status is ElectronicSignStatus.Completed or ElectronicSignStatus.Archived
or ElectronicSignStatus.Rejected or ElectronicSignStatus.Invalid or ElectronicSignStatus.Expired;
}
@@ -0,0 +1,67 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义契约锁开放平台所需的最小调用能力。
/// </summary>
public interface IQiyuesuoClient : ITransient
{
/// <summary>
/// 创建包含个人签署方和企业人工盖章动作的合同草稿。
/// </summary>
Task<QiyuesuoContractResult> CreateDraft(
QiyuesuoCreateDraftInput input);
/// <summary>
/// 向合同草稿中上传一份 DOCX 文件。
/// </summary>
Task<QiyuesuoDocumentResult> AddDocument(
QiyuesuoAddDocumentInput input);
/// <summary>
/// 发起合同并为每份文档指定员工签名与企业盖章位置。
/// </summary>
Task<QiyuesuoContractResult> Send(
QiyuesuoSendContractInput input);
/// <summary>
/// 获取员工签署页面地址。
/// </summary>
Task<string> GetSignPageUrl(
QiyuesuoSignPageInput input);
/// <summary>
/// 获取当前合同中指定文档的只读浏览页面地址。
/// </summary>
Task<string> GetViewPageUrl(
QiyuesuoViewPageInput input);
/// <summary>
/// 查询契约锁合同详情。
/// </summary>
Task<QiyuesuoContractResult> GetContractDetail(string contractId);
/// <summary>
/// 催办当前待签署人。
/// </summary>
Task Notice(string contractId);
/// <summary>
/// 由后台管理员确认后执行企业盖章节点。
/// </summary>
Task CompanySign(QiyuesuoCompanySignInput input);
/// <summary>
/// 作废尚未完成的合同流程。
/// </summary>
Task Invalid(string contractId, string sealId, string reason);
/// <summary>
/// 下载单份签署完成的 PDF 文档。
/// </summary>
Task<byte[]> DownloadDocument(string documentId);
/// <summary>
/// 下载合同原文、签署文件与签署日志组成的完整归档 ZIP。
/// </summary>
Task<byte[]> DownloadArchive(string contractId);
}
@@ -0,0 +1,667 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SimpleAdmin.Application;
/// <summary>
/// 直接调用契约锁开放平台,不依赖旧版 .NET Framework SDK。
/// </summary>
public class QiyuesuoClient : IQiyuesuoClient
{
private const string SignatureHmacSha256 = "HMACSHA256";
private const string AccessTokenHeader = "x-qys-open-accesstoken";
private const string TimestampHeader = "x-qys-open-timestamp";
private const string NonceHeader = "x-qys-open-nonce";
private const string SignatureHeader = "x-qys-open-signature";
private const string SignatureVersionHeader = "x-qys-open-signature-version";
private const string RequestBodySignatureKey = "x-qys-open-body";
private static readonly HttpClient HttpClient = new()
{
Timeout = Timeout.InfiniteTimeSpan
};
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
private readonly QiyuesuoSettingOptions _setting;
public QiyuesuoClient()
{
_setting = App.GetConfig<QiyuesuoSettingOptions>(
"QiyuesuoSetting",
true)
?? new QiyuesuoSettingOptions();
}
/// <inheritdoc />
public async Task<QiyuesuoContractResult> CreateDraft(
QiyuesuoCreateDraftInput input)
{
return await PostJson<QiyuesuoContractResult>(
"/v2/contract/draft",
QiyuesuoContractRequestBuilder.BuildDraft(input));
}
/// <inheritdoc />
public async Task<QiyuesuoDocumentResult> AddDocument(
QiyuesuoAddDocumentInput input)
{
var fields = new Dictionary<string, string>
{
["contractId"] = input.ContractId,
["bizId"] = input.BizId,
["title"] = input.Title,
["fileSuffix"] = "docx",
["documentSort"] = input.DocumentSort.ToString(
CultureInfo.InvariantCulture)
};
return await PostFile<QiyuesuoDocumentResult>(
"/v2/document/addbyfile",
fields,
input.FileBytes,
input.FileName);
}
/// <inheritdoc />
public Task<QiyuesuoContractResult> Send(
QiyuesuoSendContractInput input)
{
return PostJson<QiyuesuoContractResult>(
"/v2/contract/send",
new
{
contractId = input.ContractId,
bizId = input.BizId,
tenantName = input.CompanyName,
// 入职合同的签署位置由系统统一预设,禁止签署人自行增加位置,
// 避免同一份材料出现“预设签名 + 手动新增签名”的重复签名。
stamperRule = "MUST_FORBID",
locateAllStamperKeywords = false,
stampers = input.Stampers
});
}
/// <inheritdoc />
public async Task<string> GetSignPageUrl(
QiyuesuoSignPageInput input)
{
var result = await PostJsonElement(
"/v2/contract/pageurl",
new
{
contractId = input.ContractId,
user = new
{
contact = input.EmployeeMobile,
contactType = "MOBILE"
},
callbackPage = NullIfBlank(input.CallbackPage)
});
if (result.ValueKind == JsonValueKind.String)
return result.GetString();
if (TryGetString(result, "url", out var url)
|| TryGetString(result, "pageUrl", out url)
|| TryGetString(result, "signUrl", out url))
{
return url;
}
throw Oops.Bah("契约锁未返回员工签署页面地址");
}
/// <inheritdoc />
public async Task<string> GetViewPageUrl(
QiyuesuoViewPageInput input)
{
var result = await GetJsonElement(
"/v2/contract/viewurl",
new Dictionary<string, string>
{
["contractId"] = input.ContractId,
["documentId"] = input.DocumentId,
["tenantName"] = input.CompanyName,
["downloadButton"] = "false",
["printButton"] = "false"
});
if (result.ValueKind == JsonValueKind.String)
return result.GetString();
if (TryGetString(result, "url", out var url)
|| TryGetString(result, "viewUrl", out url)
|| TryGetString(result, "pageUrl", out url))
{
return url;
}
throw Oops.Bah("契约锁未返回合同浏览页面地址");
}
/// <inheritdoc />
public Task<QiyuesuoContractResult> GetContractDetail(string contractId)
{
return GetJson<QiyuesuoContractResult>(
"/v2/contract/detail",
new Dictionary<string, string>
{
["contractId"] = contractId,
["queryActionOperate"] = "true",
["queryActionOperator"] = "true",
["queryLocation"] = "true"
});
}
/// <inheritdoc />
public async Task Notice(string contractId)
{
await PostJsonElement(
"/v2/contract/notice",
new
{
contractId,
onlySigning = true
});
}
/// <inheritdoc />
public async Task CompanySign(QiyuesuoCompanySignInput input)
{
await PostJsonElement(
"/v2/contract/companysign",
new
{
contractId = input.ContractId,
tenantName = input.CompanyName,
sealId = input.SealId
});
}
/// <inheritdoc />
public async Task Invalid(string contractId, string sealId, string reason)
{
await PostJsonElement(
"/v2/contract/invalid",
new
{
contractId,
sealId,
reason,
deleteDoc = false,
autoSign = true
});
}
/// <inheritdoc />
public Task<byte[]> DownloadDocument(string documentId)
{
return Download(
"/v2/document/download",
new Dictionary<string, string>
{
["documentId"] = documentId
});
}
/// <inheritdoc />
public Task<byte[]> DownloadArchive(string contractId)
{
return Download(
"/v2/contract/download",
new Dictionary<string, string>
{
["contractId"] = contractId,
["downloadItems"] = "CONTRACT,SIGNLOG",
["needCompressForOneFile"] = "true"
});
}
private async Task<T> PostJson<T>(string path, object body)
{
var result = await PostJsonElement(path, body);
return result.Deserialize<T>(JsonOptions)
?? throw Oops.Bah("契约锁返回数据为空");
}
private async Task<JsonElement> PostJsonElement(
string path,
object body)
{
ValidateSettings();
var json = JsonSerializer.Serialize(body, JsonOptions);
using var request = new HttpRequestMessage(
HttpMethod.Post,
BuildUrl(path));
request.Content = new StringContent(
json,
Encoding.UTF8,
"application/json");
AddAuthorizationHeaders(
request,
path,
HttpMethod.Post,
jsonBody: json);
return await SendForJsonResult(request);
}
private async Task<T> PostFile<T>(
string path,
Dictionary<string, string> fields,
byte[] fileBytes,
string fileName)
{
ValidateSettings();
using var content = new MultipartFormDataContent();
foreach (var field in fields)
{
content.Add(
new StringContent(field.Value ?? string.Empty, Encoding.UTF8),
field.Key);
}
var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
content.Add(fileContent, "file", fileName);
using var request = new HttpRequestMessage(
HttpMethod.Post,
BuildUrl(path))
{
Content = content
};
AddAuthorizationHeaders(
request,
path,
HttpMethod.Post,
fields,
fileBytes);
var result = await SendForJsonResult(request);
return result.Deserialize<T>(JsonOptions)
?? throw Oops.Bah("契约锁返回数据为空");
}
private async Task<T> GetJson<T>(
string path,
Dictionary<string, string> query)
{
var result = await GetJsonElement(path, query);
return result.Deserialize<T>(JsonOptions)
?? throw Oops.Bah("契约锁返回数据为空");
}
private async Task<JsonElement> GetJsonElement(
string path,
Dictionary<string, string> query)
{
ValidateSettings();
using var request = new HttpRequestMessage(
HttpMethod.Get,
BuildUrl(path, query));
AddAuthorizationHeaders(
request,
path,
HttpMethod.Get,
query);
return await SendForJsonResult(request);
}
private async Task<byte[]> Download(
string path,
Dictionary<string, string> query)
{
ValidateSettings();
using var request = new HttpRequestMessage(
HttpMethod.Get,
BuildUrl(path, query));
AddAuthorizationHeaders(
request,
path,
HttpMethod.Get,
query);
using var response = await Send(request);
var bytes = await response.Content.ReadAsByteArrayAsync();
if (!response.IsSuccessStatusCode || LooksLikeJson(response, bytes))
{
ThrowProviderError(bytes, response.StatusCode);
}
if (bytes.Length == 0)
throw Oops.Bah("契约锁下载文件内容为空");
return bytes;
}
private async Task<JsonElement> SendForJsonResult(
HttpRequestMessage request)
{
using var response = await Send(request);
var responseText = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(responseText))
throw Oops.Bah("契约锁返回内容为空");
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var hasCode = root.TryGetProperty("code", out var codeElement);
var code = hasCode ? ReadCode(codeElement) : 0;
var hasResponseCode = TryGetString(
root,
"responseCode",
out var responseCode);
var responseCodeSucceeded = !hasResponseCode
|| responseCode == "0"
|| responseCode == "00000000";
if (!response.IsSuccessStatusCode
|| (hasCode && code != 0)
|| !responseCodeSucceeded)
{
var hasMessage = TryGetString(
root,
"message",
out var providerMessage)
|| TryGetString(
root,
"responseMessage",
out providerMessage);
var message = hasMessage
? providerMessage
: $"HTTP {(int)response.StatusCode},响应码 {responseCode ?? code.ToString()}";
var providerCode = hasResponseCode
? responseCode
: hasCode
? code.ToString(CultureInfo.InvariantCulture)
: ((int)response.StatusCode).ToString(CultureInfo.InvariantCulture);
throw Oops.Bah($"契约锁请求失败:{message}(响应码:{providerCode})");
}
if (!root.TryGetProperty("result", out var result))
return root.Clone();
return result.Clone();
}
private async Task<HttpResponseMessage> Send(
HttpRequestMessage request)
{
using var timeout = new CancellationTokenSource(
TimeSpan.FromSeconds(Math.Max(10, _setting.TimeoutSeconds)));
try
{
return await HttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
timeout.Token);
}
catch (OperationCanceledException)
{
throw Oops.Bah("契约锁请求超时,请稍后重试");
}
catch (HttpRequestException exception)
{
throw Oops.Bah($"无法连接契约锁:{exception.Message}");
}
}
private void AddAuthorizationHeaders(
HttpRequestMessage request,
string path,
HttpMethod method,
Dictionary<string, string> parameters = null,
byte[] fileBytes = null,
string jsonBody = null)
{
var timestamp = DateTimeOffset.UtcNow
.ToUnixTimeMilliseconds()
.ToString(CultureInfo.InvariantCulture);
var nonce = Guid.NewGuid().ToString();
var signature = string.Equals(
_setting.SignatureVersion,
SignatureHmacSha256,
StringComparison.OrdinalIgnoreCase)
? BuildHmacSha256Signature(
path,
method,
timestamp,
nonce,
parameters,
fileBytes,
jsonBody)
: BuildMd5Signature(timestamp, nonce);
request.Headers.TryAddWithoutValidation(
AccessTokenHeader,
_setting.AppToken.Trim());
request.Headers.TryAddWithoutValidation(TimestampHeader, timestamp);
request.Headers.TryAddWithoutValidation(NonceHeader, nonce);
request.Headers.TryAddWithoutValidation(SignatureHeader, signature);
request.Headers.TryAddWithoutValidation("version", "OnboardX-1.0");
if (!string.IsNullOrWhiteSpace(_setting.SignatureVersion))
{
request.Headers.TryAddWithoutValidation(
SignatureVersionHeader,
_setting.SignatureVersion.Trim());
}
}
private string BuildMd5Signature(string timestamp, string nonce)
{
var source = _setting.AppToken.Trim()
+ _setting.AppSecret.Trim()
+ timestamp
+ nonce;
return Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(source)))
.ToLowerInvariant();
}
private string BuildHmacSha256Signature(
string path,
HttpMethod method,
string timestamp,
string nonce,
Dictionary<string, string> parameters,
byte[] fileBytes,
string jsonBody)
{
var values = new List<KeyValuePair<string, string>>
{
new(AccessTokenHeader, _setting.AppToken.Trim()),
new(TimestampHeader, timestamp),
new(NonceHeader, nonce),
new(SignatureVersionHeader, SignatureHmacSha256)
};
if (!string.IsNullOrWhiteSpace(jsonBody))
values.Add(new KeyValuePair<string, string>(
RequestBodySignatureKey,
jsonBody));
if (parameters != null)
{
values.AddRange(parameters.Where(item =>
!string.IsNullOrWhiteSpace(item.Key)
&& !string.IsNullOrWhiteSpace(item.Value)));
}
if (fileBytes != null)
{
values.Add(new KeyValuePair<string, string>(
"file",
Convert.ToHexString(MD5.HashData(fileBytes))
.ToLowerInvariant()));
}
var builder = new StringBuilder()
.Append("requestMethod=")
.Append(method.Method)
.Append("&requestUrl=")
.Append(path);
foreach (var value in values
.OrderBy(item => item.Key, StringComparer.Ordinal))
{
builder.Append('&')
.Append(value.Key.Trim())
.Append('=')
.Append(value.Value.Trim());
}
using var hmac = new HMACSHA256(
Encoding.UTF8.GetBytes(_setting.AppSecret.Trim()));
return Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString())))
.ToLowerInvariant();
}
private string BuildUrl(
string path,
Dictionary<string, string> query = null)
{
var url = $"{_setting.ServerUrl.Trim().TrimEnd('/')}/{path.TrimStart('/')}";
if (query == null || query.Count == 0)
return url;
var queryString = string.Join("&", query.Select(item =>
Uri.EscapeDataString(item.Key)
+ "="
+ Uri.EscapeDataString(item.Value ?? string.Empty)));
return url + "?" + queryString;
}
private void ValidateSettings()
{
if (!_setting.Enabled)
throw Oops.Bah("契约锁电子签功能尚未启用");
if (string.IsNullOrWhiteSpace(_setting.ServerUrl))
throw Oops.Bah("请配置契约锁 ServerUrl");
if (string.IsNullOrWhiteSpace(_setting.AppToken))
throw Oops.Bah("请配置契约锁 AppToken");
if (string.IsNullOrWhiteSpace(_setting.AppSecret))
throw Oops.Bah("请配置契约锁 AppSecret");
}
private static int ReadCode(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Number
&& element.TryGetInt32(out var numericCode))
{
return numericCode;
}
return int.TryParse(element.ToString(), out var textCode)
? textCode
: -1;
}
private static bool TryGetString(
JsonElement element,
string propertyName,
out string value)
{
value = null;
if (element.ValueKind != JsonValueKind.Object
|| !element.TryGetProperty(propertyName, out var property)
|| property.ValueKind == JsonValueKind.Null)
{
return false;
}
value = property.ValueKind == JsonValueKind.String
? property.GetString()
: property.ToString();
return !string.IsNullOrWhiteSpace(value);
}
private static bool LooksLikeJson(
HttpResponseMessage response,
byte[] bytes)
{
var contentType = response.Content.Headers.ContentType?.MediaType;
if (!string.IsNullOrWhiteSpace(contentType)
&& contentType.Contains("json", StringComparison.OrdinalIgnoreCase))
{
return true;
}
var first = bytes.FirstOrDefault(item =>
!char.IsWhiteSpace((char)item));
return first == '{' || first == '[';
}
private static void ThrowProviderError(
byte[] bytes,
global::System.Net.HttpStatusCode statusCode)
{
var text = Encoding.UTF8.GetString(bytes);
try
{
using var document = JsonDocument.Parse(text);
var root = document.RootElement;
var hasMessage = TryGetString(
root,
"message",
out var providerMessage)
|| TryGetString(
root,
"responseMessage",
out providerMessage);
var message = hasMessage ? providerMessage : text;
throw Oops.Bah($"契约锁下载失败:{message}");
}
catch (JsonException)
{
throw Oops.Bah(
$"契约锁下载失败:HTTP {(int)statusCode}");
}
}
private static string NullIfBlank(string value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>
/// 构造兼容契约锁长整型 Id 和字符串 Id 两种返回形式的 JSON 配置。
/// </summary>
private static JsonSerializerOptions CreateJsonOptions()
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
options.Converters.Add(new FlexibleStringJsonConverter());
return options;
}
/// <summary>
/// 将第三方响应中的字符串或数字统一读取为字符串,避免长整型 Id 精度丢失。
/// </summary>
private sealed class FlexibleStringJsonConverter : JsonConverter<string>
{
public override string Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
return reader.TokenType switch
{
JsonTokenType.String => reader.GetString(),
JsonTokenType.Number => GetRawNumber(ref reader),
JsonTokenType.True => bool.TrueString,
JsonTokenType.False => bool.FalseString,
_ => reader.GetString()
};
}
public override void Write(
Utf8JsonWriter writer,
string value,
JsonSerializerOptions options)
{
writer.WriteStringValue(value);
}
private static string GetRawNumber(ref Utf8JsonReader reader)
{
if (reader.TryGetInt64(out var integer))
return integer.ToString(CultureInfo.InvariantCulture);
return reader.GetDecimal().ToString(CultureInfo.InvariantCulture);
}
}
}
@@ -0,0 +1,259 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 创建契约锁合同草稿所需参数。
/// </summary>
public class QiyuesuoCreateDraftInput
{
/// <summary>本系统幂等业务编号。</summary>
public string BizId { get; set; }
/// <summary>合同流程标题。</summary>
public string Subject { get; set; }
/// <summary>签署企业法定名称。</summary>
public string CompanyName { get; set; }
/// <summary>员工姓名。</summary>
public string EmployeeName { get; set; }
/// <summary>员工手机号。</summary>
public string EmployeeMobile { get; set; }
/// <summary>契约锁业务分类 Id。</summary>
public string CategoryId { get; set; }
/// <summary>契约锁企业印章 Id。</summary>
public string SealId { get; set; }
/// <summary>契约锁企业成员姓名。</summary>
public string CreatorName { get; set; }
/// <summary>契约锁企业成员手机号。</summary>
public string CreatorMobile { get; set; }
/// <summary>契约锁服务器回调地址。</summary>
public string CallbackUrl { get; set; }
/// <summary>是否需要企业签章节点;仅员工签字的流程不创建该节点。</summary>
public bool RequiresCompanySign { get; set; } = true;
}
/// <summary>
/// 上传契约锁合同文档所需参数。
/// </summary>
public class QiyuesuoAddDocumentInput
{
/// <summary>契约锁合同流程 Id。</summary>
public string ContractId { get; set; }
/// <summary>本系统幂等业务编号。</summary>
public string BizId { get; set; }
/// <summary>文档标题。</summary>
public string Title { get; set; }
/// <summary>文档排序。</summary>
public int DocumentSort { get; set; }
/// <summary>上传文件名。</summary>
public string FileName { get; set; }
/// <summary>DOCX 文件内容。</summary>
public byte[] FileBytes { get; set; }
}
/// <summary>
/// 契约锁签章位置。
/// </summary>
public class QiyuesuoStamperInput
{
/// <summary>企业签署动作 Id。</summary>
public string ActionId { get; set; }
/// <summary>个人签署方 Id。</summary>
public string SignatoryId { get; set; }
/// <summary>签章类型。</summary>
public string Type { get; set; }
/// <summary>契约锁文档 Id。</summary>
public string DocumentId { get; set; }
/// <summary>骑缝章覆盖的契约锁文档 Id,多个 Id 使用英文逗号分隔。</summary>
public string DocumentIds { get; set; }
/// <summary>骑缝章位置指定方式。</summary>
public string AcrossType { get; set; }
/// <summary>企业印章 Id。</summary>
public string SealId { get; set; }
/// <summary>签署位置关键字。</summary>
public string Keyword { get; set; }
/// <summary>同名关键字的序号,从 1 开始。</summary>
public int? KeywordIndex { get; set; }
/// <summary>相对关键字的横向偏移量;契约锁要求必传,不偏移时为 0。</summary>
public decimal OffsetX { get; set; }
/// <summary>相对关键字的纵向偏移量;契约锁要求必传,不偏移时为 0。</summary>
public decimal OffsetY { get; set; }
}
/// <summary>
/// 发起契约锁合同流程所需参数。
/// </summary>
public class QiyuesuoSendContractInput
{
/// <summary>契约锁合同流程 Id。</summary>
public string ContractId { get; set; }
/// <summary>本系统幂等业务编号。</summary>
public string BizId { get; set; }
/// <summary>契约锁合同发起企业名称。</summary>
public string CompanyName { get; set; }
/// <summary>所有文档的签名和盖章位置。</summary>
public List<QiyuesuoStamperInput> Stampers { get; set; } = new();
}
/// <summary>
/// 获取契约锁员工签署页所需参数。
/// </summary>
public class QiyuesuoSignPageInput
{
/// <summary>契约锁合同流程 Id。</summary>
public string ContractId { get; set; }
/// <summary>员工手机号。</summary>
public string EmployeeMobile { get; set; }
/// <summary>签署完成后的前端返回地址。</summary>
public string CallbackPage { get; set; }
}
/// <summary>
/// 获取契约锁合同文档只读浏览页所需参数。
/// </summary>
public class QiyuesuoViewPageInput
{
/// <summary>契约锁合同流程 Id。</summary>
public string ContractId { get; set; }
/// <summary>契约锁合同文档 Id。</summary>
public string DocumentId { get; set; }
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
public string CompanyName { get; set; }
}
/// <summary>
/// 契约锁企业人工盖章所需参数。
/// </summary>
public class QiyuesuoCompanySignInput
{
/// <summary>契约锁合同流程 Id。</summary>
public string ContractId { get; set; }
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
public string CompanyName { get; set; }
/// <summary>当前流程使用的企业印章 Id 快照。</summary>
public string SealId { get; set; }
}
/// <summary>
/// 契约锁合同返回对象。
/// </summary>
public class QiyuesuoContractResult
{
/// <summary>契约锁合同流程 Id。</summary>
public string Id { get; set; }
/// <summary>兼容部分接口返回的合同流程 Id 字段名。</summary>
public string ContractId { get; set; }
/// <summary>本系统幂等业务编号。</summary>
public string BizId { get; set; }
/// <summary>流程标题。</summary>
public string Subject { get; set; }
/// <summary>契约锁合同状态。</summary>
public string Status { get; set; }
/// <summary>签署方列表。</summary>
public List<QiyuesuoSignatoryResult> Signatories { get; set; } = new();
/// <summary>合同文档列表。</summary>
public List<QiyuesuoDocumentResult> Documents { get; set; } = new();
}
/// <summary>
/// 契约锁签署方返回对象。
/// </summary>
public class QiyuesuoSignatoryResult
{
/// <summary>签署方 Id。</summary>
public string Id { get; set; }
/// <summary>兼容部分接口返回的签署方 Id 字段名。</summary>
public string SignatoryId { get; set; }
/// <summary>签署方类型。</summary>
public string TenantType { get; set; }
/// <summary>签署方名称。</summary>
public string TenantName { get; set; }
/// <summary>签署方状态。</summary>
public string Status { get; set; }
/// <summary>签署顺序。</summary>
public int? SerialNo { get; set; }
/// <summary>企业签署动作。</summary>
public List<QiyuesuoActionResult> Actions { get; set; } = new();
}
/// <summary>
/// 契约锁企业签署动作返回对象。
/// </summary>
public class QiyuesuoActionResult
{
/// <summary>签署动作 Id。</summary>
public string Id { get; set; }
/// <summary>兼容部分接口返回的签署动作 Id 字段名。</summary>
public string ActionId { get; set; }
/// <summary>签署动作类型。</summary>
public string Type { get; set; }
/// <summary>签署动作状态。</summary>
public string Status { get; set; }
/// <summary>动作执行顺序。</summary>
public int? SerialNo { get; set; }
}
/// <summary>
/// 契约锁合同文档返回对象。
/// </summary>
public class QiyuesuoDocumentResult
{
/// <summary>契约锁文档 Id。</summary>
public string Id { get; set; }
/// <summary>兼容部分接口返回的文档 Id 字段名。</summary>
public string DocumentId { get; set; }
/// <summary>文档标题。</summary>
public string Title { get; set; }
/// <summary>文档页数。</summary>
public int? PageCount { get; set; }
}
@@ -0,0 +1,206 @@
namespace SimpleAdmin.Application;
/// <summary>发起签约前按文件冻结的签字、盖章要求。</summary>
internal sealed record OnboardingSigningDocument(
string DocumentId,
string EmployeeSignKeyword,
string CompanySealKeyword,
bool? CompanySealEnabled,
bool? AcrossPageSealEnabled);
/// <summary>构造契约锁请求;无数据库和网络访问,员工签字始终覆盖全部文件。</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>
@@ -21,9 +23,144 @@ public class OnboardingEmployeePageInput : BasePageInput
public long? CompanyId { get; set; }
/// <summary>
/// 岗位 Id;为空时表示公司级配置。
/// 入职业务部门 Id。
/// </summary>
public long? DepartmentId { get; set; }
/// <summary>
/// 岗位 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>
@@ -36,6 +173,11 @@ public class OnboardingEmployeeDetailOutput
/// </summary>
public ObEmployee Employee { get; set; }
/// <summary>
/// 审核通过时对应的完整入职登记表。
/// </summary>
public ObTaskEmployeeInfo EmployeeInfo { get; set; }
/// <summary>
/// 入职任务主数据。
/// </summary>
@@ -46,8 +188,31 @@ public class OnboardingEmployeeDetailOutput
/// </summary>
public List<ObTaskContract> Contracts { get; set; } = new List<ObTaskContract>();
/// <summary>
/// 审核建档时冻结的员工合同业务快照,包含常用字段及全部自定义变量。
/// </summary>
public List<ObEmployeeContractSnapshot> ContractSnapshots { get; set; }
= new List<ObEmployeeContractSnapshot>();
/// <summary>
/// 员工入职时上传的身份证、学历证明、体检报告等资料。
/// </summary>
public List<OnboardingTaskMaterialDetailOutput> Materials { get; set; }
= new List<OnboardingTaskMaterialDetailOutput>();
/// <summary>
/// 契约锁签署文件与签署日志组成的完整归档 ZIP 文件 Id。
/// </summary>
public long? ElectronicSignArchiveFileId { get; set; }
/// <summary>
/// 提交与审核记录列表。
/// </summary>
public List<ObTaskAudit> Audits { get; set; } = new List<ObTaskAudit>();
/// <summary>
/// 员工转正及人事维护历史。
/// </summary>
public List<ObEmployeeChangeRecord> ChangeRecords { get; set; }
= new List<ObEmployeeChangeRecord>();
}
@@ -1,14 +1,30 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义正式员工档案查询业务能力。
/// 定义员工档案查询和人事维护业务能力。
/// </summary>
public interface IOnboardingEmployeeService : ITransient
{
/// <summary>
/// 分页查询审核通过后生成的正式员工档案。
/// 分页查询审核通过后生成的正式员工档案及维度统计。
/// </summary>
Task<SqlSugarPagedList<ObEmployee>> Page(OnboardingEmployeePageInput input);
Task<OnboardingEmployeePageOutput> Page(OnboardingEmployeePageInput input);
/// <summary>
/// 按当前列表筛选条件导出员工明细。
/// </summary>
Task<OnboardingEmployeeExportFile> Export(OnboardingEmployeePageInput input);
/// <summary>
/// 编辑员工用工、社保及合同性质等人事信息。
/// </summary>
Task UpdateHrInfo(OnboardingEmployeeHrUpdateInput input);
/// <summary>
/// 人工办理员工转正。
/// </summary>
Task Regularize(OnboardingEmployeeRegularizeInput input);
/// <summary>
/// 撤销一次员工转正,历史记录和附件继续保留。
/// </summary>
Task RevokeRegularization(OnboardingEmployeeRegularizationRevokeInput input);
/// <summary>
/// 查询正式员工及其来源任务、合同和审核记录。
/// </summary>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义正式员工合同快照归档能力。
/// </summary>
public interface IOnboardingEmployeeContractSnapshotService : ITransient
{
/// <summary>冻结一名员工来源任务中的全部合同和变量。</summary>
Task Build(long employeeId, long taskId);
}
@@ -0,0 +1,153 @@
using System.Globalization;
namespace SimpleAdmin.Application;
/// <summary>
/// 在正式员工建档时,将每份任务合同及其全部变量冻结为长期业务快照。
/// </summary>
public class OnboardingEmployeeContractSnapshotService : IOnboardingEmployeeContractSnapshotService
{
private readonly SqlSugarScope _db;
public OnboardingEmployeeContractSnapshotService()
{
_db = DbContext.DB;
}
/// <inheritdoc />
public async Task Build(long employeeId, long taskId)
{
var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == taskId)
.OrderBy(item => item.Id)
.ToListAsync();
if (contracts.Count == 0)
throw Oops.Bah("入职任务没有合同,无法生成员工合同快照");
var contractIds = contracts.Select(item => item.Id).ToList();
var allVariables = await _db.Queryable<ObTaskContractVariable>()
.Where(item => contractIds.Contains(item.TaskContractId))
.OrderBy(item => item.SortCode)
.OrderBy(item => item.Id)
.ToListAsync();
var now = DateTime.Now;
var snapshots = new List<ObEmployeeContractSnapshot>();
foreach (var contract in contracts)
{
var variables = allVariables
.Where(item => item.TaskContractId == contract.Id)
.OrderBy(item => item.SortCode)
.ThenBy(item => item.Id)
.ToList();
var valueMap = variables
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.Last().VariableValue,
StringComparer.OrdinalIgnoreCase);
var variableSnapshot = variables
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group =>
{
// 历史任务若意外存在重复变量,以排序后的最后一条最终值为准,避免建档时报 500。
var item = group.Last();
return (object)new
{
variableName = item.VariableName,
variableType = item.VariableType,
dataType = item.DataType,
variableValue = item.VariableValue,
sourceType = item.SourceType,
isManualOverride = item.IsManualOverride,
sortCode = item.SortCode
};
},
StringComparer.OrdinalIgnoreCase);
snapshots.Add(new ObEmployeeContractSnapshot
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
EmployeeId = employeeId,
TaskId = taskId,
TaskContractId = contract.Id,
TemplateId = contract.TemplateId,
TemplateVersionId = contract.TemplateVersionId,
ContractName = contract.ContractName,
ContractType = contract.ContractType,
ContractStartDate = ParseDate(GetValue(valueMap, "hr.ContractStartDate")),
ContractEndDate = ParseDate(GetValue(valueMap, "hr.ContractEndDate")),
ContractTerm = GetValue(valueMap, "hr.ContractTerm"),
ProbationStartDate = ParseDate(GetValue(valueMap, "hr.ProbationStartDate")),
ProbationEndDate = ParseDate(GetValue(valueMap, "hr.ProbationEndDate")),
ProbationTerm = GetValue(valueMap, "hr.ProbationTerm"),
ProbationSalary = ParseAmount(GetValue(valueMap, "hr.ProbationSalary")),
BaseSalary = ParseAmount(GetValue(valueMap, "hr.BaseSalary")),
PositionSalary = ParseAmount(GetValue(valueMap, "hr.PositionSalary")),
PerformanceAllowance = ParseAmount(GetValue(valueMap, "hr.PerformanceAllowance")),
AttendanceBonus = ParseAmount(GetValue(valueMap, "hr.AttendanceBonus")),
RegularSalary = ParseAmount(GetValue(valueMap, "hr.RegularSalary")),
WorkCity = GetValue(valueMap, "hr.WorkCity"),
PositionName = GetValue(valueMap, "hr.PositionName"),
EmployeeNo = GetValue(valueMap, "hr.EmployeeNo"),
VariablesJson = global::System.Text.Json.JsonSerializer.Serialize(variableSnapshot),
SnapshotVersion = 1,
SnapshotAt = now,
Status = CommonStatusConst.ENABLE,
CreateTime = now,
CreateUserId = UserManager.UserId,
CreateUser = UserManager.Name,
IsDelete = false
});
}
// Build 只在审核建档事务内执行;先删后建使异常重试保持幂等。
await _db.Deleteable<ObEmployeeContractSnapshot>()
.Where(item => item.TaskId == taskId)
.ExecuteCommandAsync();
await _db.Insertable(snapshots).ExecuteCommandAsync();
}
private static string GetValue(
IReadOnlyDictionary<string, string> valueMap,
string variableCode)
{
return valueMap.TryGetValue(variableCode, out var value) && !string.IsNullOrWhiteSpace(value)
? value.Trim()
: null;
}
private static DateTime? ParseDate(string value)
{
return DateTime.TryParse(value, out var date) ? date.Date : null;
}
private static decimal? ParseAmount(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var normalized = value
.Replace("¥", string.Empty)
.Replace("¥", string.Empty)
.Replace("元", string.Empty)
.Trim();
if (decimal.TryParse(
normalized,
NumberStyles.Number | NumberStyles.AllowCurrencySymbol,
CultureInfo.GetCultureInfo("zh-CN"),
out var amount)
|| decimal.TryParse(
normalized,
NumberStyles.Number,
CultureInfo.InvariantCulture,
out amount))
{
return amount;
}
return null;
}
}
@@ -91,79 +91,3 @@ public class OnboardingMaterialTypeSaveInput
/// </summary>
public string Status { get; set; } = "enable";
}
/// <summary>
/// 保存公司岗位动态字段要求参数。
/// </summary>
public class OnboardingFormRequirementSaveInput
{
/// <summary>
/// 业务数据 Id。
/// </summary>
public long? Id { get; set; }
/// <summary>
/// 入职业务公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "公司Id不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 岗位 Id;为空时表示公司级配置。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 动态字段 Id。
/// </summary>
[IdNotNull(ErrorMessage = "字段Id不能为空")]
public long FieldId { get; set; }
/// <summary>
/// 是否必填、必传或必签。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 显示排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 保存公司岗位材料要求参数。
/// </summary>
public class OnboardingMaterialRequirementSaveInput
{
/// <summary>
/// 业务数据 Id。
/// </summary>
public long? Id { get; set; }
/// <summary>
/// 入职业务公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "公司Id不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 岗位 Id;为空时表示公司级配置。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 材料类型 Id。
/// </summary>
[IdNotNull(ErrorMessage = "资料类型Id不能为空")]
public long MaterialTypeId { get; set; }
/// <summary>
/// 是否必填、必传或必签。
/// </summary>
public bool Required { get; set; }
/// <summary>
/// 显示排序码。
/// </summary>
public int? SortCode { get; set; }
}
@@ -1,7 +1,7 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义动态字段、材料类型及公司岗位要求配置业务能力。
/// 定义员工动态补充字段与上传材料类型的基础配置能力。
/// </summary>
public interface IOnboardingEntryConfigService : ITransient
{
@@ -9,48 +9,29 @@ public interface IOnboardingEntryConfigService : ITransient
/// 查询全部动态入职字段。
/// </summary>
Task<List<ObFormField>> ListFormFields();
/// <summary>
/// 新增或编辑动态入职字段。
/// </summary>
Task SaveFormField(OnboardingFormFieldSaveInput input);
/// <summary>
/// 删除未被填写要求引用的动态字段。
/// 删除未被入职模板引用的动态字段。
/// </summary>
Task DeleteFormFields(BaseIdListInput input);
/// <summary>
/// 查询全部入职材料类型。
/// </summary>
Task<List<ObMaterialType>> ListMaterialTypes();
/// <summary>
/// 新增或编辑入职材料类型。
/// </summary>
Task SaveMaterialType(OnboardingMaterialTypeSaveInput input);
/// <summary>
/// 删除未被要求或上传记录引用的材料类型。
/// 删除未被入职模板或上传记录引用的材料类型。
/// </summary>
Task DeleteMaterialTypes(BaseIdListInput input);
/// <summary>
/// 查询指定公司的动态字段填写要求。
/// </summary>
Task<List<ObFormRequirement>> ListFormRequirements(long companyId);
/// <summary>
/// 新增或编辑公司岗位动态字段要求。
/// </summary>
Task SaveFormRequirement(OnboardingFormRequirementSaveInput input);
/// <summary>
/// 删除动态字段填写要求。
/// </summary>
Task DeleteFormRequirements(BaseIdListInput input);
/// <summary>
/// 查询指定公司的材料上传要求。
/// </summary>
Task<List<ObMaterialRequirement>> ListMaterialRequirements(long companyId);
/// <summary>
/// 新增或编辑公司岗位材料要求。
/// </summary>
Task SaveMaterialRequirement(OnboardingMaterialRequirementSaveInput input);
/// <summary>
/// 删除材料上传要求。
/// </summary>
Task DeleteMaterialRequirements(BaseIdListInput input);
}
}
@@ -1,15 +1,17 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 维护员工端动态字段、材料类型及公司岗位要求。
/// 维护员工端动态补充字段和材料类型;与公司、岗位的组合关系由入职模板统一管理。
/// </summary>
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,8 +29,17 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
/// </summary>
public async Task SaveFormField(OnboardingFormFieldSaveInput input)
{
var fieldCodeExists = await _db.Queryable<ObFormField>()
.AnyAsync(x => x.FieldCode == input.FieldCode && x.Id != input.Id);
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)
fieldCodeQuery = fieldCodeQuery.Where(x => x.Id != input.Id.Value);
var fieldCodeExists = await fieldCodeQuery.AnyAsync();
if (fieldCodeExists)
throw Oops.Bah("字段编码已存在");
@@ -38,7 +49,7 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
FieldCode = input.FieldCode,
FieldName = input.FieldName,
DataType = input.DataType,
OptionsJson = input.OptionsJson,
OptionsJson = NormalizeOptionsJson(input.OptionsJson),
ValidationRule = input.ValidationRule,
Placeholder = input.Placeholder,
Status = input.Status
@@ -46,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;
}
@@ -57,6 +73,27 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
await _db.Insertable(entity).ExecuteCommandAsync();
}
/// <summary>
/// 将空白的可选项转换为 null,避免 MySQL JSON 字段写入空字符串时报错。
/// </summary>
private static string NormalizeOptionsJson(string optionsJson)
{
if (string.IsNullOrWhiteSpace(optionsJson))
return null;
var normalizedJson = optionsJson.Trim();
try
{
using var jsonDocument = global::System.Text.Json.JsonDocument.Parse(normalizedJson);
return normalizedJson;
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah("可选项不是有效的 JSON 格式");
}
}
/// <summary>
/// 删除未被填写要求引用的动态字段。
/// </summary>
@@ -65,10 +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));
if (isReferenced)
throw Oops.Bah("字段已被填写要求引用,不能删除");
if (isReferencedByTask)
throw Oops.Bah("字段已被入职任务引用,不能删除,可改为停用");
await _db.Deleteable<ObFormField>().In(input.Ids).ExecuteCommandAsync();
}
@@ -88,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);
@@ -109,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;
}
@@ -128,10 +203,25 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Ids.Count == 0)
return;
var isReferenced = await _db.Queryable<ObMaterialRequirement>()
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)
throw Oops.Bah("资料类型已被资料要求引用,不能删除");
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));
@@ -140,142 +230,4 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
await _db.Deleteable<ObMaterialType>().In(input.Ids).ExecuteCommandAsync();
}
/// <summary>
/// 查询指定公司的动态字段填写要求。
/// </summary>
public Task<List<ObFormRequirement>> ListFormRequirements(long companyId)
{
return _db.Queryable<ObFormRequirement>()
.Where(x => x.CompanyId == companyId)
.OrderBy(x => x.SortCode)
.ToListAsync();
}
/// <summary>
/// 新增或编辑公司岗位动态字段要求。
/// </summary>
public async Task SaveFormRequirement(OnboardingFormRequirementSaveInput input)
{
await EnsureCompanyAndPosition(input.CompanyId, input.PositionId);
var fieldExists = await _db.Queryable<ObFormField>()
.AnyAsync(x => x.Id == input.FieldId && x.Status == "enable");
if (!fieldExists)
throw Oops.Bah("动态字段不存在或未启用");
var requirementExists = await _db.Queryable<ObFormRequirement>()
.AnyAsync(x => x.CompanyId == input.CompanyId
&& x.PositionId == input.PositionId
&& x.FieldId == input.FieldId
&& x.Id != input.Id);
if (requirementExists)
throw Oops.Bah("该公司、岗位和字段的要求已存在");
var entity = new ObFormRequirement
{
Id = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
CompanyId = input.CompanyId,
PositionId = input.PositionId,
FieldId = input.FieldId,
Required = input.Required,
SortCode = input.SortCode
};
if (input.Id.HasValue)
{
await _db.Updateable(entity).ExecuteCommandAsync();
return;
}
await _db.Insertable(entity).ExecuteCommandAsync();
}
/// <summary>
/// 删除动态字段填写要求。
/// </summary>
public Task DeleteFormRequirements(BaseIdListInput input)
{
if (input.Ids.Count == 0)
return Task.CompletedTask;
return _db.Deleteable<ObFormRequirement>().In(input.Ids).ExecuteCommandAsync();
}
/// <summary>
/// 查询指定公司的材料上传要求。
/// </summary>
public Task<List<ObMaterialRequirement>> ListMaterialRequirements(long companyId)
{
return _db.Queryable<ObMaterialRequirement>()
.Where(x => x.CompanyId == companyId)
.OrderBy(x => x.SortCode)
.ToListAsync();
}
/// <summary>
/// 新增或编辑公司岗位材料要求。
/// </summary>
public async Task SaveMaterialRequirement(OnboardingMaterialRequirementSaveInput input)
{
await EnsureCompanyAndPosition(input.CompanyId, input.PositionId);
var materialTypeExists = await _db.Queryable<ObMaterialType>()
.AnyAsync(x => x.Id == input.MaterialTypeId && x.Status == "enable");
if (!materialTypeExists)
throw Oops.Bah("资料类型不存在或未启用");
var requirementExists = await _db.Queryable<ObMaterialRequirement>()
.AnyAsync(x => x.CompanyId == input.CompanyId
&& x.PositionId == input.PositionId
&& x.MaterialTypeId == input.MaterialTypeId
&& x.Id != input.Id);
if (requirementExists)
throw Oops.Bah("该公司、岗位和资料类型的要求已存在");
var entity = new ObMaterialRequirement
{
Id = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
CompanyId = input.CompanyId,
PositionId = input.PositionId,
MaterialTypeId = input.MaterialTypeId,
Required = input.Required,
SortCode = input.SortCode
};
if (input.Id.HasValue)
{
await _db.Updateable(entity).ExecuteCommandAsync();
return;
}
await _db.Insertable(entity).ExecuteCommandAsync();
}
/// <summary>
/// 删除材料上传要求。
/// </summary>
public Task DeleteMaterialRequirements(BaseIdListInput input)
{
if (input.Ids.Count == 0)
return Task.CompletedTask;
return _db.Deleteable<ObMaterialRequirement>().In(input.Ids).ExecuteCommandAsync();
}
private async Task EnsureCompanyAndPosition(long companyId, long? positionId)
{
var companyExists = await _db.Queryable<ObCompany>()
.AnyAsync(x => x.Id == companyId && x.Status == "enable");
if (!companyExists)
throw Oops.Bah("公司不存在或未启用");
if (!positionId.HasValue)
return;
var positionExists = await _db.Queryable<ObPosition>()
.AnyAsync(x => x.Id == positionId.Value && x.CompanyId == companyId && x.Status == "enable");
if (!positionExists)
throw Oops.Bah("岗位不存在、未启用或不属于该公司");
}
}
@@ -0,0 +1,15 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义候选人身份重复校验能力。
/// </summary>
public interface IOnboardingIdentityValidationService : ITransient
{
/// <summary>
/// 校验姓名和身份证号没有出现在其他有效入职流程或正式员工档案中。
/// </summary>
Task EnsureNoDuplicate(
long currentTaskId,
string employeeName,
string idCardNumber);
}
@@ -0,0 +1,73 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 统一校验候选人身份,避免同一人员通过多个链接重复提交入职资料。
/// </summary>
public class OnboardingIdentityValidationService : IOnboardingIdentityValidationService
{
private readonly SqlSugarScope _db;
public OnboardingIdentityValidationService()
{
_db = DbContext.DB;
}
/// <inheritdoc />
public async Task EnsureNoDuplicate(
long currentTaskId,
string employeeName,
string idCardNumber)
{
employeeName = employeeName?.Trim();
idCardNumber = idCardNumber?.Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(employeeName) || string.IsNullOrWhiteSpace(idCardNumber))
return;
var formalEmployeeExists = await _db.Queryable<ObEmployee>()
.AnyAsync(item => item.EmployeeName == employeeName
&& item.IdCardNumber == idCardNumber);
if (formalEmployeeExists)
{
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)
.Where((info, task) => info.TaskId != currentTaskId
&& info.EmployeeName == employeeName
&& info.IdCardNumber == idCardNumber
&& (task.TaskStatus == OnboardingTaskStatus.Reviewing
|| task.TaskStatus == OnboardingTaskStatus.Rejected
|| task.TaskStatus == OnboardingTaskStatus.Approved
|| task.TaskStatus == OnboardingTaskStatus.Archived))
.AnyAsync();
if (duplicateTaskExists)
{
throw Oops.Bah("该姓名和身份证号已存在正在办理的入职任务,请勿重复提交;如需补充材料,请联系 HR 继续使用原任务链接");
}
// 再单独按身份证号码校验,防止不同姓名使用同一证件重复提交不同任务。
var duplicateIdCardTaskExists = await _db.Queryable<ObTaskEmployeeInfo>()
.InnerJoin<ObOnboardingTask>((info, task) => info.TaskId == task.Id)
.Where((info, task) => info.TaskId != currentTaskId
&& info.IdCardNumber == idCardNumber
&& (task.TaskStatus == OnboardingTaskStatus.Reviewing
|| task.TaskStatus == OnboardingTaskStatus.Rejected
|| task.TaskStatus == OnboardingTaskStatus.Approved
|| task.TaskStatus == OnboardingTaskStatus.Archived))
.AnyAsync();
if (duplicateIdCardTaskExists)
{
throw Oops.Bah("该身份证号码已存在正在办理的入职任务,请核对姓名和身份证号码;如需补充材料,请联系 HR 继续使用原任务链接");
}
}
}
@@ -14,4 +14,9 @@ public interface IOnboardingTaskLifecycleService : ITransient
/// 撤销尚未归档的入职任务并使员工链接失效。
/// </summary>
Task Revoke(OnboardingAuditActionInput input);
/// <summary>
/// 删除已撤销的入职任务及其全部关联快照数据。
/// </summary>
Task Delete(BaseIdInput input);
}
@@ -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)
@@ -25,60 +30,163 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
throw Oops.Bah("仅审核通过任务可归档");
}
task.TaskStatus = OnboardingTaskStatus.Archived;
task.ArchivedAt = DateTime.Now;
await _db.Updateable(task).ExecuteCommandAsync();
await Log(task, OnboardingAuditAction.Archive, input.AuditOpinion);
await _db.Ado.BeginTranAsync();
try
{
var archivedAt = DateTime.Now;
var updatedRows = await _db.Updateable<ObOnboardingTask>()
.SetColumns(item => new ObOnboardingTask
{
TaskStatus = OnboardingTaskStatus.Archived,
ArchivedAt = archivedAt
})
.Where(item => item.Id == task.Id
&& item.TaskStatus == OnboardingTaskStatus.Approved)
.ExecuteCommandAsync();
if (updatedRows != 1)
throw Oops.Bah("该入职任务状态已变化,请刷新后重试");
task.TaskStatus = OnboardingTaskStatus.Archived;
task.ArchivedAt = archivedAt;
await Log(task, OnboardingAuditAction.Archive, input.AuditOpinion);
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
}
/// <summary>
/// 撤销入职任务并同步撤销仍然有效的员工填写链接。
/// </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 || task.TaskStatus == OnboardingTaskStatus.Archived)
if (task.TaskStatus == OnboardingTaskStatus.Approved
|| task.TaskStatus == OnboardingTaskStatus.Archived
|| task.TaskStatus == OnboardingTaskStatus.Revoked)
{
throw Oops.Bah("已通过或已归档任务不能撤销");
throw Oops.Bah("已通过、已归档或已撤销任务不能再次撤销");
}
task.TaskStatus = OnboardingTaskStatus.Revoked;
await _db.Updateable(task).ExecuteCommandAsync();
await _db.Updateable<ObTaskLink>()
.SetColumns(item => new ObTaskLink
await _db.Ado.BeginTranAsync();
try
{
var originalStatus = task.TaskStatus;
var revokedAt = DateTime.Now;
var updatedRows = await _db.Updateable<ObOnboardingTask>()
.SetColumns(item => item.TaskStatus == OnboardingTaskStatus.Revoked)
.Where(item => item.Id == task.Id && item.TaskStatus == originalStatus)
.ExecuteCommandAsync();
if (updatedRows != 1)
throw Oops.Bah("该入职任务状态已变化,请刷新后重试");
await _db.Updateable<ObTaskLink>()
.SetColumns(item => new ObTaskLink
{
LinkStatus = OnboardingLinkStatus.Revoked,
RevokedAt = revokedAt
})
.Where(item => item.TaskId == task.Id
&& item.LinkStatus == OnboardingLinkStatus.Active)
.ExecuteCommandAsync();
task.TaskStatus = OnboardingTaskStatus.Revoked;
await Log(task, OnboardingAuditAction.Revoke, input.AuditOpinion);
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
}
/// <summary>
/// 删除已撤销的入职任务及其全部关联快照数据。
/// </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)
{
throw Oops.Bah("仅已撤销的任务可以删除");
}
await _db.Ado.BeginTranAsync();
try
{
var taskContractIds = await _db.Queryable<ObTaskContract>()
.Where(x => x.TaskId == task.Id)
.Select(x => x.Id)
.ToListAsync();
var signProcessIds = await _db.Queryable<ObElectronicSignProcess>()
.Where(x => x.TaskId == task.Id)
.Select(x => x.Id)
.ToListAsync();
if (signProcessIds.Count > 0)
{
LinkStatus = OnboardingLinkStatus.Revoked,
RevokedAt = DateTime.Now
})
.Where(item => item.TaskId == task.Id && item.LinkStatus == OnboardingLinkStatus.Active)
.ExecuteCommandAsync();
await Log(task, OnboardingAuditAction.Revoke, input.AuditOpinion);
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>()
.Where(x => taskContractIds.Contains(x.TaskContractId))
.ExecuteCommandAsync();
}
await _db.Deleteable<ObTaskContract>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskLink>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskMaterialRequirement>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskMaterial>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskEmployeeInfo>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskFormRequirement>().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();
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
}
private async Task<ObOnboardingTask> Authorize(long taskId)
{
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("请配置入职审核人");
using var configJson = global::System.Text.Json.JsonDocument.Parse(config.ConfigValue);
var reviewerIds = new List<long>();
if (configJson.RootElement.TryGetProperty("all", out var allReviewers))
{
reviewerIds.AddRange(allReviewers.EnumerateArray().Select(item => item.GetInt64()));
}
if (configJson.RootElement.TryGetProperty("company", out var companyReviewers)
&& companyReviewers.TryGetProperty(task.CompanyId.ToString(), out var reviewers))
{
reviewerIds.AddRange(reviewers.EnumerateArray().Select(item => item.GetInt64()));
}
if (!reviewerIds.Contains(UserManager.UserId))
{
throw Oops.Bah("您不是该入职任务的审核人");
}
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return task;
}
@@ -6,18 +6,24 @@ namespace SimpleAdmin.Application;
public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
{
private readonly SqlSugarScope _db;
private readonly IConfigService _config;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingMaterialReviewService(IConfigService config)
public OnboardingMaterialReviewService(
IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_config = config;
_dataPermission = dataPermission;
}
/// <summary>
/// 审核单份候选人上传材料并记录审核意见。
/// </summary>
public async Task Review(OnboardingMaterialReviewInput input)
{
await OnboardingConcurrency.RunMaterial(_db, input.TaskMaterialId, () => ReviewCore(input));
}
private async Task ReviewCore(OnboardingMaterialReviewInput input)
{
if (input.ReviewStatus != TaskMaterialReviewStatus.Passed
&& input.ReviewStatus != TaskMaterialReviewStatus.NeedSupplement)
@@ -46,26 +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("请配置入职审核人");
using var configJson = global::System.Text.Json.JsonDocument.Parse(config.ConfigValue);
var reviewerIds = new List<long>();
if (configJson.RootElement.TryGetProperty("all", out var allReviewers))
{
reviewerIds.AddRange(allReviewers.EnumerateArray().Select(item => item.GetInt64()));
}
if (configJson.RootElement.TryGetProperty("company", out var companyReviewers)
&& companyReviewers.TryGetProperty(task.CompanyId.ToString(), out var reviewers))
{
reviewerIds.AddRange(reviewers.EnumerateArray().Select(item => item.GetInt64()));
}
if (!reviewerIds.Contains(UserManager.UserId))
{
throw Oops.Bah("您不是该入职任务的审核人");
}
return _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
}
}
@@ -0,0 +1,229 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板分页查询参数。
/// </summary>
public class OnboardingTemplatePageInput : BasePageInput
{
/// <summary>
/// 模板编码或名称关键词。
/// </summary>
public string Keyword { get; set; }
/// <summary>
/// 所属公司 Id。
/// </summary>
public long? CompanyId { get; set; }
/// <summary>
/// 所属部门 Id。
/// </summary>
public long? DepartmentId { get; set; }
/// <summary>
/// 适用岗位 Id。
/// </summary>
public long? PositionId { get; set; }
/// <summary>
/// 启用状态。
/// </summary>
public string Status { get; set; }
}
/// <summary>
/// 保存入职模板参数。
/// </summary>
public class OnboardingTemplateSaveInput
{
/// <summary>
/// 编辑时传入的入职模板 Id。
/// </summary>
public long? Id { get; set; }
/// <summary>
/// 入职模板业务编码。
/// </summary>
public string TemplateCode { get; set; }
/// <summary>
/// 入职模板名称。
/// </summary>
public string TemplateName { get; set; }
/// <summary>
/// 所属公司 Id。
/// </summary>
public long CompanyId { get; set; }
/// <summary>
/// 所属部门 Id。
/// </summary>
[IdNotNull(ErrorMessage = "所属部门不能为空")]
public long DepartmentId { get; set; }
/// <summary>
/// 适用岗位 Id 列表。
/// </summary>
public List<long> PositionIds { get; set; } = new();
/// <summary>
/// 合同配置列表。
/// </summary>
public List<OnboardingTemplateContractInput> Contracts { get; set; } = new();
/// <summary>
/// 动态补充字段列表。
/// </summary>
public List<OnboardingTemplateFormFieldInput> FormFields { get; set; } = new();
/// <summary>
/// 上传材料列表。
/// </summary>
public List<OnboardingTemplateMaterialInput> Materials { get; set; } = new();
/// <summary>
/// 模板状态。
/// </summary>
public string Status { get; set; } = "enable";
/// <summary>
/// 模板说明。
/// </summary>
public string Description { get; set; }
}
/// <summary>
/// 入职模板合同配置参数。
/// </summary>
public class OnboardingTemplateContractInput
{
/// <summary>
/// 合同模板 Id。
/// </summary>
public long ContractTemplateId { get; set; }
/// <summary>
/// 合同模板版本 Id。
/// </summary>
public long ContractTemplateVersionId { get; set; }
/// <summary>
/// 是否必须查看确认。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 打印份数。
/// </summary>
public int PrintCopies { get; set; } = 1;
/// <summary>
/// 排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 入职模板补充字段参数。
/// </summary>
public class OnboardingTemplateFormFieldInput
{
/// <summary>
/// 动态字段 Id。
/// </summary>
public long FieldId { get; set; }
/// <summary>
/// 是否必填。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 入职模板上传材料参数。
/// </summary>
public class OnboardingTemplateMaterialInput
{
/// <summary>
/// 材料类型 Id。
/// </summary>
public long MaterialTypeId { get; set; }
/// <summary>
/// 是否必传。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 排序码。
/// </summary>
public int? SortCode { get; set; }
}
/// <summary>
/// 入职模板列表输出。
/// </summary>
public class OnboardingTemplateOutput : ObOnboardingTemplate
{
/// <summary>
/// 所属公司名称。
/// </summary>
public string CompanyName { get; set; }
/// <summary>
/// 所属部门名称。
/// </summary>
public string DepartmentName { get; set; }
/// <summary>
/// 适用岗位 Id 列表。
/// </summary>
public List<long> PositionIds { get; set; } = new();
/// <summary>
/// 适用岗位名称列表。
/// </summary>
public List<string> PositionNames { get; set; } = new();
/// <summary>
/// 合同配置数量。
/// </summary>
public int ContractCount { get; set; }
/// <summary>
/// 动态补充字段数量。
/// </summary>
public int FormFieldCount { get; set; }
/// <summary>
/// 上传材料数量。
/// </summary>
public int MaterialCount { get; set; }
}
/// <summary>
/// 入职模板详情输出。
/// </summary>
public class OnboardingTemplateDetailOutput : OnboardingTemplateOutput
{
/// <summary>
/// 合同配置明细。
/// </summary>
public List<ObOnboardingTemplateContract> Contracts { get; set; } = new();
/// <summary>
/// 动态补充字段配置明细。
/// </summary>
public List<ObOnboardingTemplateFormField> FormFields { get; set; } = new();
/// <summary>
/// 上传材料配置明细。
/// </summary>
public List<ObOnboardingTemplateMaterial> Materials { get; set; } = new();
}
@@ -0,0 +1,35 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 入职模板聚合配置服务。
/// </summary>
public interface IOnboardingTemplateService : ITransient
{
/// <summary>
/// 分页查询入职模板。
/// </summary>
Task<SqlSugarPagedList<OnboardingTemplateOutput>> Page(OnboardingTemplatePageInput input);
/// <summary>
/// 查询入职模板详情。
/// </summary>
Task<OnboardingTemplateDetailOutput> Detail(BaseIdInput input);
/// <summary>
/// 新增或编辑入职模板及全部关联配置。
/// </summary>
Task<long> Save(OnboardingTemplateSaveInput input);
/// <summary>
/// 删除未被任务使用的入职模板。
/// </summary>
Task Delete(BaseIdListInput input);
/// <summary>
/// 查询公司、部门和岗位可使用的入职模板。
/// </summary>
Task<List<OnboardingTemplateOutput>> ListEnabled(
long companyId,
long departmentId,
long positionId);
}
@@ -0,0 +1,541 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职模板聚合配置及公司岗位匹配能力。
/// </summary>
public class OnboardingTemplateService : IOnboardingTemplateService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTemplateService(IOnboardingDataPermissionService dataPermission)
{
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <inheritdoc />
public async Task<SqlSugarPagedList<OnboardingTemplateOutput>> Page(OnboardingTemplatePageInput input)
{
var query = _db.Queryable<ObOnboardingTemplate>()
.Where(item => !item.IsDelete)
.WhereIF(!string.IsNullOrWhiteSpace(input.Keyword),
item => item.TemplateCode.Contains(input.Keyword) || item.TemplateName.Contains(input.Keyword))
.WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value)
.WhereIF(input.DepartmentId.HasValue, item => item.DepartmentId == input.DepartmentId.Value)
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), item => item.Status == input.Status);
if (input.PositionId.HasValue)
{
var templateIds = await _db.Queryable<ObOnboardingTemplatePosition>()
.Where(item => item.PositionId == input.PositionId.Value)
.Select(item => item.OnboardingTemplateId)
.ToListAsync();
query = query.Where(item => templateIds.Contains(item.Id));
}
var page = await query
.OrderByDescending(item => item.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize);
var rows = await BuildOutputs(page.List.ToList());
return new SqlSugarPagedList<OnboardingTemplateOutput>
{
PageNum = page.PageNum,
PageSize = page.PageSize,
Total = page.Total,
Pages = page.Pages,
HasPrevPages = page.HasPrevPages,
HasNextPages = page.HasNextPages,
List = rows
};
}
/// <inheritdoc />
public async Task<OnboardingTemplateDetailOutput> Detail(BaseIdInput input)
{
var template = await _db.Queryable<ObOnboardingTemplate>().InSingleAsync(input.Id)
?? throw Oops.Bah("入职模板不存在");
var summary = (await BuildOutputs(new List<ObOnboardingTemplate> { template })).Single();
return new OnboardingTemplateDetailOutput
{
Id = summary.Id,
CreateUserId = summary.CreateUserId,
CreateUser = summary.CreateUser,
TemplateCode = summary.TemplateCode,
TemplateName = summary.TemplateName,
CompanyId = summary.CompanyId,
CompanyName = summary.CompanyName,
DepartmentId = summary.DepartmentId,
DepartmentName = summary.DepartmentName,
Description = summary.Description,
Status = summary.Status,
CreateTime = summary.CreateTime,
UpdateTime = summary.UpdateTime,
PositionIds = summary.PositionIds,
PositionNames = summary.PositionNames,
ContractCount = summary.ContractCount,
FormFieldCount = summary.FormFieldCount,
MaterialCount = summary.MaterialCount,
Contracts = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.OnboardingTemplateId == template.Id)
.OrderBy(item => item.SortCode)
.ToListAsync(),
FormFields = new List<ObOnboardingTemplateFormField>(),
Materials = await _db.Queryable<ObOnboardingTemplateMaterial>()
.Where(item => item.OnboardingTemplateId == template.Id)
.OrderBy(item => item.SortCode)
.ToListAsync()
};
}
/// <inheritdoc />
public async Task<long> Save(OnboardingTemplateSaveInput input)
{
NormalizeAndValidateInput(input);
await ValidateReferences(input);
var 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,
TemplateCode = input.TemplateCode.Trim(),
TemplateName = input.TemplateName.Trim(),
CompanyId = input.CompanyId,
DepartmentId = input.DepartmentId,
Description = input.Description,
Status = input.Status,
IsDelete = false
};
await _db.Ado.BeginTranAsync();
try
{
if (input.Id.HasValue || isRestore)
{
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();
await DeleteRelations(templateId);
}
else
{
await _db.Insertable(entity).ExecuteCommandAsync();
}
await InsertRelations(templateId, input);
await _db.Ado.CommitTranAsync();
return templateId;
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
}
/// <inheritdoc />
public async Task Delete(BaseIdListInput input)
{
if (input.Ids.Count == 0)
return;
var 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)
throw Oops.Bah("入职模板已生成任务,不能删除,可以改为停用");
await _db.Ado.BeginTranAsync();
try
{
foreach (var id in input.Ids)
await DeleteRelations(id);
await _db.Updateable<ObOnboardingTemplate>()
.SetColumns(it => it.IsDelete == true)
.Where(it => input.Ids.Contains(it.Id))
.ExecuteCommandAsync();
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
}
/// <inheritdoc />
public async Task<List<OnboardingTemplateOutput>> ListEnabled(
long companyId,
long departmentId,
long positionId)
{
var templateIds = await _db.Queryable<ObOnboardingTemplatePosition>()
.Where(item => item.PositionId == positionId)
.Select(item => item.OnboardingTemplateId)
.ToListAsync();
var templates = await _db.Queryable<ObOnboardingTemplate>()
.Where(item => item.CompanyId == companyId
&& item.DepartmentId == departmentId
&& item.Status == "enable"
&& templateIds.Contains(item.Id))
.OrderBy(item => item.TemplateName)
.ToListAsync();
if (templates.Count == 0)
return await BuildOutputs(templates);
var onboardingTemplateIds = templates.Select(item => item.Id).ToList();
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => onboardingTemplateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
if (contractConfigs.Count == 0)
return await BuildOutputs(new List<ObOnboardingTemplate>());
var contractTemplateIds = contractConfigs
.Select(item => item.ContractTemplateId)
.Distinct()
.ToList();
var contractVersionIds = contractConfigs
.Select(item => item.ContractTemplateVersionId)
.Distinct()
.ToList();
var contractTemplates = await _db.Queryable<ObContractTemplate>()
.Where(item => contractTemplateIds.Contains(item.Id))
.ToListAsync();
var contractVersions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => contractVersionIds.Contains(item.Id))
.ToListAsync();
var contractPositions = await _db.Queryable<ObContractTemplatePosition>()
.Where(item => contractTemplateIds.Contains(item.ContractTemplateId)
&& item.PositionId == positionId)
.ToListAsync();
var now = DateTime.Now;
var availableTemplates = templates.Where(template =>
{
var configs = contractConfigs
.Where(item => item.OnboardingTemplateId == template.Id)
.ToList();
if (configs.Count == 0)
return false;
return configs.All(config =>
{
var contractTemplate = contractTemplates
.FirstOrDefault(item => item.Id == config.ContractTemplateId);
var version = contractVersions
.FirstOrDefault(item => item.Id == config.ContractTemplateVersionId);
return contractTemplate != null
&& string.Equals(
contractTemplate.Status,
"enable",
StringComparison.OrdinalIgnoreCase)
&& contractTemplate.CompanyId == template.CompanyId
&& contractTemplate.DepartmentId == template.DepartmentId
&& contractPositions.Any(item =>
item.ContractTemplateId == config.ContractTemplateId)
&& version != null
&& version.TemplateId == config.ContractTemplateId
&& ContractTemplateVersionValidity.IsAvailable(version, now);
});
}).ToList();
return await BuildOutputs(availableTemplates);
}
private static void NormalizeAndValidateInput(OnboardingTemplateSaveInput input)
{
input.TemplateCode = OnboardingCodeValidator.Normalize(
input.TemplateCode,
"入职模板编码",
100);
input.TemplateName = input.TemplateName?.Trim();
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>();
input.Contracts ??= new List<OnboardingTemplateContractInput>();
var duplicateContract = input.Contracts
.GroupBy(item => item.ContractTemplateId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateContract != null)
throw Oops.Bah("同一份合同模板在入职模板中只能选择一个版本");
input.FormFields = input.FormFields?
.GroupBy(item => item.FieldId)
.Select(group => group.First())
.ToList() ?? new List<OnboardingTemplateFormFieldInput>();
input.Materials = input.Materials?
.GroupBy(item => item.MaterialTypeId)
.Select(group => group.First())
.ToList() ?? new List<OnboardingTemplateMaterialInput>();
if (string.IsNullOrWhiteSpace(input.TemplateName))
throw Oops.Bah("请填写入职模板名称");
if (input.CompanyId <= 0)
throw Oops.Bah("请选择所属公司");
if (input.DepartmentId <= 0)
throw Oops.Bah("请选择所属部门");
if (input.PositionIds.Count == 0)
throw Oops.Bah("请至少选择一个适用岗位");
if (input.PositionIds.Any(positionId => positionId <= 0))
throw Oops.Bah("适用岗位数据不正确");
if (input.Contracts.Count == 0)
throw Oops.Bah("请至少选择一份合同文件");
if (input.Contracts.Any(item => item.ContractTemplateId <= 0
|| item.ContractTemplateVersionId <= 0))
{
throw Oops.Bah("合同模板或版本数据不正确");
}
if (input.Status != "enable" && input.Status != "disable")
throw Oops.Bah("入职模板状态不正确");
if (input.Contracts.Any(item => item.PrintCopies <= 0))
throw Oops.Bah("合同打印份数必须大于 0");
}
private async Task ValidateReferences(OnboardingTemplateSaveInput input)
{
var companyEnabled = await _db.Queryable<ObCompany>()
.AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable");
if (!companyEnabled)
throw Oops.Bah("所属公司不存在或未启用");
var departmentEnabled = await _db.Queryable<ObDepartment>()
.AnyAsync(item => item.Id == input.DepartmentId
&& item.CompanyId == input.CompanyId
&& item.Status == "enable");
if (!departmentEnabled)
throw Oops.Bah("所属部门不存在、未启用或不属于该公司");
var positionCount = await _db.Queryable<ObPosition>()
.CountAsync(item => input.PositionIds.Contains(item.Id)
&& item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == "enable");
if (positionCount != input.PositionIds.Count)
throw Oops.Bah("存在未启用或不属于该公司、部门的岗位");
var versionIds = input.Contracts.Select(item => item.ContractTemplateVersionId).ToList();
var versions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
if (versions.Count != versionIds.Count
|| input.Contracts.Any(contract => versions.All(version => version.Id != contract.ContractTemplateVersionId
|| version.TemplateId != contract.ContractTemplateId)))
{
throw Oops.Bah("合同模板与版本不匹配");
}
var now = DateTime.Now;
if (versions.Any(version => !ContractTemplateVersionValidity.IsAvailable(version, now)))
throw Oops.Bah("入职模板只能选择当前已生效且尚未失效的启用签署材料版本");
var contractTemplateIds = input.Contracts
.Select(item => item.ContractTemplateId)
.Distinct()
.ToList();
var contractTemplates = await _db.Queryable<ObContractTemplate>()
.Where(item => contractTemplateIds.Contains(item.Id))
.ToListAsync();
if (contractTemplates.Count != contractTemplateIds.Count
|| contractTemplates.Any(item => item.CompanyId != input.CompanyId
|| item.DepartmentId != input.DepartmentId))
{
throw Oops.Bah("存在不属于当前公司、部门的合同模板");
}
var contractPositionRelations = await _db.Queryable<ObContractTemplatePosition>()
.Where(item => contractTemplateIds.Contains(item.ContractTemplateId))
.ToListAsync();
var unsupportedContract = contractTemplateIds.FirstOrDefault(contractTemplateId =>
{
var supportedPositionIds = contractPositionRelations
.Where(item => item.ContractTemplateId == contractTemplateId)
.Select(item => item.PositionId)
.ToHashSet();
return input.PositionIds.Any(positionId => !supportedPositionIds.Contains(positionId));
});
if (unsupportedContract > 0)
{
var contractName = contractTemplates
.First(item => item.Id == unsupportedContract)
.TemplateName;
throw Oops.Bah($"合同模板“{contractName}”不能覆盖当前选择的全部岗位");
}
if (input.Status == "enable")
{
if (contractTemplates.Any(item => !string.Equals(
item.Status,
"enable",
global::System.StringComparison.OrdinalIgnoreCase)))
throw Oops.Bah("启用入职模板前,请先启用全部合同模板");
}
var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList();
if (materialIds.Count > 0)
{
var materialCount = await _db.Queryable<ObMaterialType>()
.CountAsync(item => materialIds.Contains(item.Id) && item.Status == "enable");
if (materialCount != materialIds.Count)
throw Oops.Bah("存在未启用或不存在的上传材料类型");
}
}
private async Task InsertRelations(long templateId, OnboardingTemplateSaveInput input)
{
var positions = input.PositionIds.Select(positionId => new ObOnboardingTemplatePosition
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
OnboardingTemplateId = templateId,
PositionId = positionId
}).ToList();
await _db.Insertable(positions).ExecuteCommandAsync();
var contracts = input.Contracts.Select(item => new ObOnboardingTemplateContract
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
OnboardingTemplateId = templateId,
ContractTemplateId = item.ContractTemplateId,
ContractTemplateVersionId = item.ContractTemplateVersionId,
Required = item.Required,
PrintCopies = item.PrintCopies,
SortCode = item.SortCode
}).ToList();
await _db.Insertable(contracts).ExecuteCommandAsync();
if (input.Materials != null && input.Materials.Count > 0)
{
var materials = input.Materials.Select(item => new ObOnboardingTemplateMaterial
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
OnboardingTemplateId = templateId,
MaterialTypeId = item.MaterialTypeId,
Required = item.Required,
SortCode = item.SortCode
}).ToList();
await _db.Insertable(materials).ExecuteCommandAsync();
}
}
private async Task DeleteRelations(long templateId)
{
await _db.Deleteable<ObOnboardingTemplatePosition>()
.Where(item => item.OnboardingTemplateId == templateId)
.ExecuteCommandAsync();
await _db.Deleteable<ObOnboardingTemplateContract>()
.Where(item => item.OnboardingTemplateId == templateId)
.ExecuteCommandAsync();
await _db.Deleteable<ObOnboardingTemplateMaterial>()
.Where(item => item.OnboardingTemplateId == templateId)
.ExecuteCommandAsync();
}
private async Task<List<OnboardingTemplateOutput>> BuildOutputs(List<ObOnboardingTemplate> templates)
{
if (templates.Count == 0)
return new List<OnboardingTemplateOutput>();
var templateIds = templates.Select(item => item.Id).ToList();
var companyIds = templates.Select(item => item.CompanyId).Distinct().ToList();
var departmentIds = templates
.Where(item => item.DepartmentId.HasValue)
.Select(item => item.DepartmentId.Value)
.Distinct()
.ToList();
var companies = await _db.Queryable<ObCompany>()
.Where(item => companyIds.Contains(item.Id))
.ToListAsync();
var departments = departmentIds.Count == 0
? new List<ObDepartment>()
: await _db.Queryable<ObDepartment>()
.Where(item => departmentIds.Contains(item.Id))
.ToListAsync();
var relations = await _db.Queryable<ObOnboardingTemplatePosition>()
.Where(item => templateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
var positionIds = relations.Select(item => item.PositionId).Distinct().ToList();
var positions = await _db.Queryable<ObPosition>()
.Where(item => positionIds.Contains(item.Id))
.ToListAsync();
var contracts = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => templateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
var materials = await _db.Queryable<ObOnboardingTemplateMaterial>()
.Where(item => templateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
return templates.Select(template =>
{
var templatePositions = relations.Where(item => item.OnboardingTemplateId == template.Id).ToList();
return new OnboardingTemplateOutput
{
Id = template.Id,
CreateUserId = template.CreateUserId,
CreateUser = template.CreateUser,
TemplateCode = template.TemplateCode,
TemplateName = template.TemplateName,
CompanyId = template.CompanyId,
CompanyName = companies.FirstOrDefault(item => item.Id == template.CompanyId)?.CompanyName,
DepartmentId = template.DepartmentId,
DepartmentName = departments
.FirstOrDefault(item => item.Id == template.DepartmentId)?.DepartmentName,
Description = template.Description,
Status = template.Status,
CreateTime = template.CreateTime,
UpdateTime = template.UpdateTime,
PositionIds = templatePositions.Select(item => item.PositionId).ToList(),
PositionNames = templatePositions
.Select(relation => positions.FirstOrDefault(position => position.Id == relation.PositionId)?.PositionName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToList(),
ContractCount = contracts.Count(item => item.OnboardingTemplateId == template.Id),
FormFieldCount = 0,
MaterialCount = materials.Count(item => item.OnboardingTemplateId == template.Id)
};
}).ToList();
}
}
@@ -10,13 +10,23 @@ public class OnboardingPositionPageInput : BasePageInput
/// </summary>
public long? CompanyId { get; set; }
/// <summary>
/// 入职业务部门 Id。
/// </summary>
public long? DepartmentId { get; set; }
/// <summary>
/// 岗位编码。
/// </summary>
public virtual string PositionCode { get; set; }
/// <summary>
/// 岗位名称。
/// </summary>
public virtual string PositionName { get; set; }
/// <summary>
/// 启用状态或状态筛选条件。
/// 启用状态。
/// </summary>
public string Status { get; set; }
}
@@ -26,17 +36,37 @@ public class OnboardingPositionPageInput : BasePageInput
/// </summary>
public class OnboardingPositionAddInput : ObPosition
{
/// <summary>
/// 所属公司 Id。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "所属公司不能为空")]
public new long CompanyId { get; set; }
/// <summary>
/// 所属部门 Id。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "所属部门不能为空")]
public new long? DepartmentId { get; set; }
/// <summary>
/// 岗位业务编码。
/// </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>
@@ -49,4 +79,4 @@ public class OnboardingPositionEditInput : OnboardingPositionAddInput
/// </summary>
[IdNotNull(ErrorMessage = "岗位Id不能为空")]
public override long Id { get; set; }
}
}
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application;
namespace SimpleAdmin.Application;
/// <summary>
/// 定义入职业务岗位业务能力。
@@ -9,24 +9,34 @@ public interface IOnboardingPositionService : ITransient
/// 分页查询入职业务岗位。
/// </summary>
Task<SqlSugarPagedList<ObPosition>> Page(OnboardingPositionPageInput input);
/// <summary>
/// 查询指定公司下全部已启用岗位。
/// </summary>
Task<List<ObPosition>> ListEnabled(long companyId);
/// <summary>
/// 查询指定部门下全部已启用岗位。
/// </summary>
Task<List<ObPosition>> ListEnabledByDepartment(long departmentId);
/// <summary>
/// 查询岗位详情。
/// </summary>
Task<ObPosition> Detail(BaseIdInput input);
/// <summary>
/// 新增入职业务岗位。
/// </summary>
Task Add(OnboardingPositionAddInput input);
/// <summary>
/// 编辑入职业务岗位。
/// </summary>
Task Edit(OnboardingPositionEditInput input);
/// <summary>
/// 删除未被任务或正式员工引用的岗位。
/// </summary>
Task Delete(BaseIdListInput input);
}
}
@@ -5,20 +5,58 @@ namespace SimpleAdmin.Application;
/// </summary>
public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPositionService
{
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingPositionService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary>
/// 分页查询入职业务岗位。
/// </summary>
public async Task<SqlSugarPagedList<ObPosition>> Page(OnboardingPositionPageInput input)
{
return await Context.Queryable<ObPosition>()
var list = await Context.Queryable<ObPosition>()
.WhereIF(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value)
.WhereIF(
!string.IsNullOrWhiteSpace(input.PositionName),
it => it.PositionName.Contains(input.PositionName) || it.PositionCode.Contains(input.PositionName))
.WhereIF(input.DepartmentId.HasValue, it => it.DepartmentId == input.DepartmentId.Value)
.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);
if (list.List != null && list.List.Any())
{
var companyIds = list.List.Select(p => p.CompanyId).Distinct().ToList();
var departmentIds = list.List.Where(p => p.DepartmentId.HasValue).Select(p => p.DepartmentId.Value).Distinct().ToList();
var companies = await Context.Queryable<ObCompany>()
.Where(c => companyIds.Contains(c.Id))
.ToListAsync();
var departments = departmentIds.Count == 0
? new List<ObDepartment>()
: await Context.Queryable<ObDepartment>().Where(d => departmentIds.Contains(d.Id)).ToListAsync();
var companyMap = companies.ToDictionary(c => c.Id, c => c.CompanyName);
var departmentMap = departments.ToDictionary(d => d.Id, d => d.DepartmentName);
foreach (var item in list.List)
{
if (companyMap.TryGetValue(item.CompanyId, out var cName))
{
item.CompanyName = cName;
}
if (item.DepartmentId.HasValue && departmentMap.TryGetValue(item.DepartmentId.Value, out var dName))
{
item.DepartmentName = dName;
}
}
}
return list;
}
/// <summary>
@@ -32,6 +70,17 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
.ToListAsync();
}
/// <summary>
/// 查询指定部门下全部已启用岗位。
/// </summary>
public Task<List<ObPosition>> ListEnabledByDepartment(long departmentId)
{
return Context.Queryable<ObPosition>()
.Where(it => it.DepartmentId == departmentId && it.Status == "enable")
.OrderBy(it => it.PositionName)
.ToListAsync();
}
/// <summary>
/// 查询岗位详情。
/// </summary>
@@ -45,7 +94,12 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
/// </summary>
public async Task Add(OnboardingPositionAddInput input)
{
NormalizeInput(input);
if (!input.DepartmentId.HasValue)
throw Oops.Bah("请选择所属部门");
await EnsureCompanyExists(input.CompanyId);
await EnsureDepartmentExists(input.CompanyId, input.DepartmentId.Value);
await EnsureCodeUnique(input.CompanyId, input.PositionCode, 0);
await InsertAsync(input.Adapt<ObPosition>());
}
@@ -55,8 +109,18 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
/// </summary>
public async Task Edit(OnboardingPositionEditInput input)
{
await Detail(new BaseIdInput { Id = input.Id });
NormalizeInput(input);
if (!input.DepartmentId.HasValue)
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);
await EnsureCodeUnique(input.CompanyId, input.PositionCode, input.Id);
await UpdateAsync(input.Adapt<ObPosition>());
}
@@ -69,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)
@@ -79,14 +153,74 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
if (hasEmployees)
throw Oops.Bah("岗位已有正式员工,不能删除");
var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplatePosition>()
.AnyAsync(item => input.Ids.Contains(item.PositionId));
if (hasOnboardingTemplates)
throw Oops.Bah("岗位已被入职模板引用,不能删除");
var hasContractTemplates = await Context.Queryable<ObContractTemplatePosition>()
.AnyAsync(item => input.Ids.Contains(item.PositionId));
if (hasContractTemplates)
throw Oops.Bah("岗位已被合同模板引用,不能删除");
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
}
private async Task EnsureCompanyExists(long companyId)
{
var exists = await Context.Queryable<ObCompany>().AnyAsync(it => it.Id == companyId);
var exists = await Context.Queryable<ObCompany>()
.AnyAsync(it => it.Id == companyId && it.Status == "enable");
if (!exists)
throw Oops.Bah("所属公司不存在或已删除");
throw Oops.Bah("所属公司不存在、已删除或未启用");
}
private async Task EnsureDepartmentExists(long companyId, long departmentId)
{
var exists = await Context.Queryable<ObDepartment>()
.AnyAsync(it => it.Id == departmentId
&& it.CompanyId == companyId
&& it.Status == "enable");
if (!exists)
throw Oops.Bah("所属部门不存在、未启用或不属于该公司");
}
/// <summary>
/// 标准化岗位输入并拒绝非法状态。
/// </summary>
private static void NormalizeInput(OnboardingPositionAddInput input)
{
input.PositionCode = OnboardingCodeValidator.Normalize(input.PositionCode, "岗位编码", 50);
input.PositionName = input.PositionName?.Trim();
if (string.IsNullOrWhiteSpace(input.PositionName))
{
throw Oops.Bah("请填写岗位名称");
}
if (input.Status != "enable" && input.Status != "disable")
throw Oops.Bah("岗位状态不正确");
}
/// <summary>
/// 已被业务配置或历史数据引用的岗位不允许直接改变公司、部门归属。
/// </summary>
private async Task EnsureScopeCanChange(
ObPosition position,
long companyId,
long departmentId)
{
if (position.CompanyId == companyId && position.DepartmentId == departmentId)
return;
var hasTask = await Context.Queryable<ObOnboardingTask>()
.AnyAsync(item => item.PositionId == position.Id);
var hasEmployee = await Context.Queryable<ObEmployee>()
.AnyAsync(item => item.PositionId == position.Id);
var hasOnboardingTemplate = await Context.Queryable<ObOnboardingTemplatePosition>()
.AnyAsync(item => item.PositionId == position.Id);
var hasContractTemplate = await Context.Queryable<ObContractTemplatePosition>()
.AnyAsync(item => item.PositionId == position.Id);
if (hasTask || hasEmployee || hasOnboardingTemplate || hasContractTemplate)
throw Oops.Bah("岗位已被模板、任务或员工引用,不能直接变更所属公司、部门");
}
private async Task EnsureCodeUnique(long companyId, string code, long id)
@@ -24,4 +24,9 @@ public class OnboardingMaterialUploadValidationInput
/// 待上传文件大小,单位为字节。
/// </summary>
public long FileLength { get; set; }
/// <summary>
/// 需要重新上传并替换的任务材料记录 Id;为空时按新增数量校验。
/// </summary>
public long? TaskMaterialId { get; set; }
}
@@ -1,38 +1,5 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 单个动态字段填写值。
/// </summary>
public class OnboardingDynamicValueInput
{
/// <summary>
/// 动态字段 Id。
/// </summary>
public long FieldId { get; set; }
/// <summary>
/// 动态字段填写值。
/// </summary>
public string FieldValue { get; set; }
}
/// <summary>
/// 批量保存员工动态字段参数。
/// </summary>
public class OnboardingSaveDynamicValuesInput
{
/// <summary>
/// 员工填写链接中的明文 Token。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string Token { get; set; }
/// <summary>
/// 待保存的字段或变量值集合。
/// </summary>
public List<OnboardingDynamicValueInput> Values { get; set; } = new();
}
/// <summary>
/// 保存员工已上传材料参数。
/// </summary>
@@ -53,6 +20,28 @@ public class OnboardingSaveMaterialInput
/// 系统文件 Id。
/// </summary>
public long FileId { get; set; }
/// <summary>
/// 需要重新上传并替换的任务材料记录 Id;为空时新增材料。
/// </summary>
public long? TaskMaterialId { get; set; }
}
/// <summary>
/// 删除员工在当前可编辑任务中上传的一条材料。
/// </summary>
public class OnboardingDeleteMaterialInput
{
/// <summary>
/// 员工填写链接中的明文 Token。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string Token { get; set; }
/// <summary>
/// 任务材料记录 Id。
/// </summary>
public long TaskMaterialId { get; set; }
}
/// <summary>
@@ -82,4 +71,27 @@ public class OnboardingSubmitInput
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string Token { get; set; }
/// <summary>
/// 员工是否已在最终提交前逐项确认实名认证信息。
/// </summary>
public bool IdentityInformationConfirmed { get; set; }
/// <summary>
/// 员工最终确认的真实姓名。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string ConfirmedEmployeeName { get; set; }
/// <summary>
/// 员工最终确认的身份证号码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string ConfirmedIdCardNumber { get; set; }
/// <summary>
/// 员工最终确认的手机号码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string ConfirmedPhone { get; set; }
}
@@ -5,22 +5,26 @@ namespace SimpleAdmin.Application;
/// </summary>
public interface IOnboardingPublicSubmissionService : ITransient
{
/// <summary>
/// 保存候选人填写的动态字段值。
/// </summary>
Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input);
/// <summary>
/// 关联候选人已经上传的系统文件与材料类型。
/// </summary>
Task SaveMaterial(OnboardingSaveMaterialInput input);
/// <summary>
/// 删除可编辑任务中的一条已上传材料关联。
/// </summary>
Task DeleteMaterial(OnboardingDeleteMaterialInput input);
/// <summary>
/// 记录候选人已阅读并确认指定任务合同。
/// </summary>
Task ConfirmContract(OnboardingConfirmContractInput input);
/// <summary>
/// 校验全部必填资料后提交入职任务进入审核。
/// </summary>
Task Submit(OnboardingSubmitInput input);
/// <summary>
/// 在保存文件前校验材料适用范围、扩展名、大小和数量。
/// </summary>
@@ -1,92 +1,84 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 实现员工端入职资料提交业务能力。
/// 实现员工端动态资料、上传材料、合同确认和最终提交能力。
/// </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)
IOnboardingContractSnapshotService snapshotService,
IOnboardingIdentityValidationService identityValidationService,
IOnboardingContractVariableValidationService contractVariableValidationService)
{
_db = DbContext.DB;
_snapshotService = snapshotService;
_identityValidationService = identityValidationService;
_contractVariableValidationService = contractVariableValidationService;
}
/// <summary>
/// 保存候选人填写的动态字段值。
/// </summary>
public async Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input)
{
var task = await GetEditableTask(input.Token);
var requirements = await GetEffectiveFormRequirements(task);
var fieldIds = input.Values.Select(item => item.FieldId).Distinct().ToList();
var hasInapplicableField = fieldIds.Any(id => requirements.All(requirement => requirement.FieldId != id));
if (hasInapplicableField)
throw Oops.Bah("填写字段不适用于当前公司或岗位");
var fields = await _db.Queryable<ObFormField>()
.Where(field => fieldIds.Contains(field.Id) && field.Status == "enable")
.ToListAsync();
foreach (var value in input.Values)
{
var field = fields.FirstOrDefault(item => item.Id == value.FieldId)
?? throw Oops.Bah("填写字段不存在或已停用");
var old = await _db.Queryable<ObTaskFormValue>()
.FirstAsync(item => item.TaskId == task.Id && item.FieldId == field.Id);
var row = new ObTaskFormValue
{
Id = old?.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = task.Id,
FieldId = field.Id,
FieldCode = field.FieldCode,
FieldName = field.FieldName,
FieldValue = value.FieldValue,
SubmitRound = task.SubmitCount + 1
};
if (old == null)
{
await _db.Insertable(row).ExecuteCommandAsync();
}
else
{
await _db.Updateable(row).ExecuteCommandAsync();
}
}
}
/// <summary>
/// 关联候选人已经上传的系统文件与材料类型。
/// 关联已上传 OSS 文件与任务材料要求。
/// </summary>
public async Task SaveMaterial(OnboardingSaveMaterialInput input)
{
var task = await GetEditableTask(input.Token);
var requirements = await GetEffectiveMaterialRequirements(task);
var materialIsApplicable = requirements.Any(requirement => requirement.MaterialTypeId == input.MaterialTypeId);
if (!materialIsApplicable)
throw Oops.Bah("资料类型不适用于当前公司或岗位");
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveMaterialCore(input), transactional: true);
}
var material = await _db.Queryable<ObMaterialType>()
.FirstAsync(item => item.Id == input.MaterialTypeId && item.Status == "enable")
?? throw Oops.Bah("资料类型不存在或已停用");
private async Task SaveMaterialCore(OnboardingSaveMaterialInput input)
{
var task = await GetEditableTask(input.Token);
var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
.FirstAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
?? throw Oops.Bah("材料类型不属于当前入职模板");
var fileExists = await _db.Queryable<SysFile>().AnyAsync(item => item.Id == input.FileId);
if (!fileExists)
throw Oops.Bah("上传文件不存在");
var materialCount = await _db.Queryable<ObTaskMaterial>()
.CountAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId);
if (materialCount >= material.MaxFileCount)
throw Oops.Bah("该资料已达到最大上传数量");
var existingMaterials = await _db.Queryable<ObTaskMaterial>()
.Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
.ToListAsync();
if (input.TaskMaterialId.HasValue)
{
var replacement = existingMaterials.FirstOrDefault(
item => item.Id == input.TaskMaterialId.Value)
?? throw Oops.Bah("需要重新上传的材料不存在");
replacement.FileId = input.FileId;
replacement.SubmitRound = task.SubmitCount + 1;
replacement.UploadedAt = DateTime.Now;
replacement.ReviewStatus = TaskMaterialReviewStatus.Pending;
replacement.ReviewRemark = null;
await _db.Updateable(replacement).ExecuteCommandAsync();
return;
}
var supplementMaterial = existingMaterials.FirstOrDefault(
item => item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement);
if (supplementMaterial != null)
{
supplementMaterial.FileId = input.FileId;
supplementMaterial.SubmitRound = task.SubmitCount + 1;
supplementMaterial.UploadedAt = DateTime.Now;
supplementMaterial.ReviewStatus = TaskMaterialReviewStatus.Pending;
supplementMaterial.ReviewRemark = null;
await _db.Updateable(supplementMaterial).ExecuteCommandAsync();
return;
}
if (existingMaterials.Count >= requirement.MaxFileCount)
throw Oops.Bah("该材料已达到最大上传数量");
await _db.Insertable(new ObTaskMaterial
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = task.Id,
MaterialTypeId = material.Id,
MaterialName = material.MaterialName,
MaterialTypeId = requirement.MaterialTypeId,
MaterialName = requirement.MaterialName,
FileId = input.FileId,
SubmitRound = task.SubmitCount + 1,
UploadedAt = DateTime.Now
@@ -94,52 +86,82 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
}
/// <summary>
/// 记录候选人已阅读并确认指定任务合同。
/// 任务仍可编辑时删除一条已上传材料。
/// </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>()
.FirstAsync(item =>
item.Id == input.TaskMaterialId
&& item.TaskId == task.Id);
if (material == null)
throw Oops.Bah("上传材料不存在");
await _db.Deleteable<ObTaskMaterial>()
.In(material.Id)
.ExecuteCommandAsync();
}
/// <summary>
/// 记录候选人已经查看并确认动态生成的合同。
/// </summary>
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>()
.FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == task.Id)
?? throw Oops.Bah("合同不存在");
if (!contract.GeneratedDocxFileId.HasValue || !contract.ReadAt.HasValue)
throw Oops.Bah("请先查看已经生成的合同内容");
contract.ReadAt ??= DateTime.Now;
contract.ConfirmedAt = DateTime.Now;
contract.ContractStatus = TaskContractStatus.Confirmed;
await _db.Updateable(contract).ExecuteCommandAsync();
}
/// <summary>
/// 校验全部必填资料后提交入职任务进入审核。
/// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
/// </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);
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,
info.EmployeeName,
info.IdCardNumber);
await _snapshotService.Build(task.Id, info);
// 提交时统一完成动态字段、材料、合同变量和合同确认校验,保存阶段只负责暂存。
var requiredFields = (await GetEffectiveFormRequirements(task))
.Where(item => item.Required)
.Select(item => item.FieldId)
.ToList();
var values = await _db.Queryable<ObTaskFormValue>()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
var hasMissingRequiredField = requiredFields.Any(
id => values.All(item => item.FieldId != id || string.IsNullOrWhiteSpace(item.FieldValue)));
if (hasMissingRequiredField)
throw Oops.Bah("仍有必填动态信息未填写");
var requiredMaterials = (await GetEffectiveMaterialRequirements(task))
var requiredMaterials = (await GetMaterialRequirements(task.Id))
.Where(item => item.Required)
.Select(item => item.MaterialTypeId)
.ToList();
@@ -149,20 +171,30 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
var hasMissingRequiredMaterial = requiredMaterials.Any(
id => materials.All(item => item.MaterialTypeId != id));
if (hasMissingRequiredMaterial)
throw Oops.Bah("仍有必传资料未上传");
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("请确认阅读全部必签合同");
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
{
@@ -170,98 +202,109 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
TaskId = task.Id,
AuditRound = task.SubmitCount,
AuditAction = OnboardingAuditAction.Submit,
AuditOpinion = "员工提交入职资料",
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new { info, values, materials }),
AuditedAt = DateTime.Now
AuditOpinion = "员工提交入职资料,并确认姓名、身份证号码和手机号码用于实名认证",
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new
{
info,
materials,
identityConfirmation = new
{
confirmedAt = submittedAt,
summaryHash = identityConfirmationHash
}
}),
AuditedAt = submittedAt
}).ExecuteCommandAsync();
}
/// <summary>
/// 在保存文件前校验材料适用范围、扩展名、大小和数量。
/// 上传 OSS 前按照任务材料快照校验扩展名、文件大小和数量。
/// </summary>
public async Task ValidateMaterialUpload(OnboardingMaterialUploadValidationInput input)
{
var task = await GetEditableTask(input.Token);
var requirements = await GetEffectiveMaterialRequirements(task);
var materialIsApplicable = requirements.Any(requirement => requirement.MaterialTypeId == input.MaterialTypeId);
if (!materialIsApplicable)
throw Oops.Bah("资料类型不适用于当前公司或岗位");
var material = await _db.Queryable<ObMaterialType>()
.FirstAsync(item => item.Id == input.MaterialTypeId && item.Status == "enable")
?? throw Oops.Bah("资料类型不存在或已停用");
var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
.FirstAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
?? throw Oops.Bah("材料类型不属于当前入职模板");
var suffix = Path.GetExtension(input.FileName).TrimStart('.').ToLowerInvariant();
var allowedExtensions = (material.AllowedExtensions ?? string.Empty)
var allowedExtensions = (requirement.AllowedExtensions ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(item => item.TrimStart('.').ToLowerInvariant());
var suffixNotAllowed = allowedExtensions.Any() && !allowedExtensions.Contains(suffix);
if (suffixNotAllowed)
throw Oops.Bah("该资料不允许上传此文件类型");
throw Oops.Bah("该材料不允许上传此文件类型");
var fileTooLarge = material.MaxFileSizeMb.HasValue
&& input.FileLength > material.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 materialCount = await _db.Queryable<ObTaskMaterial>()
.CountAsync(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId);
if (materialCount >= material.MaxFileCount)
throw Oops.Bah("该资料已达到最大上传数量");
var materials = await _db.Queryable<ObTaskMaterial>()
.Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
.ToListAsync();
if (input.TaskMaterialId.HasValue)
{
var replacementExists = materials.Any(
item => item.Id == input.TaskMaterialId.Value);
if (!replacementExists)
throw Oops.Bah("需要重新上传的材料不存在");
return;
}
var canReplaceSupplement = materials.Any(
item => item.ReviewStatus == TaskMaterialReviewStatus.NeedSupplement);
if (materials.Count >= requirement.MaxFileCount && !canReplaceSupplement)
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("手机号码在确认后发生了变化,请返回基本信息重新核对并确认");
}
private async Task<List<ObFormRequirement>> GetEffectiveFormRequirements(ObOnboardingTask task)
/// <summary>
/// 审核快照只记录实名信息摘要,完整原始值仍以本轮员工信息快照为准。
/// </summary>
private static string BuildIdentityConfirmationHash(ObTaskEmployeeInfo info)
{
var requirements = await _db.Queryable<ObFormRequirement>()
.Where(item => item.CompanyId == task.CompanyId
&& (item.PositionId == null || item.PositionId == task.PositionId))
.ToListAsync();
return requirements
.GroupBy(item => item.FieldId)
.Select(group => group.FirstOrDefault(item => item.PositionId == task.PositionId) ?? group.First())
.ToList();
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 async Task<List<ObMaterialRequirement>> GetEffectiveMaterialRequirements(ObOnboardingTask task)
private Task<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId)
{
var requirements = await _db.Queryable<ObMaterialRequirement>()
.Where(item => item.CompanyId == task.CompanyId
&& (item.PositionId == null || item.PositionId == task.PositionId))
return _db.Queryable<ObTaskMaterialRequirement>()
.Where(item => item.TaskId == taskId)
.OrderBy(item => item.SortCode)
.ToListAsync();
return requirements
.GroupBy(item => item.MaterialTypeId)
.Select(group => group.FirstOrDefault(item => item.PositionId == task.PositionId) ?? group.First())
.ToList();
}
private async Task<ObOnboardingTask> GetEditableTask(string token)
{
if (string.IsNullOrWhiteSpace(token))
throw Oops.Bah("入职链接 Token 不能为空");
var hash = Convert.ToHexString(
global::System.Security.Cryptography.SHA256.HashData(
global::System.Text.Encoding.UTF8.GetBytes(token)));
@@ -14,7 +14,19 @@ public interface IOnboardingPublicTaskService : ITransient
/// </summary>
Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input);
/// <summary>
/// 校验任务归属后返回合同模板源文件 Id。
/// 校验合同变量并提前生成候选人的合同 DOCX。
/// </summary>
Task<OnboardingPublicContractPrepareOutput> PrepareContract(
OnboardingPublicContractSourceInput input);
/// <summary>
/// 校验任务归属后返回已经准备完成的合同文件 Id。
/// </summary>
Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input);
/// <summary>
/// 校验 Token 和任务归属后返回已上传材料的文件信息。
/// </summary>
Task<OnboardingPublicTaskMaterialOutput> GetMaterialSource(
OnboardingPublicMaterialSourceInput input);
}
@@ -0,0 +1,68 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 生成并读取入职登记表内置员工字段的任务级必填快照。
/// </summary>
internal static class OnboardingEmployeeFieldRequirementSnapshot
{
private const string EmployeeVariablePrefix = "employee.";
/// <summary>
/// 根据当前内置员工变量配置创建一份任务快照。
/// </summary>
public static async Task<List<ObTaskFormRequirement>> BuildFromCurrentDefinitions(
SqlSugarScope db,
long taskId)
{
var definitions = await db.Queryable<ObVariableDefinition>()
.Where(item => item.IsBuiltIn
&& item.VariableCategory == ContractVariableType.Employee)
.OrderBy(item => item.SortCode)
.OrderBy(item => item.Id)
.ToListAsync();
return definitions.Select(definition => new ObTaskFormRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
FieldId = definition.Id,
FieldCode = definition.VariableCode,
FieldName = definition.VariableName,
DataType = definition.DataType,
Required = definition.Required,
SortCode = definition.SortCode
}).ToList();
}
/// <summary>
/// 读取任务冻结配置;历史任务没有员工字段快照时使用当前配置兼容。
/// </summary>
public static async Task<List<ObTaskFormRequirement>> GetForTask(
SqlSugarScope db,
long taskId)
{
var snapshots = await db.Queryable<ObTaskFormRequirement>()
.Where(item => item.TaskId == taskId)
.OrderBy(item => item.SortCode)
.ToListAsync();
if (snapshots.Any(IsEmployeeField))
return snapshots;
var compatibilitySnapshots = await BuildFromCurrentDefinitions(db, taskId);
if (snapshots.Count == 0)
return compatibilitySnapshots;
snapshots.AddRange(compatibilitySnapshots);
return snapshots.OrderBy(item => item.SortCode).ToList();
}
/// <summary>
/// 判断快照是否对应入职登记表内置员工字段。
/// </summary>
public static bool IsEmployeeField(ObTaskFormRequirement requirement)
{
return requirement?.FieldCode?.StartsWith(
EmployeeVariablePrefix,
StringComparison.OrdinalIgnoreCase) == true;
}
}
@@ -0,0 +1,350 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 统一校验并规范化员工端固定入职信息。
/// </summary>
internal static class OnboardingEmployeeInfoValidator
{
private static readonly int[] IdCardWeights =
{
7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2
};
private static readonly char[] IdCardCheckCodes =
{
'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'
};
/// <summary>
/// 规范化文本,并按照任务冻结配置校验固定登记表必填项及字段格式。
/// </summary>
public static void ValidateAndNormalize(
ObTaskEmployeeInfo input,
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements = null)
{
input.EmployeeName = input.EmployeeName?.Trim();
input.Phone = input.Phone?.Trim();
input.Email = input.Email?.Trim();
input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant();
input.BankCardNumber = global::System.Text.RegularExpressions.Regex.Replace(
input.BankCardNumber ?? string.Empty,
@"[\s-]",
string.Empty);
input.BankName = input.BankName?.Trim();
input.BankAccountName = input.EmployeeName;
ValidateRequiredFields(input, fieldRequirements);
// 电子签约实名认证始终依赖以下三项,不能通过后台配置关闭。
if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100)
throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符");
if (!string.IsNullOrWhiteSpace(input.Gender)
&& input.Gender != "男"
&& input.Gender != "女")
{
throw Oops.Bah("请选择正确的性别");
}
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
input.Phone ?? string.Empty,
@"^1[3-9]\d{9}$"))
{
throw Oops.Bah("请输入正确的 11 位手机号码");
}
if (!TryValidateIdCard(input.IdCardNumber, out var idCardBirthday, out var idCardError))
throw Oops.Bah(idCardError);
ValidateBirthday(input.Birthday, idCardBirthday);
ValidateOptionalEmail(input.Email);
ValidateOptionalNumber(input.Height, "身高", 100M, 250M, "厘米");
ValidateOptionalNumber(input.Weight, "体重", 30M, 300M, "公斤");
if (!string.IsNullOrWhiteSpace(input.BankCardNumber))
ValidateBankCard(input.BankCardNumber);
if (!string.IsNullOrWhiteSpace(input.BankName) && input.BankName.Length > 100)
throw Oops.Bah("开户银行名称不能超过 100 个字符");
ValidateOptionalContactPhone(input.EmergencyPhone);
ValidateEducationHistory(input.EducationHistoryJson);
ValidateWorkHistory(input.WorkHistoryJson);
ValidateFamilyMembers(input.FamilyMembersJson);
ValidateJson(input.CustomVariablesJson, global::System.Text.Json.JsonValueKind.Object, "员工补充变量");
}
/// <summary>
/// 按任务快照校验后台配置为必填的内置员工字段。
/// </summary>
private static void ValidateRequiredFields(
ObTaskEmployeeInfo input,
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements)
{
if (fieldRequirements == null || fieldRequirements.Count == 0)
return;
foreach (var requirement in fieldRequirements.Where(item =>
item.Required
&& OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField(item)))
{
var separatorIndex = requirement.FieldCode.LastIndexOf('.');
var propertyName = separatorIndex >= 0
? requirement.FieldCode[(separatorIndex + 1)..]
: requirement.FieldCode;
var property = typeof(ObTaskEmployeeInfo).GetProperty(
propertyName,
global::System.Reflection.BindingFlags.Instance
| global::System.Reflection.BindingFlags.Public
| global::System.Reflection.BindingFlags.IgnoreCase);
if (property == null)
{
throw Oops.Bah(
$"内置员工变量“{requirement.FieldCode}”没有对应的入职登记表字段,请联系管理员");
}
var value = property.GetValue(input)?.ToString();
if (string.IsNullOrWhiteSpace(value))
throw Oops.Bah($"请填写入职登记表“{requirement.FieldName}”");
}
}
private static bool TryValidateIdCard(
string idCardNumber,
out DateTime birthday,
out string errorMessage)
{
birthday = default;
errorMessage = null;
var structureIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
idCardNumber ?? string.Empty,
"^\\d{17}[0-9X]$");
if (!structureIsValid)
{
errorMessage = "请输入正确的 18 位身份证号码";
return false;
}
var birthdayText = idCardNumber.Substring(6, 8);
var birthdayIsValid = DateTime.TryParseExact(
birthdayText,
"yyyyMMdd",
global::System.Globalization.CultureInfo.InvariantCulture,
global::System.Globalization.DateTimeStyles.None,
out birthday);
if (!birthdayIsValid || birthday.Date > DateTime.Today)
{
errorMessage = "身份证号码中的出生日期不正确";
return false;
}
var total = 0;
for (var index = 0; index < IdCardWeights.Length; index++)
{
total += (idCardNumber[index] - '0') * IdCardWeights[index];
}
if (IdCardCheckCodes[total % 11] == idCardNumber[17])
return true;
errorMessage = "身份证号码校验位不正确";
return false;
}
private static void ValidateBirthday(string birthdayText, DateTime? idCardBirthday)
{
if (string.IsNullOrWhiteSpace(birthdayText))
return;
var birthdayIsValid = DateTime.TryParseExact(
birthdayText.Trim(),
"yyyy-MM-dd",
global::System.Globalization.CultureInfo.InvariantCulture,
global::System.Globalization.DateTimeStyles.None,
out var birthday);
if (!birthdayIsValid || birthday.Date > DateTime.Today)
throw Oops.Bah("请输入正确的出生日期");
if (idCardBirthday.HasValue && birthday.Date != idCardBirthday.Value.Date)
throw Oops.Bah("出生日期与身份证号码不一致");
}
private static void ValidateOptionalEmail(string email)
{
if (string.IsNullOrWhiteSpace(email))
return;
var emailIsValid = email.Length <= 100
&& global::System.Net.Mail.MailAddress.TryCreate(email, out _);
if (!emailIsValid)
throw Oops.Bah("请输入正确的电子邮箱");
}
private static void ValidateOptionalNumber(
string value,
string fieldName,
decimal min,
decimal max,
string unit)
{
if (string.IsNullOrWhiteSpace(value))
return;
var numberIsValid = decimal.TryParse(
value.Trim(),
global::System.Globalization.NumberStyles.Number,
global::System.Globalization.CultureInfo.InvariantCulture,
out var number);
if (!numberIsValid || number < min || number > max)
{
throw Oops.Bah($"{fieldName}应为 {min:0} 至 {max:0} {unit}之间的数字");
}
}
private static void ValidateBankCard(string bankCardNumber)
{
var bankCardIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
bankCardNumber ?? string.Empty,
"^\\d{12,19}$");
if (!bankCardIsValid)
throw Oops.Bah("请输入 12 至 19 位银行卡号");
}
private static void ValidateOptionalContactPhone(string phone)
{
if (string.IsNullOrWhiteSpace(phone))
return;
var contactPhoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
phone.Trim(),
"^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$");
if (!contactPhoneIsValid)
throw Oops.Bah("请输入正确的紧急联系人手机号码或座机号码");
}
private static void ValidateEducationHistory(string json)
{
ValidateHistoryRows(json, "教育经历", (row, rowNumber) =>
{
GetRequiredRowValue(row, "timeRange", rowNumber, "教育经历", "时间范围");
GetRequiredRowValue(row, "location", rowNumber, "教育经历", "学校或机构");
GetRequiredRowValue(row, "content", rowNumber, "教育经历", "专业或学习内容");
GetRequiredRowValue(row, "result", rowNumber, "教育经历", "结果或学位");
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条教育学习经历");
}
private static void ValidateWorkHistory(string json)
{
ValidateHistoryRows(json, "工作经历", (row, rowNumber) =>
{
GetRequiredRowValue(row, "timeRange", rowNumber, "工作经历", "起止时间");
GetRequiredRowValue(row, "companyName", rowNumber, "工作经历", "工作单位");
GetRequiredRowValue(row, "position", rowNumber, "工作经历", "担任职务");
GetRequiredRowValue(row, "leaveReason", rowNumber, "工作经历", "离职原因");
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条工作经历(应届生可填写兼职、在校或实习经历)");
}
private static void ValidateFamilyMembers(string json)
{
ValidateHistoryRows(json, "家庭成员", (row, rowNumber) =>
{
GetRequiredRowValue(row, "name", rowNumber, "家庭成员", "姓名");
GetRequiredRowValue(row, "relationship", rowNumber, "家庭成员", "关系");
var ageText = GetRequiredRowValue(row, "age", rowNumber, "家庭成员", "年龄");
var phone = GetRequiredRowValue(row, "phone", rowNumber, "家庭成员", "联系电话");
var ageIsValid = int.TryParse(ageText, out var age) && age >= 0 && age <= 120;
if (!ageIsValid)
throw Oops.Bah($"第 {rowNumber} 条家庭成员的年龄应为 0 至 120 之间的整数");
var phoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
phone,
"^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$");
if (!phoneIsValid)
throw Oops.Bah($"第 {rowNumber} 条家庭成员的联系电话格式不正确");
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一位家庭成员信息");
}
private static void ValidateHistoryRows(
string json,
string fieldName,
Action<global::System.Text.Json.JsonElement, int> validateRow,
bool requireAtLeastOne = false,
string emptyErrorMessage = null)
{
if (string.IsNullOrWhiteSpace(json))
{
if (requireAtLeastOne)
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
return;
}
try
{
using var document = global::System.Text.Json.JsonDocument.Parse(json);
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Array)
throw new global::System.Text.Json.JsonException();
var rowNumber = 0;
foreach (var row in document.RootElement.EnumerateArray())
{
rowNumber++;
if (row.ValueKind != global::System.Text.Json.JsonValueKind.Object)
throw Oops.Bah($"第 {rowNumber} 条{fieldName}格式不正确,请删除后重新添加");
validateRow(row, rowNumber);
}
if (requireAtLeastOne && rowNumber == 0)
{
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
}
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写");
}
}
private static string GetRequiredRowValue(
global::System.Text.Json.JsonElement row,
string propertyName,
int rowNumber,
string fieldName,
string displayName)
{
if (!row.TryGetProperty(propertyName, out var property)
|| property.ValueKind is global::System.Text.Json.JsonValueKind.Null
or global::System.Text.Json.JsonValueKind.Undefined)
{
throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}");
}
var value = property.ValueKind == global::System.Text.Json.JsonValueKind.String
? property.GetString()
: property.ToString();
if (string.IsNullOrWhiteSpace(value))
throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}");
return value.Trim();
}
private static void ValidateJson(
string json,
global::System.Text.Json.JsonValueKind expectedKind,
string fieldName)
{
if (string.IsNullOrWhiteSpace(json))
return;
try
{
using var document = global::System.Text.Json.JsonDocument.Parse(json);
if (document.RootElement.ValueKind != expectedKind)
throw new global::System.Text.Json.JsonException();
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写");
}
}
}
@@ -1,161 +1,324 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 员工手机端入口;仅凭一次性任务链接访问,绝不创建正式员工。
/// 员工手机端公开入口,仅凭一次性任务链接访问入职资料。
/// </summary>
public class OnboardingPublicTaskService : IOnboardingPublicTaskService
{
private readonly SqlSugarScope _db;
private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingContractDocumentService _contractDocumentService;
private readonly IOnboardingIdentityValidationService _identityValidationService;
public OnboardingPublicTaskService(
IOnboardingContractSnapshotService snapshotService)
IOnboardingContractSnapshotService snapshotService,
IOnboardingContractDocumentService contractDocumentService,
IOnboardingIdentityValidationService identityValidationService)
{
_db = DbContext.DB;
_snapshotService = snapshotService;
_contractDocumentService = contractDocumentService;
_identityValidationService = identityValidationService;
}
/// <summary>
/// 通过有效 Token 获取员工端任务、字段、材料和合同信息。
/// 通过有效 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();
var materialRequirements = await _db.Queryable<ObTaskMaterialRequirement>()
.Where(item => item.TaskId == task.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var formRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
var formRequirements = await GetEffectiveFormRequirements(task);
var materialRequirements = await GetEffectiveMaterialRequirements(task);
var fields = await _db.Queryable<ObFormField>()
.Where(x => formRequirements.Select(r => r.FieldId).Contains(x.Id) && x.Status == "enable")
var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
var materialTypes = await _db.Queryable<ObMaterialType>()
.Where(x => materialRequirements.Select(r => r.MaterialTypeId).Contains(x.Id) && x.Status == "enable")
var materials = await _db.Queryable<ObTaskMaterial>()
.Where(item => item.TaskId == task.Id)
.ToListAsync();
var materialFileIds = materials
.Select(item => item.FileId)
.Distinct()
.ToList();
var materialFiles = materialFileIds.Count == 0
? new List<SysFile>()
: await _db.Queryable<SysFile>()
.Where(item => materialFileIds.Contains(item.Id))
.ToListAsync();
var materialFileMap = materialFiles.ToDictionary(item => item.Id);
var materialOutputs = materials.Select(item =>
{
materialFileMap.TryGetValue(item.FileId, out var file);
return new OnboardingPublicTaskMaterialOutput
{
Id = item.Id,
MaterialTypeId = item.MaterialTypeId,
MaterialName = item.MaterialName,
FileId = item.FileId,
FileName = file?.Name,
FileSuffix = file?.Suffix,
FileSizeInfo = file?.SizeInfo,
UploadedAt = item.UploadedAt,
ReviewStatus = item.ReviewStatus,
ReviewRemark = item.ReviewRemark
};
}).ToList();
var templateVersionIds = contracts
.Select(item => item.TemplateVersionId)
.Distinct()
.ToList();
var contractVariables = new List<OnboardingPublicContractVariableOutput>();
if (templateVersionIds.Count > 0)
{
var employeeVariables = await _db.Queryable<ObTemplateVariable>()
.Where(item => templateVersionIds.Contains(item.TemplateVersionId)
&& item.VariableType == ContractVariableType.Employee)
.OrderBy(item => item.SortCode)
.ToListAsync();
contractVariables = employeeVariables.Select(item => new OnboardingPublicContractVariableOutput
{
VariableCode = item.VariableCode,
VariableName = item.VariableName,
VariableType = item.VariableType,
DataType = item.DataType,
DefaultValue = item.DefaultValue,
OptionsJson = item.OptionsJson,
Required = item.Required
}).ToList();
}
return new OnboardingTaskPublicDetailOutput
{
Task = task,
EmployeeInfo = await _db.Queryable<ObTaskEmployeeInfo>().FirstAsync(it => it.TaskId == task.Id),
Contracts = await _db.Queryable<ObTaskContract>()
.Where(it => it.TaskId == task.Id)
.ToListAsync(),
DynamicValues = await _db.Queryable<ObTaskFormValue>()
.Where(x => x.TaskId == task.Id)
.ToListAsync(),
Materials = await _db.Queryable<ObTaskMaterial>()
.Where(x => x.TaskId == task.Id)
.ToListAsync(),
EmployeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == task.Id),
Contracts = contracts,
DynamicValues = new List<ObTaskFormValue>(),
Materials = materialOutputs,
FormFields = formRequirements
.OrderBy(x => x.SortCode)
.Select(requirement => fields.FirstOrDefault(field => field.Id == requirement.FieldId))
.Where(field => field != null)
.Select(field => new OnboardingPublicFormFieldOutput
.Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField)
.Select(requirement => new OnboardingPublicFormFieldOutput
{
FieldId = field.Id,
FieldCode = field.FieldCode,
FieldName = field.FieldName,
DataType = field.DataType,
OptionsJson = field.OptionsJson,
Required = formRequirements.First(requirement => requirement.FieldId == field.Id).Required
})
.ToList(),
MaterialRequirements = materialRequirements
.OrderBy(x => x.SortCode)
.Select(requirement => materialTypes.FirstOrDefault(material => material.Id == requirement.MaterialTypeId))
.Where(material => material != null)
.Select(material => new OnboardingPublicMaterialOutput
{
MaterialTypeId = material.Id,
MaterialName = material.MaterialName,
AllowedExtensions = material.AllowedExtensions,
MaxFileCount = material.MaxFileCount,
Required = materialRequirements.First(requirement => requirement.MaterialTypeId == material.Id).Required
})
.ToList()
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,
MaterialName = requirement.MaterialName,
AllowedExtensions = requirement.AllowedExtensions,
MaxFileCount = requirement.MaxFileCount,
MaxFileSizeMb = requirement.MaxFileSizeMb,
Required = requirement.Required
}).ToList(),
ContractVariables = contractVariables
};
}
/// <summary>
/// 保存候选人固定基础信息并刷新合同变量快照。
/// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。
/// </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)
?? throw Oops.Bah("入职任务不存在");
if (task.TaskStatus != OnboardingTaskStatus.PendingFill && task.TaskStatus != OnboardingTaskStatus.Rejected)
if (task.TaskStatus != OnboardingTaskStatus.PendingFill
&& task.TaskStatus != OnboardingTaskStatus.Rejected)
{
throw Oops.Bah("当前任务不可编辑");
}
var old = await _db.Queryable<ObTaskEmployeeInfo>().FirstAsync(it => it.TaskId == task.Id);
var formRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
OnboardingEmployeeInfoValidator.ValidateAndNormalize(input, formRequirements);
await _identityValidationService.EnsureNoDuplicate(
task.Id,
input.EmployeeName,
input.IdCardNumber);
var old = await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == task.Id);
var entity = input.Adapt<ObTaskEmployeeInfo>();
entity.TaskId = task.Id;
entity.IsDelete = false;
if (old == null)
{
entity.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
entity.Status = CommonStatusConst.ENABLE;
entity.CreateTime = DateTime.Now;
entity.CreateUserId = null;
entity.CreateUser = null;
await _db.Insertable(entity).ExecuteCommandAsync();
}
else
{
entity.Id = old.Id;
entity.Status = old.Status;
entity.CreateTime = old.CreateTime;
entity.CreateUserId = old.CreateUserId;
entity.CreateUser = old.CreateUser;
entity.ExtJson = old.ExtJson;
entity.IsDelete = old.IsDelete;
entity.UpdateTime = DateTime.Now;
entity.UpdateUserId = null;
entity.UpdateUser = null;
await _db.Updateable(entity).ExecuteCommandAsync();
}
await _snapshotService.Build(task.Id, entity);
await _snapshotService.Build(task.Id, entity, invalidateGeneratedPreview: true);
}
/// <summary>
/// 校验任务归属后返回合同模板源文件 Id。
/// 根据员工当前已填写信息校验变量并提前生成合同 DOCX。
/// </summary>
public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input)
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>()
.FirstAsync(x => x.Id == input.TaskContractId && x.TaskId == link.TaskId)
.FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == link.TaskId)
?? throw Oops.Bah("合同不存在");
var version = await _db.Queryable<ObContractTemplateVersion>().InSingleAsync(contract.TemplateVersionId)
?? throw Oops.Bah("合同模板版本不存在");
return version.SourceDocxFileId;
// 员工资料保存时会清除旧文件 Id;仍有文件 Id 时还需确认文件记录没有被异常删除。
if (contract.GeneratedDocxFileId.HasValue)
{
var generatedFileExists = await _db.Queryable<SysFile>()
.AnyAsync(item => item.Id == contract.GeneratedDocxFileId.Value);
if (!generatedFileExists)
throw Oops.Bah("已生成的合同文件不存在,请联系管理员重新生成");
return new OnboardingPublicContractPrepareOutput
{
FileId = contract.GeneratedDocxFileId.Value,
GeneratedAt = contract.GeneratedAt
};
}
await _snapshotService.Build(link.TaskId);
var fileId = await _contractDocumentService.GenerateForPublicAccess(
contract.Id,
link.TaskId);
var generatedAt = await _db.Queryable<ObTaskContract>()
.Where(item => item.Id == contract.Id)
.Select(item => item.GeneratedAt)
.SingleAsync();
return new OnboardingPublicContractPrepareOutput
{
FileId = fileId,
GeneratedAt = generatedAt
};
}
private async Task<List<ObFormRequirement>> GetEffectiveFormRequirements(ObOnboardingTask task)
/// <summary>
/// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。
/// </summary>
public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input)
{
var requirements = await _db.Queryable<ObFormRequirement>()
.Where(x => x.CompanyId == task.CompanyId && (x.PositionId == null || x.PositionId == task.PositionId))
.ToListAsync();
// 同一字段同时存在公司级和岗位级配置时,岗位配置覆盖公司默认配置。
return requirements
.GroupBy(x => x.FieldId)
.Select(group => group.FirstOrDefault(x => x.PositionId == task.PositionId) ?? group.First())
.ToList();
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => GetContractSourceFileIdCore(input));
}
private async Task<List<ObMaterialRequirement>> GetEffectiveMaterialRequirements(ObOnboardingTask task)
private async Task<long> GetContractSourceFileIdCore(OnboardingPublicContractSourceInput input)
{
var requirements = await _db.Queryable<ObMaterialRequirement>()
.Where(x => x.CompanyId == task.CompanyId && (x.PositionId == null || x.PositionId == task.PositionId))
.ToListAsync();
var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>()
.FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == link.TaskId)
?? throw Oops.Bah("合同不存在");
var fileId = contract.GeneratedDocxFileId;
if (!fileId.HasValue)
{
// 下载入口增加服务端兜底,避免前端状态与数据库短暂不同步时打开 JSON 错误页。
var prepared = await PrepareContract(input);
fileId = prepared.FileId;
}
// 同一材料同时存在公司级和岗位级配置时,岗位配置覆盖公司默认配置。
return requirements
.GroupBy(x => x.MaterialTypeId)
.Select(group => group.FirstOrDefault(x => x.PositionId == task.PositionId) ?? group.First())
.ToList();
var fileExists = await _db.Queryable<SysFile>()
.AnyAsync(item => item.Id == fileId.Value);
if (!fileExists)
throw Oops.Bah("合同文件不存在,请重新准备合同");
contract.ReadAt ??= DateTime.Now;
if (contract.ContractStatus == TaskContractStatus.PendingGenerate)
contract.ContractStatus = TaskContractStatus.PendingConfirm;
await _db.Updateable(contract)
.UpdateColumns(item => new { item.ReadAt, item.ContractStatus })
.ExecuteCommandAsync();
return fileId.Value;
}
/// <summary>
/// 校验一次性链接与材料归属,避免通过文件 Id 越权读取其他任务文件。
/// </summary>
public async Task<OnboardingPublicTaskMaterialOutput> GetMaterialSource(
OnboardingPublicMaterialSourceInput input)
{
var link = await GetActiveLink(input.Token);
var material = await _db.Queryable<ObTaskMaterial>()
.FirstAsync(item => item.Id == input.TaskMaterialId && item.TaskId == link.TaskId)
?? throw Oops.Bah("上传材料不存在");
var file = await _db.Queryable<SysFile>().InSingleAsync(material.FileId)
?? throw Oops.Bah("上传文件不存在");
return new OnboardingPublicTaskMaterialOutput
{
Id = material.Id,
MaterialTypeId = material.MaterialTypeId,
MaterialName = material.MaterialName,
FileId = material.FileId,
FileName = file.Name,
FileSuffix = file.Suffix,
FileSizeInfo = file.SizeInfo,
UploadedAt = material.UploadedAt,
ReviewStatus = material.ReviewStatus,
ReviewRemark = material.ReviewRemark
};
}
private async Task<ObTaskLink> GetActiveLink(string token)
{
if (string.IsNullOrWhiteSpace(token))
throw Oops.Bah("入职链接 Token 不能为空");
var hash = Convert.ToHexString(
global::System.Security.Cryptography.SHA256.HashData(
global::System.Text.Encoding.UTF8.GetBytes(token)));
var link = await _db.Queryable<ObTaskLink>().FirstAsync(it => it.TokenHash == hash)
var link = await _db.Queryable<ObTaskLink>().FirstAsync(item => item.TokenHash == hash)
?? throw Oops.Bah("链接不存在");
if (link.LinkStatus != OnboardingLinkStatus.Active || link.ExpiresAt <= DateTime.Now)
@@ -1,4 +1,6 @@
namespace SimpleAdmin.Application;
using System.ComponentModel.DataAnnotations;
namespace SimpleAdmin.Application;
/// <summary>
/// 创建新员工入职任务参数。
@@ -11,12 +13,34 @@ public class OnboardingTaskCreateInput
[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>
/// 用工类型:full_time 正式用工,part_time 兼职用工。
/// </summary>
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
/// <summary>
/// 本次任务使用的入职模板 Id。
/// </summary>
[IdNotNull(ErrorMessage = "入职模板Id不能为空")]
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 创建任务时由人事预先填写的合同变量。
/// </summary>
public List<OnboardingInitialContractVariableInput> ContractVariables { get; set; } = new();
/// <summary>
/// 创建任务时指定的审核人用户 Id。
/// </summary>
@@ -30,9 +54,66 @@ 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; }
}
/// <summary>
/// 创建任务时填写的单个合同变量。
/// </summary>
public class OnboardingInitialContractVariableInput
{
/// <summary>
/// 变量所属合同模板版本 Id。
/// </summary>
[IdNotNull(ErrorMessage = "合同模板版本Id不能为空")]
public long ContractTemplateVersionId { get; set; }
/// <summary>
/// DOCX 中的完整变量编码,例如 hr.MonthlySalary。
/// </summary>
public string VariableCode { get; set; }
/// <summary>
/// 人事录入的变量值。
/// </summary>
public string VariableValue { get; set; }
}
/// <summary>
/// 入职任务创建结果。
/// </summary>
@@ -58,3 +139,13 @@ public class OnboardingTaskCreatedOutput
/// </summary>
public DateTime ExpiresAt { get; set; }
}
/// <summary>
/// 批量复制入职任务结果。
/// </summary>
public class OnboardingTaskCopyOutput
{
/// <summary>
/// 本次复制生成的全部新任务及独立链接信息。
/// </summary>
public List<OnboardingTaskCreatedOutput> Tasks { get; set; } = new();
}
@@ -28,6 +28,45 @@ public class OnboardingPublicContractSourceInput
/// </summary>
[IdNotNull(ErrorMessage = "任务合同Id不能为空")]
public long TaskContractId { get; set; }
/// <summary>
/// 当前生成文件的版本标识,仅用于区分手机下载缓存,不参与文件权限判断。
/// </summary>
public long? Version { get; set; }
}
/// <summary>
/// 员工端合同准备结果。
/// </summary>
public class OnboardingPublicContractPrepareOutput
{
/// <summary>
/// 当前已经生成并可下载的系统文件 Id,同时作为客户端下载版本号。
/// </summary>
public long FileId { get; set; }
/// <summary>
/// 合同文件生成时间;复用现有文件时返回原生成时间。
/// </summary>
public DateTime? GeneratedAt { get; set; }
}
/// <summary>
/// 员工端预览已上传材料参数。
/// </summary>
public class OnboardingPublicMaterialSourceInput
{
/// <summary>
/// 员工填写链接中的明文 Token。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public string Token { get; set; }
/// <summary>
/// 任务材料记录 Id。
/// </summary>
[IdNotNull(ErrorMessage = "任务材料Id不能为空")]
public long TaskMaterialId { get; set; }
}
/// <summary>
@@ -40,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>
@@ -96,6 +117,47 @@ public class OnboardingPublicFormFieldOutput
public bool Required { get; set; }
}
/// <summary>
/// 员工端需要填写的合同员工变量。
/// </summary>
public class OnboardingPublicContractVariableOutput
{
/// <summary>
/// 合同变量编码。
/// </summary>
public string VariableCode { get; set; }
/// <summary>
/// 合同变量显示名称。
/// </summary>
public string VariableName { get; set; }
/// <summary>
/// 变量来源类型。
/// </summary>
public string VariableType { get; set; }
/// <summary>
/// 变量数据类型。
/// </summary>
public string DataType { get; set; }
/// <summary>
/// 变量默认值。
/// </summary>
public string DefaultValue { get; set; }
/// <summary>
/// 选择型变量的可选项 JSON。
/// </summary>
public string OptionsJson { get; set; }
/// <summary>
/// 是否必填。
/// </summary>
public bool Required { get; set; }
}
/// <summary>
/// 员工端材料上传要求。
/// </summary>
@@ -121,12 +183,73 @@ public class OnboardingPublicMaterialOutput
/// </summary>
public int MaxFileCount { get; set; }
/// <summary>
/// 单个文件最大大小,单位为 MB;为空表示不限制。
/// </summary>
public int? MaxFileSizeMb { get; set; }
/// <summary>
/// 是否必填、必传或必签。
/// </summary>
public bool Required { get; set; }
}
/// <summary>
/// 员工端已上传材料展示信息。
/// </summary>
public class OnboardingPublicTaskMaterialOutput
{
/// <summary>
/// 任务材料记录 Id。
/// </summary>
public long Id { get; set; }
/// <summary>
/// 材料类型 Id。
/// </summary>
public long MaterialTypeId { get; set; }
/// <summary>
/// 材料显示名称。
/// </summary>
public string MaterialName { get; set; }
/// <summary>
/// 系统文件 Id。
/// </summary>
public long FileId { get; set; }
/// <summary>
/// 用户上传时的原始文件名。
/// </summary>
public string FileName { get; set; }
/// <summary>
/// 文件扩展名,不包含点号。
/// </summary>
public string FileSuffix { get; set; }
/// <summary>
/// 格式化后的文件大小。
/// </summary>
public string FileSizeInfo { get; set; }
/// <summary>
/// 材料上传时间。
/// </summary>
public DateTime UploadedAt { get; set; }
/// <summary>
/// 材料审核状态。
/// </summary>
public string ReviewStatus { get; set; }
/// <summary>
/// 材料审核意见。
/// </summary>
public string ReviewRemark { get; set; }
}
/// <summary>
/// 员工端入职任务完整详情。
/// </summary>
@@ -165,5 +288,10 @@ public class OnboardingTaskPublicDetailOutput
/// <summary>
/// 候选人已上传材料列表。
/// </summary>
public List<ObTaskMaterial> Materials { get; set; }
public List<OnboardingPublicTaskMaterialOutput> Materials { get; set; }
/// <summary>
/// 合同中由员工填写的扩展变量。
/// </summary>
public List<OnboardingPublicContractVariableOutput> ContractVariables { get; set; }
}
@@ -9,4 +9,9 @@ public interface IOnboardingTaskService : ITransient
/// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。
/// </summary>
Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input);
/// <summary>
/// 复制原任务的业务配置和人事参数,批量创建独立的新入职任务。
/// </summary>
Task<OnboardingTaskCopyOutput> Copy(OnboardingTaskCopyInput input);
}
@@ -1,60 +1,115 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 实现入职任务创建业务能力。
/// 实现基于入职模板创建任务及冻结配置快照的业务能力。
/// </summary>
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>
/// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。
/// 校验公司、岗位和入职模板后创建任务,并冻结合同及材料要求。
/// </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)
{
throw Oops.Bah("公司不存在或未启用");
}
var departmentEnabled = await _db.Queryable<ObDepartment>()
.AnyAsync(item => item.Id == input.DepartmentId && item.CompanyId == input.CompanyId && item.Status == "enable");
if (!departmentEnabled)
throw Oops.Bah("部门不存在、未启用或不属于该公司");
var positionEnabled = await _db.Queryable<ObPosition>()
.AnyAsync(item => item.Id == input.PositionId
&& item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == "enable");
if (!positionEnabled)
{
throw Oops.Bah("岗位不存在、未启用或不属于该公司");
}
throw Oops.Bah("岗位不存在、未启用或不属于该部门/公司");
var scopes = await _db.Queryable<ObContractScope>()
.Where(item => item.CompanyId == input.CompanyId
&& (item.PositionId == null || item.PositionId == input.PositionId))
var onboardingTemplate = await _db.Queryable<ObOnboardingTemplate>()
.FirstAsync(item => item.Id == input.OnboardingTemplateId
&& item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == "enable")
?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司、部门");
var templateSupportsPosition = await _db.Queryable<ObOnboardingTemplatePosition>()
.AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id
&& item.PositionId == input.PositionId);
if (!templateSupportsPosition)
throw Oops.Bah("所选入职模板不适用于该岗位");
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
if (contractConfigs.Count == 0)
throw Oops.Bah("入职模板尚未配置合同文件");
var materialConfigs = await _db.Queryable<ObOnboardingTemplateMaterial>()
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
// 同一版本同时存在“全公司”和“指定岗位”范围时,岗位专属配置优先,避免同一合同生成两次。
scopes = scopes
.GroupBy(item => item.TemplateVersionId)
.Select(group => group.FirstOrDefault(item => item.PositionId == input.PositionId) ?? group.First())
.ToList();
if (scopes.Count == 0)
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,
"enable",
global::System.StringComparison.OrdinalIgnoreCase)))
{
throw Oops.Bah("该公司和岗位尚未配置合同适用范围");
throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板");
}
var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var contractPositionCount = await _db.Queryable<ObContractTemplatePosition>()
.CountAsync(item => templateIds.Contains(item.ContractTemplateId)
&& item.PositionId == input.PositionId);
if (contractPositionCount != templateIds.Count)
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
var versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).Distinct().ToList();
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
? new List<ObMaterialType>()
: await _db.Queryable<ObMaterialType>().Where(item => materialIds.Contains(item.Id)).ToListAsync();
if (materialTypes.Count != materialIds.Count)
throw Oops.Bah("入职模板包含不存在的材料类型,请先调整模板配置");
var expiryConfig = await _configService.GetByConfigKey(
CateGoryConst.CONFIG_BIZ_DEFINE,
"onboard.link.expireHours");
@@ -65,6 +120,7 @@ public class OnboardingTaskService : IOnboardingTaskService
throw Oops.Bah("请在系统配置中设置 onboard.link.expireHours(正整数小时)");
}
var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var expiresAt = DateTime.Now.AddHours(expiryHours);
var token = Guid.NewGuid().ToString("N");
var task = new ObOnboardingTask
@@ -72,14 +128,18 @@ public class OnboardingTaskService : IOnboardingTaskService
Id = taskId,
TaskNo = $"OB{DateTime.Now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}",
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);
await _db.Ado.BeginTranAsync();
try
{
@@ -88,32 +148,70 @@ public class OnboardingTaskService : IOnboardingTaskService
{
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();
var templateIds = scopes.Select(item => item.TemplateId).Distinct().ToList();
var templates = await _db.Queryable<ObContractTemplate>()
.Where(item => templateIds.Contains(item.Id))
.ToListAsync();
var contracts = scopes.Select(scope =>
// 初始化空白的员工入职登记表
await _db.Insertable(new ObTaskEmployeeInfo
{
var template = templates.First(item => item.Id == scope.TemplateId);
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
Status = CommonStatusConst.ENABLE,
CreateTime = DateTime.Now
}).ExecuteCommandAsync();
var 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(),
TaskId = taskId,
TemplateId = scope.TemplateId,
TemplateVersionId = scope.TemplateVersionId,
TemplateId = config.ContractTemplateId,
TemplateVersionId = config.ContractTemplateVersionId,
ContractName = template.TemplateName,
ContractType = template.ContractType,
Required = scope.Required
Required = config.Required,
CompanySealEnabled = version.CompanySealEnabled,
AcrossPageSealEnabled = version.AcrossPageSealEnabled,
PrintCopies = config.PrintCopies
};
}).ToList();
await _db.Insertable(contracts).ExecuteCommandAsync();
if (materialConfigs.Count > 0)
{
var materialSnapshots = materialConfigs.Select(config =>
{
var material = materialTypes.First(item => item.Id == config.MaterialTypeId);
return new ObTaskMaterialRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
MaterialTypeId = material.Id,
MaterialCode = material.MaterialCode,
MaterialName = material.MaterialName,
AllowedExtensions = material.AllowedExtensions,
MaxFileCount = material.MaxFileCount,
MaxFileSizeMb = material.MaxFileSizeMb,
Required = config.Required,
SortCode = config.SortCode
};
}).ToList();
await _db.Insertable(materialSnapshots).ExecuteCommandAsync();
}
await _snapshotService.Build(taskId);
await ApplyInitialContractVariables(input.ContractVariables, contracts);
await _db.Ado.CommitTranAsync();
}
catch
@@ -130,4 +228,389 @@ public class OnboardingTaskService : IOnboardingTaskService
ExpiresAt = expiresAt
};
}
/// <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>
private async Task ApplyInitialContractVariables(
List<OnboardingInitialContractVariableInput> inputs,
List<ObTaskContract> contracts)
{
inputs ??= new List<OnboardingInitialContractVariableInput>();
var versionIds = contracts.Select(item => item.TemplateVersionId).Distinct().ToList();
var definitions = await _db.Queryable<ObTemplateVariable>()
.Where(item => versionIds.Contains(item.TemplateVersionId)
&& item.HrEditable
&& (item.VariableType == "hr"
|| item.VariableType == "contract"
|| item.VariableType == "company"
|| item.VariableType == ContractVariableType.Contract
|| item.VariableType == ContractVariableType.Company
|| item.VariableCode.StartsWith("hr.")))
.ToListAsync();
var duplicateInput = inputs
.GroupBy(item => new { item.ContractTemplateVersionId, item.VariableCode })
.FirstOrDefault(group => group.Count() > 1);
if (duplicateInput != null)
throw Oops.Bah($"合同变量“{duplicateInput.Key.VariableCode}”重复提交");
foreach (var input in inputs)
{
var definitionExists = definitions.Any(item =>
item.TemplateVersionId == input.ContractTemplateVersionId
&& item.VariableCode == input.VariableCode);
if (!definitionExists)
throw Oops.Bah($"合同变量“{input.VariableCode}”不存在或不允许人事填写");
}
var missingRequired = definitions.FirstOrDefault(definition =>
{
if (!definition.Required)
return false;
var input = inputs.FirstOrDefault(item =>
item.ContractTemplateVersionId == definition.TemplateVersionId
&& item.VariableCode == definition.VariableCode);
return string.IsNullOrWhiteSpace(input?.VariableValue ?? definition.DefaultValue);
});
if (missingRequired != null)
throw Oops.Bah($"请填写合同变量“{missingRequired.VariableName}”");
if (inputs.Count == 0)
return;
var duplicateContractVersion = contracts
.GroupBy(item => item.TemplateVersionId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateContractVersion != null)
throw Oops.Bah("入职模板中存在重复的合同版本,请重新保存入职模板后再创建任务");
var contractByVersion = contracts.ToDictionary(item => item.TemplateVersionId);
var contractIds = contracts.Select(item => item.Id).ToList();
var rows = await _db.Queryable<ObTaskContractVariable>()
.Where(item => contractIds.Contains(item.TaskContractId))
.ToListAsync();
foreach (var input in inputs)
{
var contract = contractByVersion[input.ContractTemplateVersionId];
var row = rows.FirstOrDefault(item =>
item.TaskContractId == contract.Id && item.VariableCode == input.VariableCode)
?? throw Oops.Bah($"合同变量快照“{input.VariableCode}”不存在");
row.VariableValue = input.VariableValue;
row.SourceType = ContractVariableSourceType.HrInput;
row.IsManualOverride = true;
}
var changedRows = rows.Where(item => item.IsManualOverride).ToList();
if (changedRows.Count > 0)
await _db.Updateable(changedRows).ExecuteCommandAsync();
}
/// <summary>
/// 校验人事创建任务时填写的合同日期先后关系。
/// </summary>
internal static void ValidateInitialContractDateRanges(
List<OnboardingInitialContractVariableInput> inputs)
{
inputs ??= new List<OnboardingInitialContractVariableInput>();
foreach (var group in inputs.GroupBy(item => item.ContractTemplateVersionId))
{
ValidateDateRange(group, "hr.ContractStartDate", "hr.ContractEndDate", "合同");
ValidateDateRange(group, "hr.ProbationStartDate", "hr.ProbationEndDate", "试用期");
}
}
/// <summary>
/// 校验同一合同版本内一组开始、结束日期。
/// </summary>
private static void ValidateDateRange(
IEnumerable<OnboardingInitialContractVariableInput> inputs,
string startCode,
string endCode,
string rangeName)
{
var startValue = inputs.FirstOrDefault(item =>
string.Equals(item.VariableCode, startCode, StringComparison.OrdinalIgnoreCase))?.VariableValue;
var endValue = inputs.FirstOrDefault(item =>
string.Equals(item.VariableCode, endCode, StringComparison.OrdinalIgnoreCase))?.VariableValue;
if (string.IsNullOrWhiteSpace(startValue) || string.IsNullOrWhiteSpace(endValue))
return;
if (!DateTime.TryParse(startValue, out var startDate))
throw Oops.Bah($"{rangeName}开始日期格式不正确");
if (!DateTime.TryParse(endValue, out var endDate))
throw Oops.Bah($"{rangeName}结束日期格式不正确");
if (endDate.Date < startDate.Date)
throw Oops.Bah($"{rangeName}结束日期不能早于开始日期");
}
private static void ValidateEmploymentType(string employmentType)
{
if (employmentType != EmployeeEmploymentType.FullTime
&& employmentType != EmployeeEmploymentType.PartTime)
{
throw Oops.Bah("用工类型不正确,请选择正式员工或兼职员工");
}
}
}
@@ -103,6 +103,11 @@ public class OnboardingTaskContractVariableItemOutput
/// </summary>
public string DataType { get; set; }
/// <summary>
/// 任务创建时保存的变量展示顺序。
/// </summary>
public int SortCode { get; set; }
/// <summary>
/// 任务合同变量最终值。
/// </summary>
@@ -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>
/// 查询任务下全部合同及其变量当前值。
@@ -43,7 +47,8 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
Variables = variables
.Where(variable => variable.TaskContractId == contract.Id)
.OrderBy(variable => variable.VariableCode)
.OrderBy(variable => variable.SortCode)
.ThenBy(variable => variable.VariableCode)
.Select(variable =>
{
var definition = definitions.FirstOrDefault(x =>
@@ -59,6 +64,7 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
VariableName = variable.VariableName,
VariableType = variable.VariableType,
DataType = variable.DataType,
SortCode = variable.SortCode,
VariableValue = variable.VariableValue,
Required = definition?.Required == true,
HrEditable = hrEditable,
@@ -75,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)
@@ -83,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)
@@ -141,6 +156,15 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
{
await _db.Updateable(rows)
.ExecuteCommandAsync();
// 人事参数变化后,原合同文件和员工确认必须失效,避免员工确认旧内容。
contract.GeneratedDocxFileId = null;
contract.GeneratedPdfFileId = null;
contract.GeneratedAt = null;
contract.ReadAt = null;
contract.ConfirmedAt = null;
contract.ContractStatus = TaskContractStatus.PendingGenerate;
await _db.Updateable(contract).ExecuteCommandAsync();
}
}
}

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