34 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
145 changed files with 13681 additions and 512 deletions
@@ -6,8 +6,30 @@
"Title": "业务模块" //配置分组标题,string 类型 "Title": "业务模块" //配置分组标题,string 类型
}, },
//系统层设置 //系统层设置
"ApplicationSettings": { "ApplicationSettings": {
"InitTable": false, //是否初始化表结构 "InitTable": false, //是否初始化表结构
"InitSeedData": false //是否初始化种子数据 "InitSeedData": false //是否初始化种子数据
},
// 契约锁测试环境配置。AppToken、AppSecret 由部署人员填写,禁止提交真实密钥。
"QiyuesuoSetting": {
"Enabled": true,
"ServerUrl": "https://openapi.qiyuesuo.cn",
"AppToken": "rMKU99gkts",
"AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O",
"SignatureVersion": "",
"CreatorName": "袁林",
"CreatorMobile": "15123135384",
"CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev/api/open/onboarding/qiyuesuo/callback",
"SignPageCallbackUrl": "",
"SignUrlExpireMinutes": 10,
"EmployeeSignKeyword": "乙方(劳动者)签字:",
"EmployeeSignKeywordIndex": 1,
"CompanySealKeyword": "甲方(盖章):",
"CompanySealKeywordIndex": 1,
"CompanySealOffsetX": 0,
"CompanySealOffsetY": -0.05,
"EnableAcrossPageSeal": true,
"AcrossPageSealType": "ALL_DOCUMENT",
"TimeoutSeconds": 90
} }
} }
@@ -8,6 +8,28 @@
//系统层设置 //系统层设置
"ApplicationSettings": { "ApplicationSettings": {
"InitTable": false, //是否初始化表结构 "InitTable": false, //是否初始化表结构
"InitSeedData": false, //是否初始化种子数据0 "InitSeedData": false //是否初始化种子数据
} },
// 契约锁正式环境配置。上线前填写正式 AppToken/AppSecret,并把 ServerUrl 切换为 .com。
"QiyuesuoSetting": {
"Enabled": true,
"ServerUrl": "https://openapi.qiyuesuo.com",
"AppToken": "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>
/// 统一社会信用代码。 /// 统一社会信用代码。
/// </summary> /// </summary>
public string UnifiedSocialCreditCode { get; set; } public virtual string UnifiedSocialCreditCode { get; set; }
/// <summary> /// <summary>
/// 法定代表人姓名。 /// 法定代表人姓名。
/// </summary> /// </summary>
public string LegalRepresentative { get; set; } public virtual string LegalRepresentative { get; set; }
/// <summary> /// <summary>
/// 工商注册地址。 /// 工商注册地址。
/// </summary> /// </summary>
public string RegisteredAddress { get; set; } public virtual string RegisteredAddress { get; set; }
/// <summary> /// <summary>
/// 公司联系电话。 /// 公司联系电话。
/// </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>
/// 业务备注。 /// 业务备注。
/// </summary> /// </summary>
public string Remark { get; set; } public virtual string Remark { get; set; }
} }
@@ -17,6 +17,18 @@ namespace SimpleAdmin.Application;
[Tenant(SqlSugarConst.DB_DEFAULT)] [Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObContractTemplate : BaseEntity 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>
/// 合同模板业务编码。 /// 合同模板业务编码。
/// </summary> /// </summary>
@@ -36,4 +48,28 @@ public class ObContractTemplate : BaseEntity
/// 模板用途说明。 /// 模板用途说明。
/// </summary> /// </summary>
public string Description { get; set; } 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> /// </summary>
public string VersionStatus { get; set; } = ContractTemplateVersionStatus.Draft; public string VersionStatus { get; set; } = ContractTemplateVersionStatus.Draft;
/// <summary>本版本是否需要公司盖章;历史空值沿用需要盖章。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>本版本是否需要骑缝章;历史空值沿用系统骑缝章配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary> /// <summary>
/// 业务备注。 /// 业务备注。
/// </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; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 审核通过后生成的正式员工档案,是入职流程的最终业务数据。 /// 审核通过后生成的员工档案,是入职流程的最终业务数据。
/// </summary> /// </summary>
[SugarTable("ob_employee", TableDescription = "正式员工档案")] [SugarTable("ob_employee", TableDescription = "员工档案")]
[Tenant(SqlSugarConst.DB_DEFAULT)] [Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObEmployee : BaseEntity public class ObEmployee : BaseEntity
{ {
@@ -37,6 +37,18 @@ public class ObEmployee : BaseEntity
/// </summary> /// </summary>
public long CompanyId { get; set; } 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>
/// 所属部门名称。 /// 所属部门名称。
/// </summary> /// </summary>
@@ -47,6 +59,27 @@ public class ObEmployee : BaseEntity
/// </summary> /// </summary>
public long PositionId { get; set; } public long PositionId { get; set; }
/// <summary>
/// 用工类型:full_time 正式用工,part_time 兼职用工。
/// </summary>
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
/// <summary>
/// 转正状态:probation 试用期、regular 已转正、not_applicable 不适用。
/// </summary>
public string RegularizationStatus { get; set; } = EmployeeRegularizationStatus.Probation;
/// <summary>
/// 社保状态:unknown 未维护、not_purchased 未购买、purchased 已购买、stopped 已停保、not_applicable 不适用。
/// </summary>
public string SocialInsuranceStatus { get; set; } = EmployeeSocialInsuranceStatus.Unknown;
/// <summary>
/// 所属岗位名称,仅用于接口展示,不映射数据库字段。
/// </summary>
[SugarColumn(IsIgnore = true)]
public string PositionName { get; set; }
/// <summary> /// <summary>
/// 性别。 /// 性别。
/// </summary> /// </summary>
@@ -0,0 +1,28 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 员工人事材料附件,可关联转正记录及后续扩展的人事业务。
/// </summary>
[SugarTable("ob_employee_attachment", TableDescription = "员工人事材料附件")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObEmployeeAttachment : BaseEntity
{
public long EmployeeId { get; set; }
[SugarColumn(IsNullable = true)]
public long? ChangeRecordId { get; set; }
public string BusinessType { get; set; }
public long FileId { get; set; }
public string FileName { get; set; }
public DateTime UploadedAt { get; set; }
[SugarColumn(IsIgnore = true)]
public string FileSuffix { get; set; }
[SugarColumn(IsIgnore = true)]
public string FileSizeInfo { get; set; }
}
@@ -0,0 +1,27 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 员工人事信息变更记录,保留转正及后续扩展业务的前后快照。
/// </summary>
[SugarTable("ob_employee_change_record", TableDescription = "员工人事信息变更记录")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObEmployeeChangeRecord : BaseEntity
{
public long EmployeeId { get; set; }
public string ChangeType { get; set; }
[SugarColumn(IsNullable = true)]
public DateTime? EffectiveDate { get; set; }
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string BeforeJson { get; set; }
[SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)]
public string AfterJson { get; set; }
public string Remark { get; set; }
[SugarColumn(IsIgnore = true)]
public List<ObEmployeeAttachment> Attachments { get; set; } = new();
}
@@ -0,0 +1,89 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 正式员工的一份合同在审核建档时冻结的业务快照。
/// 常用日期和薪资使用独立列,全部标准及自定义变量同时保存在 VariablesJson 中。
/// </summary>
[SugarTable("ob_employee_contract_snapshot", TableDescription = "员工合同快照")]
[Tenant(SqlSugarConst.DB_DEFAULT)]
public class ObEmployeeContractSnapshot : BaseEntity
{
/// <summary>正式员工 Id。</summary>
public long EmployeeId { get; set; }
/// <summary>来源入职任务 Id。</summary>
public long TaskId { get; set; }
/// <summary>来源任务合同 Id,同一任务合同只允许生成一份员工快照。</summary>
public long TaskContractId { get; set; }
/// <summary>合同模板 Id。</summary>
public long TemplateId { get; set; }
/// <summary>合同模板版本 Id。</summary>
public long TemplateVersionId { get; set; }
/// <summary>合同名称快照。</summary>
public string ContractName { get; set; }
/// <summary>合同类型快照。</summary>
public string ContractType { get; set; }
/// <summary>合同开始日期。</summary>
public DateTime? ContractStartDate { get; set; }
/// <summary>合同结束日期。</summary>
public DateTime? ContractEndDate { get; set; }
/// <summary>合同期限原始文本。</summary>
public string ContractTerm { get; set; }
/// <summary>试用期开始日期。</summary>
public DateTime? ProbationStartDate { get; set; }
/// <summary>试用期结束日期。</summary>
public DateTime? ProbationEndDate { get; set; }
/// <summary>试用期期限原始文本。</summary>
public string ProbationTerm { get; set; }
/// <summary>明确使用 hr.ProbationSalary 保存的试用薪资;不从其他工资项目推算。</summary>
public decimal? ProbationSalary { get; set; }
/// <summary>基本工资。</summary>
public decimal? BaseSalary { get; set; }
/// <summary>岗位工资。</summary>
public decimal? PositionSalary { get; set; }
/// <summary>绩效津贴。</summary>
public decimal? PerformanceAllowance { get; set; }
/// <summary>全勤奖。</summary>
public decimal? AttendanceBonus { get; set; }
/// <summary>转正后薪资。</summary>
public decimal? RegularSalary { get; set; }
/// <summary>工作地点或城市。</summary>
public string WorkCity { get; set; }
/// <summary>合同中的岗位名称。</summary>
public string PositionName { get; set; }
/// <summary>合同中的员工工号。</summary>
public string EmployeeNo { get; set; }
/// <summary>
/// 以 VariableCode 为键保存该合同当时的全部变量元数据和值。
/// 导出时通过稳定编码判断变量是否存在,不依赖可修改的中文名称。
/// </summary>
[SugarColumn(ColumnDataType = "json", IsNullable = false)]
public string VariablesJson { get; set; }
/// <summary>快照结构版本。</summary>
public int SnapshotVersion { get; set; } = 1;
/// <summary>快照生成时间。</summary>
public DateTime SnapshotAt { get; set; }
}
@@ -22,11 +22,22 @@ public class ObOnboardingTask : BaseEntity
/// </summary> /// </summary>
public long CompanyId { get; set; } public long CompanyId { get; set; }
/// <summary>
/// 所属入职业务部门 Id。
/// </summary>
[SugarColumn(IsNullable = true)]
public long? DepartmentId { get; set; }
/// <summary> /// <summary>
/// 入职任务所属岗位 Id。 /// 入职任务所属岗位 Id。
/// </summary> /// </summary>
public long PositionId { get; set; } public long PositionId { get; set; }
/// <summary>
/// 用工类型:full_time 正式用工,part_time 兼职用工。
/// </summary>
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
/// <summary> /// <summary>
/// 创建任务时锁定的入职模板 Id。 /// 创建任务时锁定的入职模板 Id。
/// </summary> /// </summary>
@@ -83,6 +94,12 @@ public class ObOnboardingTask : BaseEntity
[SugarColumn(IsIgnore = true)] [SugarColumn(IsIgnore = true)]
public string CompanyName { get; set; } public string CompanyName { get; set; }
/// <summary>
/// 部门名称 (展示用)
/// </summary>
[SugarColumn(IsIgnore = true)]
public string DepartmentName { get; set; }
/// <summary> /// <summary>
/// 岗位名称 (展示用) /// 岗位名称 (展示用)
/// </summary> /// </summary>
@@ -94,5 +111,4 @@ public class ObOnboardingTask : BaseEntity
/// </summary> /// </summary>
[SugarColumn(IsIgnore = true)] [SugarColumn(IsIgnore = true)]
public string OnboardingTemplateName { get; set; } public string OnboardingTemplateName { get; set; }
} }
@@ -22,6 +22,12 @@ public class ObOnboardingTemplate : BaseEntity
/// </summary> /// </summary>
public long CompanyId { get; set; } public long CompanyId { get; set; }
/// <summary>
/// 所属入职业务部门 Id。
/// </summary>
[SugarColumn(IsNullable = true)]
public long? DepartmentId { get; set; }
/// <summary> /// <summary>
/// 模板用途和适用范围说明。 /// 模板用途和适用范围说明。
/// </summary> /// </summary>
@@ -11,7 +11,7 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 入职业务岗位档案,保存岗位默认合同期限、试用期及业务参数。 /// 入职业务岗位档案,保存岗位所属部门、默认合同期限、试用期及业务参数。
/// </summary> /// </summary>
[SugarTable("ob_position", TableDescription = "入职业务岗位")] [SugarTable("ob_position", TableDescription = "入职业务岗位")]
[Tenant(SqlSugarConst.DB_DEFAULT)] [Tenant(SqlSugarConst.DB_DEFAULT)]
@@ -22,6 +22,12 @@ public class ObPosition : BaseEntity
/// </summary> /// </summary>
public long CompanyId { get; set; } public long CompanyId { get; set; }
/// <summary>
/// 所属入职业务部门 Id。
/// </summary>
[SugarColumn(IsNullable = true)]
public long? DepartmentId { get; set; }
/// <summary> /// <summary>
/// 岗位业务编码。 /// 岗位业务编码。
/// </summary> /// </summary>
@@ -50,5 +56,17 @@ public class ObPosition : BaseEntity
/// <summary> /// <summary>
/// 业务备注。 /// 业务备注。
/// </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; }
} }
@@ -52,6 +52,12 @@ public class ObTaskContract : BaseEntity
/// </summary> /// </summary>
public int PrintCopies { get; set; } = 1; public int PrintCopies { get; set; } = 1;
/// <summary>任务创建时冻结的公司盖章要求;历史空值沿用需要盖章。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>任务创建时冻结的骑缝章要求;历史空值沿用系统配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary> /// <summary>
/// 任务合同当前状态。 /// 任务合同当前状态。
/// </summary> /// </summary>
@@ -42,6 +42,11 @@ public class ObTaskContractVariable : BaseEntity
/// </summary> /// </summary>
public string DataType { get; set; } public string DataType { get; set; }
/// <summary>
/// 变量在任务合同中的展示顺序,创建任务时从模板变量复制并固定。
/// </summary>
public int SortCode { get; set; } = 100;
/// <summary> /// <summary>
/// 合同生成时使用的最终变量值。 /// 合同生成时使用的最终变量值。
/// </summary> /// </summary>
@@ -36,7 +36,7 @@ public static class OnboardingTaskStatus
public const string Rejected = "rejected"; public const string Rejected = "rejected";
/// <summary> /// <summary>
/// 已通过,系统已经生成正式员工档案。 /// 已通过,系统已经生成员工档案。
/// </summary> /// </summary>
public const string Approved = "approved"; public const string Approved = "approved";
@@ -128,12 +128,138 @@ public static class TaskContractStatus
/// </summary> /// </summary>
public const string OfflinePendingSign = "offline_pending_sign"; public const string OfflinePendingSign = "offline_pending_sign";
/// <summary>
/// 已发起第三方电子签,等待员工签署或企业盖章。
/// </summary>
public const string ElectronicSigning = "electronic_signing";
/// <summary>
/// 第三方电子签已完成,签署文件正在或已经归档。
/// </summary>
public const string ElectronicSigned = "electronic_signed";
/// <summary> /// <summary>
/// 已归档,任务流程结束且历史数据只读。 /// 已归档,任务流程结束且历史数据只读。
/// </summary> /// </summary>
public const string Archived = "archived"; public const string Archived = "archived";
} }
/// <summary>
/// 员工用工类型。
/// </summary>
public static class EmployeeEmploymentType
{
public const string FullTime = "full_time";
public const string PartTime = "part_time";
}
/// <summary>
/// 员工转正状态,仅由后台人事操作改变,不根据合同日期自动推算。
/// </summary>
public static class EmployeeRegularizationStatus
{
public const string Probation = "probation";
public const string Regular = "regular";
public const string NotApplicable = "not_applicable";
}
/// <summary>
/// 员工社保状态。
/// </summary>
public static class EmployeeSocialInsuranceStatus
{
public const string Unknown = "unknown";
public const string NotPurchased = "not_purchased";
public const string Purchased = "purchased";
public const string Stopped = "stopped";
public const string NotApplicable = "not_applicable";
}
/// <summary>
/// 员工人事变更类型。
/// </summary>
public static class EmployeeChangeType
{
public const string HrInfoUpdate = "hr_info_update";
public const string Regularization = "regularization";
public const string RegularizationRevoke = "regularization_revoke";
}
/// <summary>
/// 员工附件业务类型。
/// </summary>
public static class EmployeeAttachmentBusinessType
{
public const string Regularization = "regularization";
}
/// <summary>
/// 电子签服务商。
/// </summary>
public static class ElectronicSignProvider
{
/// <summary>
/// 契约锁开放平台。
/// </summary>
public const string Qiyuesuo = "qiyuesuo";
}
/// <summary>
/// 入职电子签流程状态。
/// </summary>
public static class ElectronicSignStatus
{
/// <summary>
/// 正在调用第三方创建流程。
/// </summary>
public const string Creating = "creating";
/// <summary>
/// 已创建草稿,尚未成功发送。
/// </summary>
public const string Draft = "draft";
/// <summary>
/// 已发送,等待员工签署。
/// </summary>
public const string PendingEmployeeSign = "pending_employee_sign";
/// <summary>
/// 员工已签,等待后台管理员核对文件并确认企业盖章。
/// </summary>
public const string PendingCompanySeal = "pending_company_seal";
/// <summary>
/// 所有签署已完成,等待文件归档。
/// </summary>
public const string Completed = "completed";
/// <summary>
/// 签署文件和签署日志已经归档。
/// </summary>
public const string Archived = "archived";
/// <summary>
/// 流程被拒签。
/// </summary>
public const string Rejected = "rejected";
/// <summary>
/// 流程已作废。
/// </summary>
public const string Invalid = "invalid";
/// <summary>
/// 流程已过期。
/// </summary>
public const string Expired = "expired";
/// <summary>
/// 创建、发送、同步或归档失败,可由管理员重试。
/// </summary>
public const string Failed = "failed";
}
/// <summary> /// <summary>
/// 合同变量类型。 /// 合同变量类型。
/// </summary> /// </summary>
@@ -0,0 +1,102 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 契约锁开放平台连接及入职电子签业务配置。
/// </summary>
public class QiyuesuoSettingOptions
{
/// <summary>
/// 是否启用契约锁电子签功能。
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// 契约锁开放平台地址;测试环境为 https://openapi.qiyuesuo.cn。
/// </summary>
public string ServerUrl { get; set; } = "https://openapi.qiyuesuo.cn";
/// <summary>
/// 契约锁应用 App Token。
/// </summary>
public string AppToken { get; set; }
/// <summary>
/// 契约锁应用 App Secret。
/// </summary>
public string AppSecret { get; set; }
/// <summary>
/// 签名版本;留空使用兼容性最好的 MD5,设置 HMACSHA256 使用新版签名。
/// </summary>
public string SignatureVersion { get; set; }
/// <summary>
/// 契约锁企业成员中的合同创建人姓名。
/// </summary>
public string CreatorName { get; set; }
/// <summary>
/// 契约锁企业成员中的合同创建人手机号。
/// </summary>
public string CreatorMobile { get; set; }
/// <summary>
/// 契约锁合同状态回调地址。
/// </summary>
public string CallbackUrl { get; set; }
/// <summary>
/// 员工从契约锁签署页面返回时跳转的前端地址;可为空。
/// </summary>
public string SignPageCallbackUrl { get; set; }
/// <summary>
/// 员工签署页链接有效分钟数。
/// </summary>
public int SignUrlExpireMinutes { get; set; } = 10;
/// <summary>
/// DOCX 中定位员工签名位置的关键字。
/// </summary>
public string EmployeeSignKeyword { get; set; } = "乙方(劳动者)签字:";
/// <summary>
/// 员工签名关键字在单份文档中重复时使用第几个,从 1 开始。
/// </summary>
public int EmployeeSignKeywordIndex { get; set; } = 1;
/// <summary>
/// DOCX 中定位企业盖章位置的关键字。
/// </summary>
public string CompanySealKeyword { get; set; } = "甲方(盖章):";
/// <summary>
/// 企业盖章关键字在单份文档中重复时使用第几个,从 1 开始。
/// </summary>
public int CompanySealKeywordIndex { get; set; } = 1;
/// <summary>
/// 企业盖章相对关键字的横向偏移量,范围 (-1, 1),正数向右、负数向左。
/// </summary>
public decimal CompanySealOffsetX { get; set; }
/// <summary>
/// 企业盖章相对关键字的纵向偏移量,范围 (-1, 1),正数向上、负数向下。
/// </summary>
public decimal CompanySealOffsetY { get; set; }
/// <summary>
/// 历史版本未单独设置开关时,是否在企业盖章时加盖骑缝章;新版本显式开关优先。
/// </summary>
public bool EnableAcrossPageSeal { get; set; } = true;
/// <summary>
/// 骑缝章方式:ALL_DOCUMENT(所选文档合并)或 SINGLE_DOCUMENT(所选文档各自独立)。
/// </summary>
public string AcrossPageSealType { get; set; } = "ALL_DOCUMENT";
/// <summary>
/// 单次 HTTP 请求超时秒数。
/// </summary>
public int TimeoutSeconds { get; set; } = 90;
}
@@ -1,27 +1,35 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 实现入职审核业务能力。 /// 实现入职任务审核与退回业务能力。
/// </summary> /// </summary>
public class OnboardingAuditService : IOnboardingAuditService public class OnboardingAuditService : IOnboardingAuditService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IConfigService _config;
private readonly IOnboardingContractSnapshotService _snapshotService; private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingEmployeeContractSnapshotService _employeeContractSnapshotService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingAuditService( public OnboardingAuditService(
IConfigService config, IOnboardingContractSnapshotService snapshotService,
IOnboardingContractSnapshotService snapshotService) IOnboardingEmployeeContractSnapshotService employeeContractSnapshotService,
IOnboardingDataPermissionService dataPermission)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_config = config;
_snapshotService = snapshotService; _snapshotService = snapshotService;
_employeeContractSnapshotService = employeeContractSnapshotService;
_dataPermission = dataPermission;
} }
/// <summary> /// <summary>
/// 审核通过入职任务,并在同一事务中创建正式员工档案。 /// 审核通过入职任务,并在同一事务中创建正式员工档案。
/// </summary> /// </summary>
public async Task Approve(OnboardingAuditActionInput input) 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 task = await GetAuditableTask(input.TaskId);
var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>() var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
@@ -44,21 +52,57 @@ public class OnboardingAuditService : IOnboardingAuditService
throw Oops.Bah("该身份证号或手机号已存在正式员工"); throw Oops.Bah("该身份证号或手机号已存在正式员工");
} }
var contractStartDate = await ResolveContractStartDate(task.Id);
var employee = employeeInfo.Adapt<ObEmployee>(); 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.Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
employee.CompanyId = task.CompanyId; employee.CompanyId = task.CompanyId;
employee.DepartmentId = task.DepartmentId;
employee.PositionId = task.PositionId; employee.PositionId = task.PositionId;
employee.DepartmentName = employeeInfo.OnboardingDepartment; 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.College = employeeInfo.GraduatedSchool;
employee.BirthDate = ParseDate(employeeInfo.Birthday); employee.BirthDate = ParseDate(employeeInfo.Birthday);
employee.ContactAddress = employeeInfo.CurrentAddress; employee.ContactAddress = employeeInfo.CurrentAddress;
employee.EntryDate = ParseDate(employeeInfo.OnboardingDate); employee.EntryDate = contractStartDate;
employee.ContractStartDate = contractStartDate;
employee.Remark = employeeInfo.OtherRemarks; employee.Remark = employeeInfo.OtherRemarks;
employee.ExtraInfoJson = global::System.Text.Json.JsonSerializer.Serialize(employeeInfo); employee.ExtraInfoJson = global::System.Text.Json.JsonSerializer.Serialize(employeeInfo);
employee.Status = CommonStatusConst.ENABLE; employee.Status = CommonStatusConst.ENABLE;
employee.CreateTime = DateTime.Now; employee.CreateTime = DateTime.Now;
employee.CreateUserId = UserManager.UserId; // 员工档案归属于原任务创建人,而不是执行审核的人。
employee.CreateUser = UserManager.Name; employee.CreateUserId = task.CreateUserId;
employee.CreateUser = task.CreateUser;
employee.UpdateTime = null; employee.UpdateTime = null;
employee.UpdateUserId = null; employee.UpdateUserId = null;
employee.UpdateUser = null; employee.UpdateUser = null;
@@ -68,13 +112,45 @@ public class OnboardingAuditService : IOnboardingAuditService
await _db.Ado.BeginTranAsync(); await _db.Ado.BeginTranAsync();
try 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 _snapshotService.Build(task.Id, employeeInfo);
await _db.Insertable(employee).ExecuteCommandAsync(); 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.EmployeeId = employee.Id;
task.TaskStatus = OnboardingTaskStatus.Approved; task.TaskStatus = OnboardingTaskStatus.Approved;
task.AuditedAt = DateTime.Now; task.AuditedAt = DateTime.Now;
await _db.Updateable(task).ExecuteCommandAsync();
await WriteAudit(task, OnboardingAuditAction.Approve, input.AuditOpinion); await WriteAudit(task, OnboardingAuditAction.Approve, input.AuditOpinion);
await _db.Ado.CommitTranAsync(); await _db.Ado.CommitTranAsync();
} }
@@ -89,6 +165,11 @@ public class OnboardingAuditService : IOnboardingAuditService
/// 退回入职任务,保存审核意见并允许候选人补充资料。 /// 退回入职任务,保存审核意见并允许候选人补充资料。
/// </summary> /// </summary>
public async Task Reject(OnboardingAuditActionInput input) 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)) if (string.IsNullOrWhiteSpace(input.AuditOpinion))
{ {
@@ -96,10 +177,34 @@ public class OnboardingAuditService : IOnboardingAuditService
} }
var task = await GetAuditableTask(input.TaskId); var task = await GetAuditableTask(input.TaskId);
task.TaskStatus = OnboardingTaskStatus.Rejected; await _db.Ado.BeginTranAsync();
task.RejectReason = input.AuditOpinion; try
await _db.Updateable(task).ExecuteCommandAsync(); {
await WriteAudit(task, OnboardingAuditAction.Reject, input.AuditOpinion); 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) private async Task<ObOnboardingTask> GetAuditableTask(long taskId)
@@ -111,30 +216,56 @@ public class OnboardingAuditService : IOnboardingAuditService
throw Oops.Bah("当前任务不在审核中"); throw Oops.Bah("当前任务不在审核中");
} }
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds") await _dataPermission.EnsureCanManage(
?? throw Oops.Bah("请配置入职审核人"); task.CreateUserId,
using var configJson = global::System.Text.Json.JsonDocument.Parse(config.ConfigValue); OnboardingPermissionCodes.TaskManageAll,
// 审核人由全局配置与公司专属配置合并得出,不依赖独立业务表。 "入职任务");
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 task; 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>
/// 将登记表中的日期文本转换为正式员工档案日期。 /// 将登记表中的日期文本转换为正式员工档案日期。
/// </summary> /// </summary>
@@ -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";
}
@@ -30,19 +30,64 @@ public class OnboardingCompanyAddInput : ObCompany
/// 公司业务编码。 /// 公司业务编码。
/// </summary> /// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司编码不能为空")] [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司编码不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司编码不能超过50个字符")]
public override string CompanyCode { get; set; } public override string CompanyCode { get; set; }
/// <summary> /// <summary>
/// 公司名称。 /// 公司名称。
/// </summary> /// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司名称不能为空")] [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "公司名称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "公司名称不能超过50个字符")]
public override string CompanyName { get; set; } public override string CompanyName { get; set; }
/// <summary> /// <summary>
/// 合同甲方完整法定名称。 /// 合同甲方完整法定名称。
/// </summary> /// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "合同甲方全称不能为空")] [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "合同甲方全称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(100, ErrorMessage = "合同甲方全称不能超过100个字符")]
public override string LegalName { get; set; } 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> /// <summary>
@@ -5,6 +5,13 @@ namespace SimpleAdmin.Application;
/// </summary> /// </summary>
public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingCompanyService public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingCompanyService
{ {
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingCompanyService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary> /// <summary>
/// 分页查询入职业务公司。 /// 分页查询入职业务公司。
/// </summary> /// </summary>
@@ -14,7 +21,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyCode), it => it.CompanyCode.Contains(input.CompanyCode)) .WhereIF(!string.IsNullOrWhiteSpace(input.CompanyCode), it => it.CompanyCode.Contains(input.CompanyCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.CompanyName), it => it.CompanyName.Contains(input.CompanyName)) .WhereIF(!string.IsNullOrWhiteSpace(input.CompanyName), it => it.CompanyName.Contains(input.CompanyName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}") .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyCode", "CompanyName", "LegalName", "ContactPhone"))
.OrderBy(it => it.CreateTime) .OrderBy(it => it.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
} }
@@ -43,6 +50,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
/// </summary> /// </summary>
public async Task Add(OnboardingCompanyAddInput input) public async Task Add(OnboardingCompanyAddInput input)
{ {
NormalizeAndValidateQiyuesuoConfig(input);
await EnsureCodeUnique(input.CompanyCode, 0); await EnsureCodeUnique(input.CompanyCode, 0);
await InsertAsync(input.Adapt<ObCompany>()); await InsertAsync(input.Adapt<ObCompany>());
} }
@@ -52,7 +60,12 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
/// </summary> /// </summary>
public async Task Edit(OnboardingCompanyEditInput input) 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 EnsureCodeUnique(input.CompanyCode, input.Id);
await UpdateAsync(input.Adapt<ObCompany>()); await UpdateAsync(input.Adapt<ObCompany>());
} }
@@ -65,11 +78,36 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
if (input.Ids.Count == 0) if (input.Ids.Count == 0)
return; 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>() var hasPositions = await Context.Queryable<ObPosition>()
.AnyAsync(it => input.Ids.Contains(it.CompanyId)); .AnyAsync(it => input.Ids.Contains(it.CompanyId));
if (hasPositions) if (hasPositions)
throw Oops.Bah("公司下仍有岗位,不能删除"); 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>() var hasTasks = await Context.Queryable<ObOnboardingTask>()
.AnyAsync(it => input.Ids.Contains(it.CompanyId)); .AnyAsync(it => input.Ids.Contains(it.CompanyId));
if (hasTasks) if (hasTasks)
@@ -83,6 +121,36 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray()); await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
} }
private static void NormalizeAndValidateQiyuesuoConfig(ObCompany company)
{
company.CompanyCode = OnboardingCodeValidator.Normalize(company.CompanyCode, "公司编码", 50);
company.CompanyName = company.CompanyName?.Trim();
company.LegalName = company.LegalName?.Trim();
if (string.IsNullOrWhiteSpace(company.CompanyName))
throw Oops.Bah("公司名称不能为空");
company.QiyuesuoCategoryId = company.QiyuesuoCategoryId?.Trim();
company.QiyuesuoSealId = company.QiyuesuoSealId?.Trim();
var hasCategoryId = !string.IsNullOrWhiteSpace(company.QiyuesuoCategoryId);
var hasSealId = !string.IsNullOrWhiteSpace(company.QiyuesuoSealId);
if (hasCategoryId != hasSealId)
throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写");
if (!hasCategoryId)
return;
if (!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) private async Task EnsureCodeUnique(string code, long id)
{ {
var exists = await Context.Queryable<ObCompany>() var exists = await Context.Queryable<ObCompany>()
@@ -11,3 +11,63 @@ public class OnboardingGenerateContractInput
[IdNotNull(ErrorMessage = "任务合同Id不能为空")] [IdNotNull(ErrorMessage = "任务合同Id不能为空")]
public long TaskContractId { get; set; } public long TaskContractId { get; set; }
} }
/// <summary>
/// 人事在创建入职任务前下载合同预览的参数。
/// </summary>
public class OnboardingContractPreviewInput
{
/// <summary>
/// 入职业务公司 Id。
/// </summary>
[IdNotNull(ErrorMessage = "公司Id不能为空")]
public long CompanyId { get; set; }
/// <summary>
/// 入职业务部门 Id。
/// </summary>
[IdNotNull(ErrorMessage = "部门Id不能为空")]
public long DepartmentId { get; set; }
/// <summary>
/// 入职岗位 Id。
/// </summary>
[IdNotNull(ErrorMessage = "岗位Id不能为空")]
public long PositionId { get; set; }
/// <summary>
/// 当前选择的入职模板 Id。
/// </summary>
[IdNotNull(ErrorMessage = "入职模板Id不能为空")]
public long OnboardingTemplateId { get; set; }
/// <summary>
/// 本次需要预览的合同模板版本 Id;批量预览时无需传入。
/// </summary>
public long? ContractTemplateVersionId { get; set; }
/// <summary>
/// 人事当前在创建表单中填写的合同变量。
/// </summary>
public List<OnboardingInitialContractVariableInput> ContractVariables
{
get;
set;
} = new();
}
/// <summary>
/// 创建任务前生成的临时 DOCX 预览文件。
/// </summary>
public class OnboardingContractPreviewOutput
{
/// <summary>
/// DOCX 文件内容,仅通过当前请求返回,不保存到文件表或 OSS。
/// </summary>
public byte[] FileBytes { get; set; }
/// <summary>
/// 浏览器下载时使用的文件名。
/// </summary>
public string FileName { get; set; }
}
@@ -5,8 +5,25 @@ namespace SimpleAdmin.Application;
/// </summary> /// </summary>
public interface IOnboardingContractDocumentService : ITransient public interface IOnboardingContractDocumentService : ITransient
{ {
/// <summary>
/// 创建入职任务前,根据人事当前填写的参数生成临时 DOCX 预览。
/// </summary>
Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
OnboardingContractPreviewInput input);
/// <summary>
/// 创建入职任务前,按模板顺序生成全部合同预览并打包为 ZIP。
/// </summary>
Task<OnboardingContractPreviewOutput> PreviewAllBeforeCreate(
OnboardingContractPreviewInput input);
/// <summary> /// <summary>
/// 根据已审核通过的任务合同生成并保存 DOCX 文件。 /// 根据已审核通过的任务合同生成并保存 DOCX 文件。
/// </summary> /// </summary>
Task<long> Generate(OnboardingGenerateContractInput input); Task<long> Generate(OnboardingGenerateContractInput input);
/// <summary>
/// 员工公开入口已验证一次性任务链接后,生成该链接所属的合同文件。
/// </summary>
Task<long> GenerateForPublicAccess(long taskContractId, long taskId);
} }
@@ -7,26 +7,342 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IFileService _fileService; private readonly IFileService _fileService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingContractDocumentService(IFileService fileService) public OnboardingContractDocumentService(
IFileService fileService,
IOnboardingDataPermissionService dataPermission)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_fileService = fileService; _fileService = fileService;
_dataPermission = dataPermission;
}
/// <summary>
/// 在任务创建前生成指定合同的临时 DOCX,仅通过响应返回,不写文件表和 OSS。
/// </summary>
public async Task<OnboardingContractPreviewOutput> PreviewBeforeCreate(
OnboardingContractPreviewInput input)
{
var context = await LoadPreviewContext(input);
var contractConfig = input.ContractTemplateVersionId.HasValue
? context.ContractConfigs.FirstOrDefault(item =>
item.ContractTemplateVersionId == input.ContractTemplateVersionId.Value)
: context.ContractConfigs.FirstOrDefault();
if (contractConfig == null)
throw Oops.Bah("所选合同不属于当前入职模板或已被移除");
return await RenderPreview(input, context, contractConfig, null);
}
/// <summary>
/// 在任务创建前生成全部合同预览,并按模板顺序打包为 ZIP。
/// </summary>
public async Task<OnboardingContractPreviewOutput> PreviewAllBeforeCreate(
OnboardingContractPreviewInput input)
{
var context = await LoadPreviewContext(input);
using var zipBytes = new MemoryStream();
using (var archive = new global::System.IO.Compression.ZipArchive(
zipBytes,
global::System.IO.Compression.ZipArchiveMode.Create,
true))
{
for (var index = 0; index < context.ContractConfigs.Count; index++)
{
var preview = await RenderPreview(
input,
context,
context.ContractConfigs[index],
index + 1);
var entry = archive.CreateEntry(
preview.FileName,
global::System.IO.Compression.CompressionLevel.Fastest);
await using var entryStream = entry.Open();
await entryStream.WriteAsync(preview.FileBytes);
}
}
return new OnboardingContractPreviewOutput
{
FileBytes = zipBytes.ToArray(),
FileName = $"全部合同_人事预览_{DateTime.Now:yyyyMMddHHmmss}.zip"
};
}
/// <summary>
/// 校验预览上下文,并读取入职模板锁定的全部合同配置。
/// </summary>
private async Task<ContractPreviewContext> LoadPreviewContext(
OnboardingContractPreviewInput input)
{
var company = await _db.Queryable<ObCompany>()
.FirstAsync(item => item.Id == input.CompanyId
&& item.Status == CommonStatusConst.ENABLE)
?? throw Oops.Bah("公司不存在或未启用");
var departmentExists = await _db.Queryable<ObDepartment>()
.AnyAsync(item => item.Id == input.DepartmentId
&& item.CompanyId == input.CompanyId
&& item.Status == CommonStatusConst.ENABLE);
if (!departmentExists)
throw Oops.Bah("部门不存在、未启用或不属于该公司");
var positionExists = await _db.Queryable<ObPosition>()
.AnyAsync(item => item.Id == input.PositionId
&& item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == CommonStatusConst.ENABLE);
if (!positionExists)
throw Oops.Bah("岗位不存在、未启用或不属于该部门/公司");
var onboardingTemplate = await _db.Queryable<ObOnboardingTemplate>()
.FirstAsync(item => item.Id == input.OnboardingTemplateId
&& item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == CommonStatusConst.ENABLE)
?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司、部门");
var supportsPosition = await _db.Queryable<ObOnboardingTemplatePosition>()
.AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id
&& item.PositionId == input.PositionId);
if (!supportsPosition)
throw Oops.Bah("所选入职模板不适用于该岗位");
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => item.OnboardingTemplateId == onboardingTemplate.Id)
.OrderBy(item => item.SortCode)
.OrderBy(item => item.Id)
.ToListAsync();
if (contractConfigs.Count == 0)
throw Oops.Bah("入职模板尚未配置合同文件");
var duplicateVersion = contractConfigs
.GroupBy(item => item.ContractTemplateVersionId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateVersion != null)
throw Oops.Bah("入职模板中存在重复的合同版本,请先调整模板配置");
var templateIds = contractConfigs
.Select(item => item.ContractTemplateId)
.Distinct()
.ToList();
var contractTemplates = await _db.Queryable<ObContractTemplate>()
.Where(item => templateIds.Contains(item.Id))
.ToListAsync();
if (contractTemplates.Count != templateIds.Count
|| contractTemplates.Any(item => item.CompanyId != input.CompanyId
|| item.DepartmentId != input.DepartmentId
|| !string.Equals(
item.Status,
CommonStatusConst.ENABLE,
global::System.StringComparison.OrdinalIgnoreCase)))
{
throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板");
}
var supportedTemplateCount = await _db.Queryable<ObContractTemplatePosition>()
.CountAsync(item => templateIds.Contains(item.ContractTemplateId)
&& item.PositionId == input.PositionId);
if (supportedTemplateCount != templateIds.Count)
throw Oops.Bah("入职模板包含不适用于当前岗位的合同模板");
var versionIds = contractConfigs
.Select(item => item.ContractTemplateVersionId)
.ToList();
var versions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
var now = DateTime.Now;
var versionsValid = versions.Count == versionIds.Count
&& contractConfigs.All(config => versions.Any(version =>
version.Id == config.ContractTemplateVersionId
&& version.TemplateId == config.ContractTemplateId
&& ContractTemplateVersionValidity.IsAvailable(version, now)));
if (!versionsValid)
throw Oops.Bah("入职模板包含不存在、未生效、已失效、未启用或归属错误的签署材料版本");
var inputs = input.ContractVariables
?? new List<OnboardingInitialContractVariableInput>();
OnboardingTaskService.ValidateInitialContractDateRanges(inputs);
var duplicateInput = inputs
.GroupBy(item => new
{
item.ContractTemplateVersionId,
VariableCode = item.VariableCode?.ToUpperInvariant()
})
.FirstOrDefault(group => group.Count() > 1);
if (duplicateInput != null)
throw Oops.Bah($"合同变量“{duplicateInput.Key.VariableCode}”重复提交");
var unknownVersionInput = inputs.FirstOrDefault(item =>
!versionIds.Contains(item.ContractTemplateVersionId));
if (unknownVersionInput != null)
throw Oops.Bah($"合同变量“{unknownVersionInput.VariableCode}”不属于当前入职模板");
return new ContractPreviewContext
{
Company = company,
ContractConfigs = contractConfigs,
ContractTemplates = contractTemplates,
Versions = versions
};
}
/// <summary>
/// 根据一条合同配置和当前人事参数渲染一份 DOCX。
/// </summary>
private async Task<OnboardingContractPreviewOutput> RenderPreview(
OnboardingContractPreviewInput input,
ContractPreviewContext context,
ObOnboardingTemplateContract contractConfig,
int? fileOrder)
{
var contractTemplate = context.ContractTemplates.First(item =>
item.Id == contractConfig.ContractTemplateId);
var version = context.Versions.First(item =>
item.Id == contractConfig.ContractTemplateVersionId);
var definitions = await _db.Queryable<ObTemplateVariable>()
.Where(item => item.TemplateVersionId == version.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var duplicateDefinition = definitions
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateDefinition != null)
throw Oops.Bah($"合同变量“{duplicateDefinition.Key}”存在重复定义");
var inputs = (input.ContractVariables
?? new List<OnboardingInitialContractVariableInput>())
.Where(item => item.ContractTemplateVersionId == version.Id)
.ToList();
var editableDefinitions = definitions
.Where(IsHrEditableDefinition)
.ToList();
foreach (var submitted in inputs)
{
var definitionExists = editableDefinitions.Any(definition =>
string.Equals(
definition.VariableCode,
submitted.VariableCode,
StringComparison.OrdinalIgnoreCase));
if (!definitionExists)
throw Oops.Bah($"合同变量“{submitted.VariableCode}”不存在或不允许人事填写");
}
var missingRequired = editableDefinitions.FirstOrDefault(definition =>
{
if (!definition.Required)
return false;
var submitted = inputs.FirstOrDefault(item =>
string.Equals(
item.VariableCode,
definition.VariableCode,
StringComparison.OrdinalIgnoreCase));
return string.IsNullOrWhiteSpace(
submitted?.VariableValue ?? definition.DefaultValue);
});
if (missingRequired != null)
throw Oops.Bah($"请填写合同变量“{missingRequired.VariableName}”");
var previewTask = new ObOnboardingTask
{
TaskNo = "【发起后自动生成】",
CompanyId = input.CompanyId,
DepartmentId = input.DepartmentId,
PositionId = input.PositionId,
OnboardingTemplateId = input.OnboardingTemplateId
};
var emptyDynamicValues = new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
var values = definitions.ToDictionary(
definition => definition.VariableCode,
definition =>
{
var submitted = inputs.FirstOrDefault(item =>
string.Equals(
item.VariableCode,
definition.VariableCode,
StringComparison.OrdinalIgnoreCase));
if (submitted != null)
return submitted.VariableValue ?? string.Empty;
return OnboardingContractSnapshotService.ResolveValue(
definition,
null,
emptyDynamicValues,
context.Company,
previewTask,
true)
?? string.Empty;
},
StringComparer.OrdinalIgnoreCase);
var source = await _fileService.Download(
new BaseIdInput { Id = version.SourceDocxFileId })
?? throw Oops.Bah("无法读取合同源文件");
await using var sourceStream = source.FileStream;
using var sourceBytes = new MemoryStream();
await sourceStream.CopyToAsync(sourceBytes);
return new OnboardingContractPreviewOutput
{
FileBytes = DocxTemplateProcessor.Render(
sourceBytes.ToArray(),
values),
FileName = BuildPreviewFileName(contractTemplate.TemplateName, fileOrder)
};
} }
/// <summary> /// <summary>
/// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。 /// 根据锁定的模板版本和任务变量快照生成已替换变量的合同 DOCX。
/// </summary> /// </summary>
public async Task<long> Generate(OnboardingGenerateContractInput input) 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) var contract = await _db.Queryable<ObTaskContract>().InSingleAsync(input.TaskContractId)
?? throw Oops.Bah("任务合同不存在"); ?? throw Oops.Bah("任务合同不存在");
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(contract.TaskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(contract.TaskId)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
return await GenerateCore(contract, task);
}
/// <inheritdoc />
public async Task<long> GenerateForPublicAccess(long taskContractId, long taskId)
{
return await OnboardingConcurrency.RunTask(_db, taskId, () => GenerateForPublicAccessCore(taskContractId, taskId));
}
private async Task<long> GenerateForPublicAccessCore(long taskContractId, long taskId)
{
var contract = await _db.Queryable<ObTaskContract>()
.FirstAsync(item => item.Id == taskContractId && item.TaskId == taskId)
?? throw Oops.Bah("合同不存在");
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在");
return await GenerateCore(contract, task);
}
/// <summary>
/// 执行已经完成调用方权限或公开链接归属校验的合同生成逻辑。
/// </summary>
private async Task<long> GenerateCore(ObTaskContract contract, ObOnboardingTask task)
{
var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill
|| task.TaskStatus == OnboardingTaskStatus.Rejected || task.TaskStatus == OnboardingTaskStatus.Rejected
|| task.TaskStatus == OnboardingTaskStatus.Reviewing || task.TaskStatus == OnboardingTaskStatus.Reviewing
|| task.TaskStatus == OnboardingTaskStatus.Approved; || task.TaskStatus == OnboardingTaskStatus.Approved
|| task.TaskStatus == OnboardingTaskStatus.Archived;
if (!statusAllowsGeneration) if (!statusAllowsGeneration)
throw Oops.Bah("当前任务状态不能生成合同"); throw Oops.Bah("当前任务状态不能生成合同");
if (contract.GeneratedDocxFileId.HasValue) if (contract.GeneratedDocxFileId.HasValue)
@@ -42,12 +358,21 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
var variables = await _db.Queryable<ObTaskContractVariable>() var variables = await _db.Queryable<ObTaskContractVariable>()
.Where(item => item.TaskContractId == contract.Id) .Where(item => item.TaskContractId == contract.Id)
.ToListAsync(); .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 var missing = definitions
.Where(item => item.Required) .Where(item => item.Required)
.FirstOrDefault(definition => string.IsNullOrWhiteSpace( .FirstOrDefault(definition => string.IsNullOrWhiteSpace(
variables.FirstOrDefault(variable => variable.VariableCode == definition.VariableCode)?.VariableValue)); variables.FirstOrDefault(variable => variable.VariableCode == definition.VariableCode)?.VariableValue));
if (missing != null) if (missing != null)
throw Oops.Bah($"合同变量“{missing.VariableName}”尚未填写,暂时不能预览合同"); throw Oops.Bah($"合同“{contract.ContractName}”的变量“{missing.VariableName}”尚未填写,暂时不能预览合同");
var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id }) var sourceResult = await _fileService.Download(new BaseIdInput { Id = source.Id })
?? throw Oops.Bah("无法读取合同源文件"); ?? throw Oops.Bah("无法读取合同源文件");
@@ -62,8 +387,19 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
item => item.VariableValue ?? string.Empty, item => item.VariableValue ?? string.Empty,
StringComparer.OrdinalIgnoreCase)); StringComparer.OrdinalIgnoreCase));
var generatedAt = DateTime.Now; var generatedAt = DateTime.Now;
// 在文件名中加入生成批次,避免手机系统把同名的新合同误认为历史下载文件。 var employeeInfo = await _db.Queryable<ObTaskEmployeeInfo>()
var name = $"{task.TaskNo}_{contract.ContractName}_{generatedAt:yyyyMMddHHmmssfff}.docx"; .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( var fileId = await _fileService.StoreGeneratedFile(
SysDictConst.FILE_ENGINE_ALIYUN_OSS, SysDictConst.FILE_ENGINE_ALIYUN_OSS,
name, name,
@@ -77,4 +413,45 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ
await _db.Updateable(contract).ExecuteCommandAsync(); await _db.Updateable(contract).ExecuteCommandAsync();
return fileId; return fileId;
} }
/// <summary>
/// 判断变量是否允许人事在发起任务前填写。
/// </summary>
private static bool IsHrEditableDefinition(ObTemplateVariable definition)
{
return definition.HrEditable
&& (definition.VariableType == "hr"
|| definition.VariableType == ContractVariableType.Contract
|| definition.VariableType == ContractVariableType.Company
|| definition.VariableCode.StartsWith(
"hr.",
StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 生成兼容 Windows 和 Linux 的合同预览下载文件名。
/// </summary>
private static string BuildPreviewFileName(string contractName, int? fileOrder = null)
{
var invalidChars = global::System.IO.Path.GetInvalidFileNameChars();
var safeName = new string((contractName ?? "合同")
.Select(character => invalidChars.Contains(character) ? '_' : character)
.ToArray());
var orderPrefix = fileOrder.HasValue ? $"{fileOrder.Value:D2}_" : string.Empty;
return $"{orderPrefix}{safeName}_人事预览_{DateTime.Now:yyyyMMddHHmmss}.docx";
}
/// <summary>
/// 创建任务前预览合同所需的已校验数据。
/// </summary>
private sealed class ContractPreviewContext
{
public ObCompany Company { get; init; }
public List<ObOnboardingTemplateContract> ContractConfigs { get; init; }
public List<ObContractTemplate> ContractTemplates { get; init; }
public List<ObContractTemplateVersion> Versions { get; init; }
}
} }
@@ -19,6 +19,14 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
long taskId, long taskId,
ObTaskEmployeeInfo employeeInfo = null, ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false) bool invalidateGeneratedPreview = false)
{
await OnboardingConcurrency.RunTask(_db, taskId, () => BuildCore(taskId, employeeInfo, invalidateGeneratedPreview), transactional: true);
}
private async Task BuildCore(
long taskId,
ObTaskEmployeeInfo employeeInfo = null,
bool invalidateGeneratedPreview = false)
{ {
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
@@ -53,6 +61,7 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
VariableName = definition.VariableName, VariableName = definition.VariableName,
VariableType = definition.VariableType, VariableType = definition.VariableType,
DataType = definition.DataType, DataType = definition.DataType,
SortCode = definition.SortCode ?? 100,
VariableValue = preserveManual VariableValue = preserveManual
? old.VariableValue ? old.VariableValue
: ResolveValue(definition, employeeInfo, dynamicValueMap, company, task), : ResolveValue(definition, employeeInfo, dynamicValueMap, company, task),
@@ -63,13 +72,29 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
}; };
}).ToList(); }).ToList();
// 仅当参与合同渲染的变量编码或变量值发生变化时,才作废已生成文件。
// 重复保存完全相同的员工资料不应导致员工刚查看的合同突然失效。
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>() await _db.Deleteable<ObTaskContractVariable>()
.Where(item => item.TaskContractId == contract.Id) .Where(item => item.TaskContractId == contract.Id)
.ExecuteCommandAsync(); .ExecuteCommandAsync();
if (rows.Count > 0) if (rows.Count > 0)
await _db.Insertable(rows).ExecuteCommandAsync(); await _db.Insertable(rows).ExecuteCommandAsync();
if (invalidateGeneratedPreview && task.TaskStatus is OnboardingTaskStatus.PendingFill or OnboardingTaskStatus.Rejected) if (invalidateGeneratedPreview
&& snapshotChanged
&& task.TaskStatus is OnboardingTaskStatus.PendingFill or OnboardingTaskStatus.Rejected)
{ {
contract.GeneratedDocxFileId = null; contract.GeneratedDocxFileId = null;
contract.GeneratedPdfFileId = null; contract.GeneratedPdfFileId = null;
@@ -93,12 +118,13 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
}; };
} }
private static string ResolveValue( internal static string ResolveValue(
ObTemplateVariable definition, ObTemplateVariable definition,
ObTaskEmployeeInfo employeeInfo, ObTaskEmployeeInfo employeeInfo,
IReadOnlyDictionary<string, string> dynamicValues, IReadOnlyDictionary<string, string> dynamicValues,
ObCompany company, ObCompany company,
ObOnboardingTask task) ObOnboardingTask task,
bool useEmployeePlaceholder = false)
{ {
var sourceField = GetSourceField(definition); var sourceField = GetSourceField(definition);
if (definition.VariableType == ContractVariableType.Employee) if (definition.VariableType == ContractVariableType.Employee)
@@ -116,15 +142,29 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
if (doc.RootElement.TryGetProperty(sourceField, out var prop) if (doc.RootElement.TryGetProperty(sourceField, out var prop)
|| doc.RootElement.TryGetProperty(definition.VariableCode, out prop)) || doc.RootElement.TryGetProperty(definition.VariableCode, out prop))
{ {
var str = prop.GetString(); var str = prop.ValueKind switch
{
global::System.Text.Json.JsonValueKind.String => prop.GetString(),
global::System.Text.Json.JsonValueKind.Number => prop.ToString(),
global::System.Text.Json.JsonValueKind.True => bool.TrueString,
global::System.Text.Json.JsonValueKind.False => bool.FalseString,
global::System.Text.Json.JsonValueKind.Null => null,
_ => throw Oops.Bah($"员工补充变量“{definition.VariableName}”格式不正确")
};
if (!string.IsNullOrWhiteSpace(str)) return str; if (!string.IsNullOrWhiteSpace(str)) return str;
} }
} }
catch { } catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah("员工补充变量数据格式异常,请重新保存入职登记表后再生成合同");
}
} }
return dynamicValues.TryGetValue(sourceField, out var dynamicValue) if (dynamicValues.TryGetValue(sourceField, out var dynamicValue))
? dynamicValue return dynamicValue;
return useEmployeePlaceholder
? $"【待员工填写:{definition.VariableName ?? definition.VariableCode}】"
: definition.DefaultValue; : definition.DefaultValue;
} }
@@ -192,7 +232,8 @@ public class OnboardingContractSnapshotService : IOnboardingContractSnapshotServ
{ {
return sourceField.ToUpperInvariant() switch return sourceField.ToUpperInvariant() switch
{ {
"COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.CompanyCode), // 内置合同变量 CompanyCode 表示统一社会信用代码,保留旧 DOCX 占位符兼容。
"COMPANYCODE" or "COMPANY_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
"COMPANYNAME" or "COMPANY_NAME" => nameof(ObCompany.CompanyName), "COMPANYNAME" or "COMPANY_NAME" => nameof(ObCompany.CompanyName),
"LEGALNAME" or "LEGAL_NAME" or "COMPANYLEGALNAME" => nameof(ObCompany.LegalName), "LEGALNAME" or "LEGAL_NAME" or "COMPANYLEGALNAME" => nameof(ObCompany.LegalName),
"UNIFIEDSOCIALCREDITCODE" or "UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode), "UNIFIEDSOCIALCREDITCODE" or "UNIFIED_SOCIAL_CREDIT_CODE" => nameof(ObCompany.UnifiedSocialCreditCode),
@@ -17,25 +17,106 @@ public static class DocxTemplateProcessor
/// </summary> /// </summary>
public static List<string> ExtractVariableCodes(byte[] docx) public static List<string> ExtractVariableCodes(byte[] docx)
{ {
using var input = new MemoryStream(docx); ValidateDocxHeader(docx);
using var archive = new global::System.IO.Compression.ZipArchive( try
input,
global::System.IO.Compression.ZipArchiveMode.Read);
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
{ {
using var stream = entry.Open(); using var input = new MemoryStream(docx);
var document = global::System.Xml.Linq.XDocument.Load(stream); using var archive = new global::System.IO.Compression.ZipArchive(
foreach (var paragraph in document.Descendants(WordNamespace + "p")) input,
global::System.IO.Compression.ZipArchiveMode.Read);
if (archive.GetEntry("word/document.xml") == null)
throw Oops.Bah("上传文件不是有效的 DOCX 文档,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
{ {
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value)); using var stream = entry.Open();
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text)) var document = global::System.Xml.Linq.XDocument.Load(stream);
codes.Add(match.Groups["code"].Value.Trim()); foreach (var paragraph in document.Descendants(WordNamespace + "p"))
{
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
ValidatePlaceholderSyntax(text);
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
codes.Add(match.Groups["code"].Value.Trim());
}
} }
return codes.OrderBy(code => code).ToList();
}
catch (global::System.IO.InvalidDataException)
{
throw Oops.Bah("DOCX 文件结构已经损坏或上传不完整,请重新另存为 DOCX 后上传");
}
catch (global::System.Xml.XmlException)
{
throw Oops.Bah("DOCX 文件内部内容损坏,Word 可以尝试修复该文件;请修复并重新另存为 DOCX 后上传");
}
}
/// <summary>
/// 占位符提取正则只会匹配正确格式,因此必须先主动检查错误格式,
/// 避免缺少花括号或变量编码写错时被静默忽略并继续创建版本。
/// </summary>
private static void ValidatePlaceholderSyntax(string text)
{
if (string.IsNullOrEmpty(text))
return;
if (text.Contains("{{", StringComparison.Ordinal)
|| text.Contains("}}", StringComparison.Ordinal))
{
throw Oops.Bah(
"DOCX 占位符格式错误:检测到中文全角花括号。请使用英文半角双花括号,例如 {{employee.EmployeeName}}。");
} }
return codes.OrderBy(code => code).ToList(); if (text.Contains("{{{", StringComparison.Ordinal)
|| text.Contains("}}}", StringComparison.Ordinal))
{
throw Oops.Bah(
"DOCX 占位符格式错误:检测到多余的花括号。占位符必须使用成对的英文半角双花括号,例如 {{employee.EmployeeName}}。");
}
var searchStart = 0;
while (searchStart < text.Length)
{
var openIndex = text.IndexOf("{{", searchStart, StringComparison.Ordinal);
var closeIndex = text.IndexOf("}}", searchStart, StringComparison.Ordinal);
if (closeIndex >= 0 && (openIndex < 0 || closeIndex < openIndex))
{
throw Oops.Bah(
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, closeIndex)} 缺少对应的起始符“{{{{”。正确格式示例:{{{{employee.EmployeeName}}}}。");
}
if (openIndex < 0)
return;
closeIndex = text.IndexOf("}}", openIndex + 2, StringComparison.Ordinal);
if (closeIndex < 0)
{
throw Oops.Bah(
$"DOCX 占位符格式错误:{BuildPlaceholderPreview(text, openIndex)} 缺少结束符“}}}}”。正确格式示例:{{{{employee.EmployeeName}}}}。");
}
var placeholder = text.Substring(openIndex, closeIndex + 2 - openIndex);
var match = PlaceholderRegex.Match(placeholder);
if (!match.Success || match.Length != placeholder.Length)
{
throw Oops.Bah(
$"DOCX 占位符格式错误:“{placeholder}”。变量编码只能包含英文字母、数字、下划线、点和短横线,并且必须使用成对的英文半角双花括号。");
}
searchStart = closeIndex + 2;
}
}
private static string BuildPlaceholderPreview(string text, int markerIndex)
{
const int maxLength = 60;
var preview = text[markerIndex..].Trim();
if (preview.Length > maxLength)
preview = preview[..maxLength] + "…";
return $"“{preview}”";
} }
/// <summary> /// <summary>
@@ -43,6 +124,7 @@ public static class DocxTemplateProcessor
/// </summary> /// </summary>
public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values) public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values)
{ {
ValidateDocxHeader(docx);
using var input = new MemoryStream(docx); using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive( using var archive = new global::System.IO.Compression.ZipArchive(
input, input,
@@ -157,4 +239,26 @@ public static class DocxTemplateProcessor
|| entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase)) || entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase))
&& entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)); && entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
} }
/// <summary>
/// 根据文件头识别真实格式,避免只修改扩展名的旧版 DOC 在解压时变成系统异常。
/// </summary>
private static void ValidateDocxHeader(byte[] docx)
{
if (docx == null || docx.Length < 4)
throw Oops.Bah("上传的 DOCX 文件为空或上传不完整,请重新选择文件上传");
// 旧版 Word 97-2003 二进制 DOC 使用 OLE Compound File 文件头:D0 CF 11 E0。
if (docx[0] == 0xD0
&& docx[1] == 0xCF
&& docx[2] == 0x11
&& docx[3] == 0xE0)
{
throw Oops.Bah("上传文件实际是旧版 DOC 格式,不能直接修改后缀名;请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
}
// DOCX 是 Open XML ZIP 包,正常文件以 PK 开头。
if (docx[0] != 0x50 || docx[1] != 0x4B)
throw Oops.Bah("上传文件的真实格式不是 DOCX,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
}
} }
@@ -5,6 +5,21 @@ namespace SimpleAdmin.Application;
/// </summary> /// </summary>
public class OnboardingContractTemplatePageInput : BasePageInput 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>
/// 合同模板名称。 /// 合同模板名称。
/// </summary> /// </summary>
@@ -26,6 +41,23 @@ public class OnboardingContractTemplatePageInput : BasePageInput
/// </summary> /// </summary>
public class OnboardingContractTemplateAddInput 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>
/// 合同模板业务编码。 /// 合同模板业务编码。
/// </summary> /// </summary>
@@ -66,3 +98,27 @@ public class OnboardingContractTemplateEditInput : OnboardingContractTemplateAdd
[IdNotNull(ErrorMessage = "模板Id不能为空")] [IdNotNull(ErrorMessage = "模板Id不能为空")]
public long Id { get; set; } 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; } public string FileHash { get; set; }
/// <summary> /// <summary>
/// 生效日期。 /// 生效时间。
/// </summary> /// </summary>
public DateTime? EffectiveDate { get; set; } public DateTime? EffectiveDate { get; set; }
/// <summary> /// <summary>
/// 失效日期。 /// 失效时间。
/// </summary> /// </summary>
public DateTime? ExpiryDate { get; set; } public DateTime? ExpiryDate { get; set; }
/// <summary>本版本是否需要公司盖章;旧客户端未传时沿用原规则。</summary>
public bool? CompanySealEnabled { get; set; }
/// <summary>本版本是否需要骑缝章;旧客户端未传时沿用系统配置。</summary>
public bool? AcrossPageSealEnabled { get; set; }
/// <summary> /// <summary>
/// 业务备注。 /// 业务备注。
/// </summary> /// </summary>
@@ -78,3 +84,19 @@ public class OnboardingContractVersionStatusInput
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "版本状态不能为空")] [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "版本状态不能为空")]
public string VersionStatus { get; set; } 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; }
}
@@ -15,6 +15,11 @@ public interface IOnboardingContractTemplateService : ITransient
/// </summary> /// </summary>
Task<ObContractTemplate> Detail(BaseIdInput input); Task<ObContractTemplate> Detail(BaseIdInput input);
/// <summary>
/// 查询同时适用于指定公司、部门和全部已选岗位的启用合同模板。
/// </summary>
Task<List<ObContractTemplate>> ListAvailable(OnboardingContractTemplateAvailableInput input);
/// <summary> /// <summary>
/// 新增合同模板主档案。 /// 新增合同模板主档案。
/// </summary> /// </summary>
@@ -45,6 +50,11 @@ public interface IOnboardingContractTemplateService : ITransient
/// </summary> /// </summary>
Task SetVersionStatus(OnboardingContractVersionStatusInput input); Task SetVersionStatus(OnboardingContractVersionStatusInput input);
/// <summary>
/// 修改合同模板版本的盖章配置,仅影响后续新建任务。
/// </summary>
Task SetVersionSeals(OnboardingContractVersionSealInput input);
/// <summary> /// <summary>
/// 删除未被入职模板或任务使用的合同版本。 /// 删除未被入职模板或任务使用的合同版本。
/// </summary> /// </summary>
@@ -6,10 +6,14 @@ namespace SimpleAdmin.Application;
public class OnboardingContractTemplateService : DbRepository<ObContractTemplate>, IOnboardingContractTemplateService public class OnboardingContractTemplateService : DbRepository<ObContractTemplate>, IOnboardingContractTemplateService
{ {
private readonly IFileService _fileService; private readonly IFileService _fileService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingContractTemplateService(IFileService fileService) public OnboardingContractTemplateService(
IFileService fileService,
IOnboardingDataPermissionService dataPermission)
{ {
_fileService = fileService; _fileService = fileService;
_dataPermission = dataPermission;
} }
/// <summary> /// <summary>
@@ -17,15 +21,31 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary> /// </summary>
public async Task<SqlSugarPagedList<ObContractTemplate>> Page(OnboardingContractTemplatePageInput input) 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( .WhereIF(
!string.IsNullOrWhiteSpace(input.TemplateName), !string.IsNullOrWhiteSpace(input.TemplateName),
it => it.TemplateName.Contains(input.TemplateName) || it.TemplateCode.Contains(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.ContractType), it => it.ContractType == input.ContractType)
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status);
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
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) .OrderBy(it => it.TemplateName)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
await PopulateScopeDisplay(page.List.ToList());
return page;
} }
/// <summary> /// <summary>
@@ -33,7 +53,61 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary> /// </summary>
public async Task<ObContractTemplate> Detail(BaseIdInput input) 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> /// <summary>
@@ -41,8 +115,24 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary> /// </summary>
public async Task Add(OnboardingContractTemplateAddInput input) public async Task Add(OnboardingContractTemplateAddInput input)
{ {
NormalizeInput(input);
await ValidateBusinessScope(input.CompanyId, input.DepartmentId, input.PositionIds);
await EnsureCodeUnique(input.TemplateCode, 0); 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> /// <summary>
@@ -50,9 +140,34 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary> /// </summary>
public async Task Edit(OnboardingContractTemplateEditInput input) 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 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>
@@ -63,6 +178,16 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (input.Ids.Count == 0) if (input.Ids.Count == 0)
return; return;
var templates = await Context.Queryable<ObContractTemplate>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (templates.Count != input.Ids.Distinct().Count())
throw Oops.Bah("合同模板不存在");
await _dataPermission.EnsureCanManage(
templates.Select(item => item.CreateUserId),
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplateContract>() var hasOnboardingTemplates = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => input.Ids.Contains(it.ContractTemplateId)); .AnyAsync(it => input.Ids.Contains(it.ContractTemplateId));
if (hasOnboardingTemplates) if (hasOnboardingTemplates)
@@ -91,8 +216,8 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
.ExecuteCommandAsync(); .ExecuteCommandAsync();
} }
await Context.Deleteable<ObContractScope>() await Context.Deleteable<ObContractTemplatePosition>()
.Where(it => input.Ids.Contains(it.TemplateId)) .Where(item => input.Ids.Contains(item.ContractTemplateId))
.ExecuteCommandAsync(); .ExecuteCommandAsync();
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray()); await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
@@ -112,6 +237,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
{ {
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id) var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("合同版本不存在"); ?? throw Oops.Bah("合同版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
var isUsedInOnboardingTemplate = await Context.Queryable<ObOnboardingTemplateContract>() var isUsedInOnboardingTemplate = await Context.Queryable<ObOnboardingTemplateContract>()
.AnyAsync(it => it.ContractTemplateVersionId == version.Id); .AnyAsync(it => it.ContractTemplateVersionId == version.Id);
@@ -129,9 +255,6 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
await Context.Deleteable<ObTemplateVariable>() await Context.Deleteable<ObTemplateVariable>()
.Where(it => it.TemplateVersionId == version.Id) .Where(it => it.TemplateVersionId == version.Id)
.ExecuteCommandAsync(); .ExecuteCommandAsync();
await Context.Deleteable<ObContractScope>()
.Where(it => it.TemplateVersionId == version.Id)
.ExecuteCommandAsync();
await Context.Deleteable<ObContractTemplateVersion>() await Context.Deleteable<ObContractTemplateVersion>()
.In(version.Id) .In(version.Id)
.ExecuteCommandAsync(); .ExecuteCommandAsync();
@@ -149,11 +272,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary> /// </summary>
public async Task<SqlSugarPagedList<ObContractTemplateVersion>> VersionPage(OnboardingContractVersionPageInput input) 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) .Where(it => it.TemplateId == input.TemplateId)
.WhereIF(!string.IsNullOrWhiteSpace(input.VersionStatus), it => it.VersionStatus == input.VersionStatus) .WhereIF(!string.IsNullOrWhiteSpace(input.VersionStatus), it => it.VersionStatus == input.VersionStatus)
.OrderByDescending(it => it.CreateTime) .OrderByDescending(it => it.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize); .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> /// <summary>
@@ -161,7 +294,12 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
/// </summary> /// </summary>
public async Task AddVersion(OnboardingContractVersionAddInput input) 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) 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)) if (!string.Equals(file.Suffix, "docx", StringComparison.OrdinalIgnoreCase))
@@ -182,6 +320,15 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
// 严格比对变量字典库,防止手误和非法占位符 // 严格比对变量字典库,防止手误和非法占位符
var dictList = await Context.Queryable<ObVariableDefinition>().ToListAsync(); 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 dictMap = dictList.ToDictionary(it => it.VariableCode, it => it, StringComparer.OrdinalIgnoreCase);
var undefinedCodes = variableCodes.Where(code => !dictMap.ContainsKey(code)).ToList(); var undefinedCodes = variableCodes.Where(code => !dictMap.ContainsKey(code)).ToList();
if (undefinedCodes.Count > 0) if (undefinedCodes.Count > 0)
@@ -231,9 +378,25 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id) var version = await Context.Queryable<ObContractTemplateVersion>().InSingleAsync(input.Id)
?? throw Oops.Bah("模板版本不存在"); ?? 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) if (input.VersionStatus == ContractTemplateVersionStatus.Enabled)
{ {
ContractTemplateVersionValidity.ValidateRange(version.EffectiveDate, version.ExpiryDate);
if (version.ExpiryDate.HasValue && version.ExpiryDate.Value <= DateTime.Now)
throw Oops.Bah("该签署材料版本已经失效,不能启用");
var hasUnresolvedVariables = await Context.Queryable<ObTemplateVariable>() var hasUnresolvedVariables = await Context.Queryable<ObTemplateVariable>()
.AnyAsync(item => item.TemplateVersionId == input.Id .AnyAsync(item => item.TemplateVersionId == input.Id
&& item.VariableType == ContractVariableType.Unresolved); && item.VariableType == ContractVariableType.Unresolved);
@@ -242,7 +405,21 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
} }
version.VersionStatus = input.VersionStatus; 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> /// <summary>
@@ -268,6 +445,11 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (!string.Equals(variable.VariableCode, input.VariableCode, StringComparison.Ordinal)) if (!string.Equals(variable.VariableCode, input.VariableCode, StringComparison.Ordinal))
throw Oops.Bah("DOCX 占位符编码由系统识别,不能修改"); throw Oops.Bah("DOCX 占位符编码由系统识别,不能修改");
var version = await Context.Queryable<ObContractTemplateVersion>()
.InSingleAsync(variable.TemplateVersionId)
?? throw Oops.Bah("模板版本不存在");
await EnsureTemplateCanManage(version.TemplateId);
var isUsedInTask = await Context.Queryable<ObTaskContract>() var isUsedInTask = await Context.Queryable<ObTaskContract>()
.AnyAsync(it => it.TemplateVersionId == input.TemplateVersionId); .AnyAsync(it => it.TemplateVersionId == input.TemplateVersionId);
if (isUsedInTask) if (isUsedInTask)
@@ -278,6 +460,16 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
await Context.Updateable(entity).ExecuteCommandAsync(); await Context.Updateable(entity).ExecuteCommandAsync();
} }
private async Task EnsureTemplateCanManage(long templateId)
{
var template = await Context.Queryable<ObContractTemplate>().InSingleAsync(templateId)
?? throw Oops.Bah("合同模板不存在");
await _dataPermission.EnsureCanManage(
template.CreateUserId,
OnboardingPermissionCodes.ContractManageAll,
"合同模板");
}
private static ObTemplateVariable BuildDetectedVariable(long versionId, string code, ObVariableDefinition def, int index) private static ObTemplateVariable BuildDetectedVariable(long versionId, string code, ObVariableDefinition def, int index)
{ {
var separatorIndex = code.IndexOf('.'); var separatorIndex = code.IndexOf('.');
@@ -306,9 +498,11 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
EmployeeField = variableType is ContractVariableType.Employee or ContractVariableType.Company EmployeeField = variableType is ContractVariableType.Employee or ContractVariableType.Company
? sourceField ? sourceField
: null, : null,
DefaultValue = def?.DefaultValue,
Required = def?.Required ?? true, Required = def?.Required ?? true,
HrEditable = variableType == ContractVariableType.Contract, HrEditable = variableType == ContractVariableType.Contract,
SortCode = (index + 1) * 10, // 合同版本首次识别时继承全局变量字典排序;未登记排序时才使用识别顺序兜底。
SortCode = def?.SortCode ?? (index + 1) * 10,
Remark = def?.Remark Remark = def?.Remark
}; };
} }
@@ -336,4 +530,237 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
if (exists) if (exists)
throw Oops.Bah("模板编码已存在"); throw Oops.Bah("模板编码已存在");
} }
/// <summary>
/// 标准化合同模板保存参数并执行基础业务校验。
/// </summary>
private static void NormalizeInput(OnboardingContractTemplateAddInput input)
{
input.TemplateCode = OnboardingCodeValidator.Normalize(input.TemplateCode, "签署材料模板编码", 50);
input.TemplateName = input.TemplateName?.Trim();
input.ContractType = input.ContractType?.Trim();
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>();
if (string.IsNullOrWhiteSpace(input.TemplateName)
|| string.IsNullOrWhiteSpace(input.ContractType))
{
throw Oops.Bah("请填写模板编码、模板名称和合同类型");
}
if (input.PositionIds.Count == 0)
throw Oops.Bah("请至少选择一个适用岗位");
if (input.Status != "enable" && input.Status != "disable")
throw Oops.Bah("合同模板状态不正确");
}
/// <summary>
/// 校验公司、部门、岗位属于同一条启用的业务层级。
/// </summary>
private async Task ValidateBusinessScope(
long companyId,
long departmentId,
List<long> positionIds)
{
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; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
@@ -21,9 +23,144 @@ public class OnboardingEmployeePageInput : BasePageInput
public long? CompanyId { get; set; } public long? CompanyId { get; set; }
/// <summary> /// <summary>
/// 岗位 Id;为空时表示公司级配置。 /// 入职业务部门 Id。
/// </summary>
public long? DepartmentId { get; set; }
/// <summary>
/// 岗位 Id。
/// </summary> /// </summary>
public long? PositionId { get; set; } public long? PositionId { get; set; }
/// <summary>
/// 用工类型。
/// </summary>
public string EmploymentType { get; set; }
/// <summary>
/// 转正状态。
/// </summary>
public string RegularizationStatus { get; set; }
/// <summary>
/// 社保状态。
/// </summary>
public string SocialInsuranceStatus { get; set; }
/// <summary>
/// 合同即将到期筛选(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> /// <summary>
@@ -36,6 +173,11 @@ public class OnboardingEmployeeDetailOutput
/// </summary> /// </summary>
public ObEmployee Employee { get; set; } public ObEmployee Employee { get; set; }
/// <summary>
/// 审核通过时对应的完整入职登记表。
/// </summary>
public ObTaskEmployeeInfo EmployeeInfo { get; set; }
/// <summary> /// <summary>
/// 入职任务主数据。 /// 入职任务主数据。
/// </summary> /// </summary>
@@ -46,8 +188,31 @@ public class OnboardingEmployeeDetailOutput
/// </summary> /// </summary>
public List<ObTaskContract> Contracts { get; set; } = new List<ObTaskContract>(); public List<ObTaskContract> Contracts { get; set; } = new List<ObTaskContract>();
/// <summary>
/// 审核建档时冻结的员工合同业务快照,包含常用字段及全部自定义变量。
/// </summary>
public List<ObEmployeeContractSnapshot> ContractSnapshots { get; set; }
= new List<ObEmployeeContractSnapshot>();
/// <summary>
/// 员工入职时上传的身份证、学历证明、体检报告等资料。
/// </summary>
public List<OnboardingTaskMaterialDetailOutput> Materials { get; set; }
= new List<OnboardingTaskMaterialDetailOutput>();
/// <summary>
/// 契约锁签署文件与签署日志组成的完整归档 ZIP 文件 Id。
/// </summary>
public long? ElectronicSignArchiveFileId { get; set; }
/// <summary> /// <summary>
/// 提交与审核记录列表。 /// 提交与审核记录列表。
/// </summary> /// </summary>
public List<ObTaskAudit> Audits { get; set; } = new List<ObTaskAudit>(); public List<ObTaskAudit> Audits { get; set; } = new List<ObTaskAudit>();
/// <summary>
/// 员工转正及人事维护历史。
/// </summary>
public List<ObEmployeeChangeRecord> ChangeRecords { get; set; }
= new List<ObEmployeeChangeRecord>();
} }
@@ -1,14 +1,30 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 定义正式员工档案查询业务能力。 /// 定义员工档案查询和人事维护业务能力。
/// </summary> /// </summary>
public interface IOnboardingEmployeeService : ITransient public interface IOnboardingEmployeeService : ITransient
{ {
/// <summary> /// <summary>
/// 分页查询审核通过后生成的正式员工档案。 /// 分页查询审核通过后生成的正式员工档案及维度统计。
/// </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>
/// 查询正式员工及其来源任务、合同和审核记录。 /// 查询正式员工及其来源任务、合同和审核记录。
/// </summary> /// </summary>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 定义正式员工合同快照归档能力。
/// </summary>
public interface IOnboardingEmployeeContractSnapshotService : ITransient
{
/// <summary>冻结一名员工来源任务中的全部合同和变量。</summary>
Task Build(long employeeId, long taskId);
}
@@ -0,0 +1,153 @@
using System.Globalization;
namespace SimpleAdmin.Application;
/// <summary>
/// 在正式员工建档时,将每份任务合同及其全部变量冻结为长期业务快照。
/// </summary>
public class OnboardingEmployeeContractSnapshotService : IOnboardingEmployeeContractSnapshotService
{
private readonly SqlSugarScope _db;
public OnboardingEmployeeContractSnapshotService()
{
_db = DbContext.DB;
}
/// <inheritdoc />
public async Task Build(long employeeId, long taskId)
{
var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == taskId)
.OrderBy(item => item.Id)
.ToListAsync();
if (contracts.Count == 0)
throw Oops.Bah("入职任务没有合同,无法生成员工合同快照");
var contractIds = contracts.Select(item => item.Id).ToList();
var allVariables = await _db.Queryable<ObTaskContractVariable>()
.Where(item => contractIds.Contains(item.TaskContractId))
.OrderBy(item => item.SortCode)
.OrderBy(item => item.Id)
.ToListAsync();
var now = DateTime.Now;
var snapshots = new List<ObEmployeeContractSnapshot>();
foreach (var contract in contracts)
{
var variables = allVariables
.Where(item => item.TaskContractId == contract.Id)
.OrderBy(item => item.SortCode)
.ThenBy(item => item.Id)
.ToList();
var valueMap = variables
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.Last().VariableValue,
StringComparer.OrdinalIgnoreCase);
var variableSnapshot = variables
.GroupBy(item => item.VariableCode, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group =>
{
// 历史任务若意外存在重复变量,以排序后的最后一条最终值为准,避免建档时报 500。
var item = group.Last();
return (object)new
{
variableName = item.VariableName,
variableType = item.VariableType,
dataType = item.DataType,
variableValue = item.VariableValue,
sourceType = item.SourceType,
isManualOverride = item.IsManualOverride,
sortCode = item.SortCode
};
},
StringComparer.OrdinalIgnoreCase);
snapshots.Add(new ObEmployeeContractSnapshot
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
EmployeeId = employeeId,
TaskId = taskId,
TaskContractId = contract.Id,
TemplateId = contract.TemplateId,
TemplateVersionId = contract.TemplateVersionId,
ContractName = contract.ContractName,
ContractType = contract.ContractType,
ContractStartDate = ParseDate(GetValue(valueMap, "hr.ContractStartDate")),
ContractEndDate = ParseDate(GetValue(valueMap, "hr.ContractEndDate")),
ContractTerm = GetValue(valueMap, "hr.ContractTerm"),
ProbationStartDate = ParseDate(GetValue(valueMap, "hr.ProbationStartDate")),
ProbationEndDate = ParseDate(GetValue(valueMap, "hr.ProbationEndDate")),
ProbationTerm = GetValue(valueMap, "hr.ProbationTerm"),
ProbationSalary = ParseAmount(GetValue(valueMap, "hr.ProbationSalary")),
BaseSalary = ParseAmount(GetValue(valueMap, "hr.BaseSalary")),
PositionSalary = ParseAmount(GetValue(valueMap, "hr.PositionSalary")),
PerformanceAllowance = ParseAmount(GetValue(valueMap, "hr.PerformanceAllowance")),
AttendanceBonus = ParseAmount(GetValue(valueMap, "hr.AttendanceBonus")),
RegularSalary = ParseAmount(GetValue(valueMap, "hr.RegularSalary")),
WorkCity = GetValue(valueMap, "hr.WorkCity"),
PositionName = GetValue(valueMap, "hr.PositionName"),
EmployeeNo = GetValue(valueMap, "hr.EmployeeNo"),
VariablesJson = global::System.Text.Json.JsonSerializer.Serialize(variableSnapshot),
SnapshotVersion = 1,
SnapshotAt = now,
Status = CommonStatusConst.ENABLE,
CreateTime = now,
CreateUserId = UserManager.UserId,
CreateUser = UserManager.Name,
IsDelete = false
});
}
// Build 只在审核建档事务内执行;先删后建使异常重试保持幂等。
await _db.Deleteable<ObEmployeeContractSnapshot>()
.Where(item => item.TaskId == taskId)
.ExecuteCommandAsync();
await _db.Insertable(snapshots).ExecuteCommandAsync();
}
private static string GetValue(
IReadOnlyDictionary<string, string> valueMap,
string variableCode)
{
return valueMap.TryGetValue(variableCode, out var value) && !string.IsNullOrWhiteSpace(value)
? value.Trim()
: null;
}
private static DateTime? ParseDate(string value)
{
return DateTime.TryParse(value, out var date) ? date.Date : null;
}
private static decimal? ParseAmount(string value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var normalized = value
.Replace("¥", string.Empty)
.Replace("¥", string.Empty)
.Replace("元", string.Empty)
.Trim();
if (decimal.TryParse(
normalized,
NumberStyles.Number | NumberStyles.AllowCurrencySymbol,
CultureInfo.GetCultureInfo("zh-CN"),
out var amount)
|| decimal.TryParse(
normalized,
NumberStyles.Number,
CultureInfo.InvariantCulture,
out amount))
{
return amount;
}
return null;
}
}
@@ -6,10 +6,12 @@ namespace SimpleAdmin.Application;
public class OnboardingEntryConfigService : IOnboardingEntryConfigService public class OnboardingEntryConfigService : IOnboardingEntryConfigService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingEntryConfigService() public OnboardingEntryConfigService(IOnboardingDataPermissionService dataPermission)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_dataPermission = dataPermission;
} }
/// <summary> /// <summary>
@@ -27,6 +29,11 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
/// </summary> /// </summary>
public async Task SaveFormField(OnboardingFormFieldSaveInput input) public async Task SaveFormField(OnboardingFormFieldSaveInput input)
{ {
input.FieldCode = OnboardingCodeValidator.Normalize(input.FieldCode, "字段编码", 100);
input.FieldName = input.FieldName?.Trim();
if (string.IsNullOrWhiteSpace(input.FieldName))
throw Oops.Bah("字段名称不能为空");
var fieldCodeQuery = _db.Queryable<ObFormField>() var fieldCodeQuery = _db.Queryable<ObFormField>()
.Where(x => x.FieldCode == input.FieldCode); .Where(x => x.FieldCode == input.FieldCode);
if (input.Id.HasValue) if (input.Id.HasValue)
@@ -50,10 +57,15 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Id.HasValue) if (input.Id.HasValue)
{ {
var exists = await _db.Queryable<ObFormField>().AnyAsync(x => x.Id == input.Id.Value); var exists = await _db.Queryable<ObFormField>().InSingleAsync(input.Id.Value)
if (!exists) ?? throw Oops.Bah("字段不存在");
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(); await _db.Updateable(entity).ExecuteCommandAsync();
return; return;
} }
@@ -90,12 +102,21 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Ids.Count == 0) if (input.Ids.Count == 0)
return; 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)); .AnyAsync(x => input.Ids.Contains(x.FieldId));
var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateFormField>() if (isReferencedByTask)
.AnyAsync(x => input.Ids.Contains(x.FieldId)); throw Oops.Bah("字段已被入职任务引用,不能删除,可改为停用");
if (isReferenced || isReferencedByTemplate)
throw Oops.Bah("字段已被入职模板引用,不能删除");
await _db.Deleteable<ObFormField>().In(input.Ids).ExecuteCommandAsync(); await _db.Deleteable<ObFormField>().In(input.Ids).ExecuteCommandAsync();
} }
@@ -115,8 +136,30 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
/// </summary> /// </summary>
public async Task SaveMaterialType(OnboardingMaterialTypeSaveInput input) public async Task SaveMaterialType(OnboardingMaterialTypeSaveInput input)
{ {
input.MaterialCode = OnboardingCodeValidator.Normalize(input.MaterialCode, "资料编码", 100);
input.MaterialName = input.MaterialName?.Trim();
if (string.IsNullOrWhiteSpace(input.MaterialName))
throw Oops.Bah("资料名称不能为空");
if (input.MaxFileCount <= 0) if (input.MaxFileCount <= 0)
throw Oops.Bah("最大文件数量必须大于0"); throw Oops.Bah("最大文件数量必须大于0");
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>() var materialCodeExists = await _db.Queryable<ObMaterialType>()
.AnyAsync(x => x.MaterialCode == input.MaterialCode && x.Id != input.Id); .AnyAsync(x => x.MaterialCode == input.MaterialCode && x.Id != input.Id);
@@ -136,10 +179,15 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Id.HasValue) if (input.Id.HasValue)
{ {
var exists = await _db.Queryable<ObMaterialType>().AnyAsync(x => x.Id == input.Id.Value); var exists = await _db.Queryable<ObMaterialType>().InSingleAsync(input.Id.Value)
if (!exists) ?? throw Oops.Bah("资料类型不存在");
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(); await _db.Updateable(entity).ExecuteCommandAsync();
return; return;
} }
@@ -155,13 +203,26 @@ public class OnboardingEntryConfigService : IOnboardingEntryConfigService
if (input.Ids.Count == 0) if (input.Ids.Count == 0)
return; return;
var isReferenced = await _db.Queryable<ObMaterialRequirement>() var materialTypes = await _db.Queryable<ObMaterialType>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId)); .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>() var isReferencedByTemplate = await _db.Queryable<ObOnboardingTemplateMaterial>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId)); .AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (isReferenced || isReferencedByTemplate) if (isReferencedByTemplate)
throw Oops.Bah("资料类型已被入职模板引用,不能删除"); 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>() var hasUploads = await _db.Queryable<ObTaskMaterial>()
.AnyAsync(x => input.Ids.Contains(x.MaterialTypeId)); .AnyAsync(x => input.Ids.Contains(x.MaterialTypeId));
if (hasUploads) if (hasUploads)
@@ -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 继续使用原任务链接");
}
}
}
@@ -6,18 +6,23 @@ namespace SimpleAdmin.Application;
public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IConfigService _config; private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskLifecycleService(IConfigService config) public OnboardingTaskLifecycleService(IOnboardingDataPermissionService dataPermission)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_config = config; _dataPermission = dataPermission;
} }
/// <summary> /// <summary>
/// 归档已审核通过的入职任务。 /// 归档已审核通过的入职任务。
/// </summary> /// </summary>
public async Task Archive(OnboardingAuditActionInput input) 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); var task = await Authorize(input.TaskId);
if (task.TaskStatus != OnboardingTaskStatus.Approved) if (task.TaskStatus != OnboardingTaskStatus.Approved)
@@ -25,40 +30,93 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
throw Oops.Bah("仅审核通过任务可归档"); throw Oops.Bah("仅审核通过任务可归档");
} }
task.TaskStatus = OnboardingTaskStatus.Archived; await _db.Ado.BeginTranAsync();
task.ArchivedAt = DateTime.Now; try
await _db.Updateable(task).ExecuteCommandAsync(); {
await Log(task, OnboardingAuditAction.Archive, input.AuditOpinion); 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>
/// 撤销入职任务并同步撤销仍然有效的员工填写链接。 /// 撤销入职任务并同步撤销仍然有效的员工填写链接。
/// </summary> /// </summary>
public async Task Revoke(OnboardingAuditActionInput input) 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); 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.Ado.BeginTranAsync();
await _db.Updateable(task).ExecuteCommandAsync(); try
await _db.Updateable<ObTaskLink>() {
.SetColumns(item => new ObTaskLink var originalStatus = task.TaskStatus;
{ var revokedAt = DateTime.Now;
LinkStatus = OnboardingLinkStatus.Revoked, var updatedRows = await _db.Updateable<ObOnboardingTask>()
RevokedAt = DateTime.Now .SetColumns(item => item.TaskStatus == OnboardingTaskStatus.Revoked)
}) .Where(item => item.Id == task.Id && item.TaskStatus == originalStatus)
.Where(item => item.TaskId == task.Id && item.LinkStatus == OnboardingLinkStatus.Active) .ExecuteCommandAsync();
.ExecuteCommandAsync(); if (updatedRows != 1)
await Log(task, OnboardingAuditAction.Revoke, input.AuditOpinion); 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>
/// 删除已撤销的入职任务及其全部关联快照数据。 /// 删除已撤销的入职任务及其全部关联快照数据。
/// </summary> /// </summary>
public async Task Delete(BaseIdInput input) 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); var task = await Authorize(input.Id);
if (task.TaskStatus != OnboardingTaskStatus.Revoked) if (task.TaskStatus != OnboardingTaskStatus.Revoked)
@@ -74,6 +132,28 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
.Select(x => x.Id) .Select(x => x.Id)
.ToListAsync(); .ToListAsync();
var signProcessIds = await _db.Queryable<ObElectronicSignProcess>()
.Where(x => x.TaskId == task.Id)
.Select(x => x.Id)
.ToListAsync();
if (signProcessIds.Count > 0)
{
await _db.Deleteable<ObElectronicSignEvent>()
.Where(x => x.SignProcessId.HasValue
&& signProcessIds.Contains(x.SignProcessId.Value))
.ExecuteCommandAsync();
await _db.Deleteable<ObElectronicSignDocument>()
.Where(x => signProcessIds.Contains(x.SignProcessId))
.ExecuteCommandAsync();
await _db.Deleteable<ObElectronicSignProcess>()
.Where(x => signProcessIds.Contains(x.Id))
.ExecuteCommandAsync();
}
await _db.Deleteable<ObEmployeeContractSnapshot>()
.Where(x => x.TaskId == task.Id)
.ExecuteCommandAsync();
if (taskContractIds.Count > 0) if (taskContractIds.Count > 0)
{ {
await _db.Deleteable<ObTaskContractVariable>() await _db.Deleteable<ObTaskContractVariable>()
@@ -86,6 +166,8 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
await _db.Deleteable<ObTaskMaterialRequirement>().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<ObTaskMaterial>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObTaskEmployeeInfo>().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<ObTaskAudit>().Where(x => x.TaskId == task.Id).ExecuteCommandAsync();
await _db.Deleteable<ObOnboardingTask>().In(task.Id).ExecuteCommandAsync(); await _db.Deleteable<ObOnboardingTask>().In(task.Id).ExecuteCommandAsync();
await _db.Ado.CommitTranAsync(); await _db.Ado.CommitTranAsync();
@@ -101,26 +183,10 @@ public class OnboardingTaskLifecycleService : IOnboardingTaskLifecycleService
{ {
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(taskId)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
var config = await _config.GetByConfigKey(CateGoryConst.CONFIG_BIZ_DEFINE, "onboard.reviewer.userIds") await _dataPermission.EnsureCanManage(
?? throw Oops.Bah("请配置入职审核人"); task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
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 task; return task;
} }
@@ -6,18 +6,24 @@ namespace SimpleAdmin.Application;
public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IConfigService _config; private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingMaterialReviewService(IConfigService config) public OnboardingMaterialReviewService(
IOnboardingDataPermissionService dataPermission)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_config = config; _dataPermission = dataPermission;
} }
/// <summary> /// <summary>
/// 审核单份候选人上传材料并记录审核意见。 /// 审核单份候选人上传材料并记录审核意见。
/// </summary> /// </summary>
public async Task Review(OnboardingMaterialReviewInput input) 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 if (input.ReviewStatus != TaskMaterialReviewStatus.Passed
&& input.ReviewStatus != TaskMaterialReviewStatus.NeedSupplement) && input.ReviewStatus != TaskMaterialReviewStatus.NeedSupplement)
@@ -46,26 +52,11 @@ public class OnboardingMaterialReviewService : IOnboardingMaterialReviewService
await _db.Updateable(material).ExecuteCommandAsync(); 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") return _dataPermission.EnsureCanManage(
?? throw Oops.Bah("请配置入职审核人"); task.CreateUserId,
using var configJson = global::System.Text.Json.JsonDocument.Parse(config.ConfigValue); OnboardingPermissionCodes.TaskManageAll,
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("您不是该入职任务的审核人");
}
} }
} }
@@ -15,6 +15,11 @@ public class OnboardingTemplatePageInput : BasePageInput
/// </summary> /// </summary>
public long? CompanyId { get; set; } public long? CompanyId { get; set; }
/// <summary>
/// 所属部门 Id。
/// </summary>
public long? DepartmentId { get; set; }
/// <summary> /// <summary>
/// 适用岗位 Id。 /// 适用岗位 Id。
/// </summary> /// </summary>
@@ -51,6 +56,12 @@ public class OnboardingTemplateSaveInput
/// </summary> /// </summary>
public long CompanyId { get; set; } public long CompanyId { get; set; }
/// <summary>
/// 所属部门 Id。
/// </summary>
[IdNotNull(ErrorMessage = "所属部门不能为空")]
public long DepartmentId { get; set; }
/// <summary> /// <summary>
/// 适用岗位 Id 列表。 /// 适用岗位 Id 列表。
/// </summary> /// </summary>
@@ -165,6 +176,11 @@ public class OnboardingTemplateOutput : ObOnboardingTemplate
/// </summary> /// </summary>
public string CompanyName { get; set; } public string CompanyName { get; set; }
/// <summary>
/// 所属部门名称。
/// </summary>
public string DepartmentName { get; set; }
/// <summary> /// <summary>
/// 适用岗位 Id 列表。 /// 适用岗位 Id 列表。
/// </summary> /// </summary>
@@ -26,7 +26,10 @@ public interface IOnboardingTemplateService : ITransient
Task Delete(BaseIdListInput input); Task Delete(BaseIdListInput input);
/// <summary> /// <summary>
/// 查询公司岗位可使用的入职模板。 /// 查询公司、部门和岗位可使用的入职模板。
/// </summary> /// </summary>
Task<List<OnboardingTemplateOutput>> ListEnabled(long companyId, long positionId); Task<List<OnboardingTemplateOutput>> ListEnabled(
long companyId,
long departmentId,
long positionId);
} }
@@ -6,10 +6,12 @@ namespace SimpleAdmin.Application;
public class OnboardingTemplateService : IOnboardingTemplateService public class OnboardingTemplateService : IOnboardingTemplateService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTemplateService() public OnboardingTemplateService(IOnboardingDataPermissionService dataPermission)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_dataPermission = dataPermission;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -20,6 +22,7 @@ public class OnboardingTemplateService : IOnboardingTemplateService
.WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), .WhereIF(!string.IsNullOrWhiteSpace(input.Keyword),
item => item.TemplateCode.Contains(input.Keyword) || item.TemplateName.Contains(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.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); .WhereIF(!string.IsNullOrWhiteSpace(input.Status), item => item.Status == input.Status);
if (input.PositionId.HasValue) if (input.PositionId.HasValue)
@@ -58,10 +61,14 @@ public class OnboardingTemplateService : IOnboardingTemplateService
return new OnboardingTemplateDetailOutput return new OnboardingTemplateDetailOutput
{ {
Id = summary.Id, Id = summary.Id,
CreateUserId = summary.CreateUserId,
CreateUser = summary.CreateUser,
TemplateCode = summary.TemplateCode, TemplateCode = summary.TemplateCode,
TemplateName = summary.TemplateName, TemplateName = summary.TemplateName,
CompanyId = summary.CompanyId, CompanyId = summary.CompanyId,
CompanyName = summary.CompanyName, CompanyName = summary.CompanyName,
DepartmentId = summary.DepartmentId,
DepartmentName = summary.DepartmentName,
Description = summary.Description, Description = summary.Description,
Status = summary.Status, Status = summary.Status,
CreateTime = summary.CreateTime, CreateTime = summary.CreateTime,
@@ -89,28 +96,60 @@ public class OnboardingTemplateService : IOnboardingTemplateService
NormalizeAndValidateInput(input); NormalizeAndValidateInput(input);
await ValidateReferences(input); await ValidateReferences(input);
var templateId = input.Id ?? global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); var codeOwnerQuery = _db.Queryable<ObOnboardingTemplate>()
var codeExists = await _db.Queryable<ObOnboardingTemplate>() .ClearFilter()
.AnyAsync(item => item.TemplateCode == input.TemplateCode && item.Id != templateId); .Where(item => item.TemplateCode == input.TemplateCode);
if (codeExists) 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("入职模板编码已存在"); 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 var entity = new ObOnboardingTemplate
{ {
Id = templateId, Id = templateId,
TemplateCode = input.TemplateCode.Trim(), TemplateCode = input.TemplateCode.Trim(),
TemplateName = input.TemplateName.Trim(), TemplateName = input.TemplateName.Trim(),
CompanyId = input.CompanyId, CompanyId = input.CompanyId,
DepartmentId = input.DepartmentId,
Description = input.Description, Description = input.Description,
Status = input.Status Status = input.Status,
IsDelete = false
}; };
await _db.Ado.BeginTranAsync(); await _db.Ado.BeginTranAsync();
try try
{ {
if (input.Id.HasValue) if (input.Id.HasValue || isRestore)
{ {
var exists = await _db.Queryable<ObOnboardingTemplate>().AnyAsync(item => item.Id == templateId); var exists = isRestore || await _db.Queryable<ObOnboardingTemplate>()
.AnyAsync(item => item.Id == templateId);
if (!exists) if (!exists)
throw Oops.Bah("入职模板不存在"); throw Oops.Bah("入职模板不存在");
await _db.Updateable(entity).IgnoreColumns(ignoreAllNullColumns: true).ExecuteCommandAsync(); await _db.Updateable(entity).IgnoreColumns(ignoreAllNullColumns: true).ExecuteCommandAsync();
@@ -138,6 +177,16 @@ public class OnboardingTemplateService : IOnboardingTemplateService
if (input.Ids.Count == 0) if (input.Ids.Count == 0)
return; 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>() var isUsed = await _db.Queryable<ObOnboardingTask>()
.AnyAsync(item => input.Ids.Contains(item.OnboardingTemplateId)); .AnyAsync(item => input.Ids.Contains(item.OnboardingTemplateId));
if (isUsed) if (isUsed)
@@ -149,9 +198,6 @@ public class OnboardingTemplateService : IOnboardingTemplateService
foreach (var id in input.Ids) foreach (var id in input.Ids)
await DeleteRelations(id); await DeleteRelations(id);
await _db.Deleteable<ObOnboardingTemplate>()
.Where(it => input.Ids.Contains(it.Id))
.ExecuteCommandAsync();
await _db.Updateable<ObOnboardingTemplate>() await _db.Updateable<ObOnboardingTemplate>()
.SetColumns(it => it.IsDelete == true) .SetColumns(it => it.IsDelete == true)
.Where(it => input.Ids.Contains(it.Id)) .Where(it => input.Ids.Contains(it.Id))
@@ -167,7 +213,10 @@ public class OnboardingTemplateService : IOnboardingTemplateService
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<List<OnboardingTemplateOutput>> ListEnabled(long companyId, long positionId) public async Task<List<OnboardingTemplateOutput>> ListEnabled(
long companyId,
long departmentId,
long positionId)
{ {
var templateIds = await _db.Queryable<ObOnboardingTemplatePosition>() var templateIds = await _db.Queryable<ObOnboardingTemplatePosition>()
.Where(item => item.PositionId == positionId) .Where(item => item.PositionId == positionId)
@@ -175,22 +224,87 @@ public class OnboardingTemplateService : IOnboardingTemplateService
.ToListAsync(); .ToListAsync();
var templates = await _db.Queryable<ObOnboardingTemplate>() var templates = await _db.Queryable<ObOnboardingTemplate>()
.Where(item => item.CompanyId == companyId .Where(item => item.CompanyId == companyId
&& item.DepartmentId == departmentId
&& item.Status == "enable" && item.Status == "enable"
&& templateIds.Contains(item.Id)) && templateIds.Contains(item.Id))
.OrderBy(item => item.TemplateName) .OrderBy(item => item.TemplateName)
.ToListAsync(); .ToListAsync();
return await BuildOutputs(templates); if (templates.Count == 0)
return await BuildOutputs(templates);
var onboardingTemplateIds = templates.Select(item => item.Id).ToList();
var contractConfigs = await _db.Queryable<ObOnboardingTemplateContract>()
.Where(item => onboardingTemplateIds.Contains(item.OnboardingTemplateId))
.ToListAsync();
if (contractConfigs.Count == 0)
return await BuildOutputs(new List<ObOnboardingTemplate>());
var contractTemplateIds = contractConfigs
.Select(item => item.ContractTemplateId)
.Distinct()
.ToList();
var contractVersionIds = contractConfigs
.Select(item => item.ContractTemplateVersionId)
.Distinct()
.ToList();
var contractTemplates = await _db.Queryable<ObContractTemplate>()
.Where(item => contractTemplateIds.Contains(item.Id))
.ToListAsync();
var contractVersions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => contractVersionIds.Contains(item.Id))
.ToListAsync();
var contractPositions = await _db.Queryable<ObContractTemplatePosition>()
.Where(item => contractTemplateIds.Contains(item.ContractTemplateId)
&& item.PositionId == positionId)
.ToListAsync();
var now = DateTime.Now;
var availableTemplates = templates.Where(template =>
{
var configs = contractConfigs
.Where(item => item.OnboardingTemplateId == template.Id)
.ToList();
if (configs.Count == 0)
return false;
return configs.All(config =>
{
var contractTemplate = contractTemplates
.FirstOrDefault(item => item.Id == config.ContractTemplateId);
var version = contractVersions
.FirstOrDefault(item => item.Id == config.ContractTemplateVersionId);
return contractTemplate != null
&& string.Equals(
contractTemplate.Status,
"enable",
StringComparison.OrdinalIgnoreCase)
&& contractTemplate.CompanyId == template.CompanyId
&& contractTemplate.DepartmentId == template.DepartmentId
&& contractPositions.Any(item =>
item.ContractTemplateId == config.ContractTemplateId)
&& version != null
&& version.TemplateId == config.ContractTemplateId
&& ContractTemplateVersionValidity.IsAvailable(version, now);
});
}).ToList();
return await BuildOutputs(availableTemplates);
} }
private static void NormalizeAndValidateInput(OnboardingTemplateSaveInput input) private static void NormalizeAndValidateInput(OnboardingTemplateSaveInput input)
{ {
input.TemplateCode = input.TemplateCode?.Trim(); input.TemplateCode = OnboardingCodeValidator.Normalize(
input.TemplateCode,
"入职模板编码",
100);
input.TemplateName = input.TemplateName?.Trim(); input.TemplateName = input.TemplateName?.Trim();
input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>(); input.PositionIds = input.PositionIds?.Distinct().ToList() ?? new List<long>();
input.Contracts = input.Contracts? input.Contracts ??= new List<OnboardingTemplateContractInput>();
.GroupBy(item => item.ContractTemplateVersionId) var duplicateContract = input.Contracts
.Select(group => group.First()) .GroupBy(item => item.ContractTemplateId)
.ToList() ?? new List<OnboardingTemplateContractInput>(); .FirstOrDefault(group => group.Count() > 1);
if (duplicateContract != null)
throw Oops.Bah("同一份合同模板在入职模板中只能选择一个版本");
input.FormFields = input.FormFields? input.FormFields = input.FormFields?
.GroupBy(item => item.FieldId) .GroupBy(item => item.FieldId)
.Select(group => group.First()) .Select(group => group.First())
@@ -200,14 +314,23 @@ public class OnboardingTemplateService : IOnboardingTemplateService
.Select(group => group.First()) .Select(group => group.First())
.ToList() ?? new List<OnboardingTemplateMaterialInput>(); .ToList() ?? new List<OnboardingTemplateMaterialInput>();
if (string.IsNullOrWhiteSpace(input.TemplateCode) || string.IsNullOrWhiteSpace(input.TemplateName)) if (string.IsNullOrWhiteSpace(input.TemplateName))
throw Oops.Bah("请填写入职模板编码和名称"); throw Oops.Bah("请填写入职模板名称");
if (input.CompanyId <= 0) if (input.CompanyId <= 0)
throw Oops.Bah("请选择所属公司"); throw Oops.Bah("请选择所属公司");
if (input.DepartmentId <= 0)
throw Oops.Bah("请选择所属部门");
if (input.PositionIds.Count == 0) if (input.PositionIds.Count == 0)
throw Oops.Bah("请至少选择一个适用岗位"); throw Oops.Bah("请至少选择一个适用岗位");
if (input.PositionIds.Any(positionId => positionId <= 0))
throw Oops.Bah("适用岗位数据不正确");
if (input.Contracts.Count == 0) if (input.Contracts.Count == 0)
throw Oops.Bah("请至少选择一份合同文件"); throw Oops.Bah("请至少选择一份合同文件");
if (input.Contracts.Any(item => item.ContractTemplateId <= 0
|| item.ContractTemplateVersionId <= 0))
{
throw Oops.Bah("合同模板或版本数据不正确");
}
if (input.Status != "enable" && input.Status != "disable") if (input.Status != "enable" && input.Status != "disable")
throw Oops.Bah("入职模板状态不正确"); throw Oops.Bah("入职模板状态不正确");
if (input.Contracts.Any(item => item.PrintCopies <= 0)) if (input.Contracts.Any(item => item.PrintCopies <= 0))
@@ -221,12 +344,20 @@ public class OnboardingTemplateService : IOnboardingTemplateService
if (!companyEnabled) if (!companyEnabled)
throw Oops.Bah("所属公司不存在或未启用"); 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>() var positionCount = await _db.Queryable<ObPosition>()
.CountAsync(item => input.PositionIds.Contains(item.Id) .CountAsync(item => input.PositionIds.Contains(item.Id)
&& item.CompanyId == input.CompanyId && item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == "enable"); && item.Status == "enable");
if (positionCount != input.PositionIds.Count) if (positionCount != input.PositionIds.Count)
throw Oops.Bah("存在不属于该公司或未启用的岗位"); throw Oops.Bah("存在未启用或不属于该公司、部门的岗位");
var versionIds = input.Contracts.Select(item => item.ContractTemplateVersionId).ToList(); var versionIds = input.Contracts.Select(item => item.ContractTemplateVersionId).ToList();
var versions = await _db.Queryable<ObContractTemplateVersion>() var versions = await _db.Queryable<ObContractTemplateVersion>()
@@ -238,11 +369,50 @@ public class OnboardingTemplateService : IOnboardingTemplateService
{ {
throw Oops.Bah("合同模板与版本不匹配"); 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 (input.Status == "enable")
{ {
if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled)) if (contractTemplates.Any(item => !string.Equals(
throw Oops.Bah("启用入职模板前,请先启用全部合同版本"); item.Status,
"enable",
global::System.StringComparison.OrdinalIgnoreCase)))
throw Oops.Bah("启用入职模板前,请先启用全部合同模板");
} }
var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList(); var materialIds = input.Materials.Select(item => item.MaterialTypeId).ToList();
@@ -311,9 +481,19 @@ public class OnboardingTemplateService : IOnboardingTemplateService
var templateIds = templates.Select(item => item.Id).ToList(); var templateIds = templates.Select(item => item.Id).ToList();
var companyIds = templates.Select(item => item.CompanyId).Distinct().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>() var companies = await _db.Queryable<ObCompany>()
.Where(item => companyIds.Contains(item.Id)) .Where(item => companyIds.Contains(item.Id))
.ToListAsync(); .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>() var relations = await _db.Queryable<ObOnboardingTemplatePosition>()
.Where(item => templateIds.Contains(item.OnboardingTemplateId)) .Where(item => templateIds.Contains(item.OnboardingTemplateId))
.ToListAsync(); .ToListAsync();
@@ -334,10 +514,15 @@ public class OnboardingTemplateService : IOnboardingTemplateService
return new OnboardingTemplateOutput return new OnboardingTemplateOutput
{ {
Id = template.Id, Id = template.Id,
CreateUserId = template.CreateUserId,
CreateUser = template.CreateUser,
TemplateCode = template.TemplateCode, TemplateCode = template.TemplateCode,
TemplateName = template.TemplateName, TemplateName = template.TemplateName,
CompanyId = template.CompanyId, CompanyId = template.CompanyId,
CompanyName = companies.FirstOrDefault(item => item.Id == template.CompanyId)?.CompanyName, CompanyName = companies.FirstOrDefault(item => item.Id == template.CompanyId)?.CompanyName,
DepartmentId = template.DepartmentId,
DepartmentName = departments
.FirstOrDefault(item => item.Id == template.DepartmentId)?.DepartmentName,
Description = template.Description, Description = template.Description,
Status = template.Status, Status = template.Status,
CreateTime = template.CreateTime, CreateTime = template.CreateTime,
@@ -10,6 +10,11 @@ public class OnboardingPositionPageInput : BasePageInput
/// </summary> /// </summary>
public long? CompanyId { get; set; } public long? CompanyId { get; set; }
/// <summary>
/// 入职业务部门 Id。
/// </summary>
public long? DepartmentId { get; set; }
/// <summary> /// <summary>
/// 岗位编码。 /// 岗位编码。
/// </summary> /// </summary>
@@ -21,7 +26,7 @@ public class OnboardingPositionPageInput : BasePageInput
public virtual string PositionName { get; set; } public virtual string PositionName { get; set; }
/// <summary> /// <summary>
/// 启用状态或状态筛选条件。 /// 启用状态。
/// </summary> /// </summary>
public string Status { get; set; } public string Status { get; set; }
} }
@@ -31,17 +36,37 @@ public class OnboardingPositionPageInput : BasePageInput
/// </summary> /// </summary>
public class OnboardingPositionAddInput : ObPosition 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>
/// 岗位业务编码。 /// 岗位业务编码。
/// </summary> /// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位编码不能为空")] [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位编码不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位编码不能超过50个字符")]
public override string PositionCode { get; set; } public override string PositionCode { get; set; }
/// <summary> /// <summary>
/// 岗位名称。 /// 岗位名称。
/// </summary> /// </summary>
[global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位名称不能为空")] [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "岗位名称不能为空")]
[global::System.ComponentModel.DataAnnotations.MaxLength(50, ErrorMessage = "岗位名称不能超过50个字符")]
public override string PositionName { get; set; } public override string PositionName { get; set; }
/// <summary>
/// 业务备注。
/// </summary>
[global::System.ComponentModel.DataAnnotations.MaxLength(200, ErrorMessage = "备注不能超过200个字符")]
public override string Remark { get; set; }
} }
/// <summary> /// <summary>
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 定义入职业务岗位业务能力。 /// 定义入职业务岗位业务能力。
@@ -9,22 +9,32 @@ public interface IOnboardingPositionService : ITransient
/// 分页查询入职业务岗位。 /// 分页查询入职业务岗位。
/// </summary> /// </summary>
Task<SqlSugarPagedList<ObPosition>> Page(OnboardingPositionPageInput input); Task<SqlSugarPagedList<ObPosition>> Page(OnboardingPositionPageInput input);
/// <summary> /// <summary>
/// 查询指定公司下全部已启用岗位。 /// 查询指定公司下全部已启用岗位。
/// </summary> /// </summary>
Task<List<ObPosition>> ListEnabled(long companyId); Task<List<ObPosition>> ListEnabled(long companyId);
/// <summary>
/// 查询指定部门下全部已启用岗位。
/// </summary>
Task<List<ObPosition>> ListEnabledByDepartment(long departmentId);
/// <summary> /// <summary>
/// 查询岗位详情。 /// 查询岗位详情。
/// </summary> /// </summary>
Task<ObPosition> Detail(BaseIdInput input); Task<ObPosition> Detail(BaseIdInput input);
/// <summary> /// <summary>
/// 新增入职业务岗位。 /// 新增入职业务岗位。
/// </summary> /// </summary>
Task Add(OnboardingPositionAddInput input); Task Add(OnboardingPositionAddInput input);
/// <summary> /// <summary>
/// 编辑入职业务岗位。 /// 编辑入职业务岗位。
/// </summary> /// </summary>
Task Edit(OnboardingPositionEditInput input); Task Edit(OnboardingPositionEditInput input);
/// <summary> /// <summary>
/// 删除未被任务或正式员工引用的岗位。 /// 删除未被任务或正式员工引用的岗位。
/// </summary> /// </summary>
@@ -5,19 +5,58 @@ namespace SimpleAdmin.Application;
/// </summary> /// </summary>
public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPositionService public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPositionService
{ {
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingPositionService(IOnboardingDataPermissionService dataPermission)
{
_dataPermission = dataPermission;
}
/// <summary> /// <summary>
/// 分页查询入职业务岗位。 /// 分页查询入职业务岗位。
/// </summary> /// </summary>
public async Task<SqlSugarPagedList<ObPosition>> Page(OnboardingPositionPageInput input) 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(input.CompanyId.HasValue, it => it.CompanyId == input.CompanyId.Value)
.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.PositionCode), it => it.PositionCode.Contains(input.PositionCode))
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName)) .WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}") .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), input.GetSafeOrderBy("Id", "Status", "CreateTime", "UpdateTime", "CreateUser", "CreateUserId", "CompanyId", "DepartmentId", "PositionCode", "PositionName"))
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.PositionName) .OrderBy(it => it.PositionName)
.ToPagedListAsync(input.PageNum, input.PageSize); .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> /// <summary>
@@ -31,6 +70,17 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
.ToListAsync(); .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>
/// 查询岗位详情。 /// 查询岗位详情。
/// </summary> /// </summary>
@@ -44,7 +94,12 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
/// </summary> /// </summary>
public async Task Add(OnboardingPositionAddInput input) public async Task Add(OnboardingPositionAddInput input)
{ {
NormalizeInput(input);
if (!input.DepartmentId.HasValue)
throw Oops.Bah("请选择所属部门");
await EnsureCompanyExists(input.CompanyId); await EnsureCompanyExists(input.CompanyId);
await EnsureDepartmentExists(input.CompanyId, input.DepartmentId.Value);
await EnsureCodeUnique(input.CompanyId, input.PositionCode, 0); await EnsureCodeUnique(input.CompanyId, input.PositionCode, 0);
await InsertAsync(input.Adapt<ObPosition>()); await InsertAsync(input.Adapt<ObPosition>());
} }
@@ -54,8 +109,18 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
/// </summary> /// </summary>
public async Task Edit(OnboardingPositionEditInput input) 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 EnsureCompanyExists(input.CompanyId);
await EnsureDepartmentExists(input.CompanyId, input.DepartmentId.Value);
await EnsureCodeUnique(input.CompanyId, input.PositionCode, input.Id); await EnsureCodeUnique(input.CompanyId, input.PositionCode, input.Id);
await UpdateAsync(input.Adapt<ObPosition>()); await UpdateAsync(input.Adapt<ObPosition>());
} }
@@ -68,6 +133,16 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
if (input.Ids.Count == 0) if (input.Ids.Count == 0)
return; 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>() var hasTasks = await Context.Queryable<ObOnboardingTask>()
.AnyAsync(it => input.Ids.Contains(it.PositionId)); .AnyAsync(it => input.Ids.Contains(it.PositionId));
if (hasTasks) if (hasTasks)
@@ -78,14 +153,74 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
if (hasEmployees) if (hasEmployees)
throw Oops.Bah("岗位已有正式员工,不能删除"); 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()); await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
} }
private async Task EnsureCompanyExists(long companyId) 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) 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) private async Task EnsureCodeUnique(long companyId, string code, long id)
@@ -1,38 +1,5 @@
namespace SimpleAdmin.Application; 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>
/// 保存员工已上传材料参数。 /// 保存员工已上传材料参数。
/// </summary> /// </summary>
@@ -104,4 +71,27 @@ public class OnboardingSubmitInput
/// </summary> /// </summary>
[global::System.ComponentModel.DataAnnotations.Required] [global::System.ComponentModel.DataAnnotations.Required]
public string Token { get; set; } 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,11 +5,6 @@ namespace SimpleAdmin.Application;
/// </summary> /// </summary>
public interface IOnboardingPublicSubmissionService : ITransient public interface IOnboardingPublicSubmissionService : ITransient
{ {
/// <summary>
/// 保存候选人填写的动态字段值。
/// </summary>
Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input);
/// <summary> /// <summary>
/// 关联候选人已经上传的系统文件与材料类型。 /// 关联候选人已经上传的系统文件与材料类型。
/// </summary> /// </summary>
@@ -5,28 +5,32 @@ namespace SimpleAdmin.Application;
/// </summary> /// </summary>
public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionService
{ {
private const int PublicMaterialMaxFileSizeMb = 20;
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IOnboardingContractSnapshotService _snapshotService; private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingIdentityValidationService _identityValidationService;
private readonly IOnboardingContractVariableValidationService _contractVariableValidationService;
public OnboardingPublicSubmissionService(IOnboardingContractSnapshotService snapshotService) public OnboardingPublicSubmissionService(
IOnboardingContractSnapshotService snapshotService,
IOnboardingIdentityValidationService identityValidationService,
IOnboardingContractVariableValidationService contractVariableValidationService)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_snapshotService = snapshotService; _snapshotService = snapshotService;
} _identityValidationService = identityValidationService;
_contractVariableValidationService = contractVariableValidationService;
/// <summary>
/// 保存候选人填写的动态补充字段值。
/// </summary>
public async Task SaveDynamicValues(OnboardingSaveDynamicValuesInput input)
{
await GetEditableTask(input.Token);
throw Oops.Bah("新版入职登记表不再使用旧动态字段接口,请通过员工基础信息接口保存补充变量");
} }
/// <summary> /// <summary>
/// 关联已上传 OSS 文件与任务材料要求。 /// 关联已上传 OSS 文件与任务材料要求。
/// </summary> /// </summary>
public async Task SaveMaterial(OnboardingSaveMaterialInput input) public async Task SaveMaterial(OnboardingSaveMaterialInput input)
{
await OnboardingConcurrency.RunPublic(_db, input.Token, () => SaveMaterialCore(input), transactional: true);
}
private async Task SaveMaterialCore(OnboardingSaveMaterialInput input)
{ {
var task = await GetEditableTask(input.Token); var task = await GetEditableTask(input.Token);
var requirement = await _db.Queryable<ObTaskMaterialRequirement>() var requirement = await _db.Queryable<ObTaskMaterialRequirement>()
@@ -86,6 +90,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// </summary> /// </summary>
public async Task DeleteMaterial( public async Task DeleteMaterial(
OnboardingDeleteMaterialInput input) 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 task = await GetEditableTask(input.Token);
var material = await _db.Queryable<ObTaskMaterial>() var material = await _db.Queryable<ObTaskMaterial>()
@@ -104,6 +114,11 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 记录候选人已经查看并确认动态生成的合同。 /// 记录候选人已经查看并确认动态生成的合同。
/// </summary> /// </summary>
public async Task ConfirmContract(OnboardingConfirmContractInput input) 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 task = await GetEditableTask(input.Token);
var contract = await _db.Queryable<ObTaskContract>() var contract = await _db.Queryable<ObTaskContract>()
@@ -121,18 +136,28 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
/// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。 /// 校验固定信息、动态字段、材料、合同变量和合同确认后提交审核。
/// </summary> /// </summary>
public async Task Submit(OnboardingSubmitInput input) 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 task = await GetEditableTask(input.Token);
var info = await _db.Queryable<ObTaskEmployeeInfo>() var info = await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == task.Id); .FirstAsync(item => item.TaskId == task.Id)
var basicInfoIncomplete = info == null ?? throw Oops.Bah("请先完整填写基本信息");
|| string.IsNullOrWhiteSpace(info.EmployeeName)
|| string.IsNullOrWhiteSpace(info.Phone) // 最终提交必须重新执行全量校验,不能只依赖员工手机端的页面校验。
|| string.IsNullOrWhiteSpace(info.IdCardNumber) var formRequirements =
|| string.IsNullOrWhiteSpace(info.BankCardNumber) await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
|| string.IsNullOrWhiteSpace(info.BankName); OnboardingEmployeeInfoValidator.ValidateAndNormalize(info, formRequirements);
if (basicInfoIncomplete) EnsureIdentityInformationConfirmed(input, info);
throw Oops.Bah("请先完整填写基本信息"); await _db.Updateable(info).ExecuteCommandAsync();
await _identityValidationService.EnsureNoDuplicate(
task.Id,
info.EmployeeName,
info.IdCardNumber);
await _snapshotService.Build(task.Id, info); await _snapshotService.Build(task.Id, info);
@@ -148,18 +173,28 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
if (hasMissingRequiredMaterial) if (hasMissingRequiredMaterial)
throw Oops.Bah("仍有必传材料未上传"); throw Oops.Bah("仍有必传材料未上传");
await EnsureRequiredContractVariables(task); await _contractVariableValidationService.Validate(task.Id);
var hasUnconfirmedRequiredContract = await _db.Queryable<ObTaskContract>() var hasUnconfirmedRequiredContract = await _db.Queryable<ObTaskContract>()
.AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null); .AnyAsync(item => item.TaskId == task.Id && item.Required && item.ConfirmedAt == null);
if (hasUnconfirmedRequiredContract) 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.TaskStatus = OnboardingTaskStatus.Reviewing;
task.SubmitCount++; task.SubmitCount++;
task.LastSubmittedAt = DateTime.Now; task.LastSubmittedAt = submittedAt;
task.RejectReason = null; 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 await _db.Insertable(new ObTaskAudit
{ {
@@ -167,9 +202,18 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
TaskId = task.Id, TaskId = task.Id,
AuditRound = task.SubmitCount, AuditRound = task.SubmitCount,
AuditAction = OnboardingAuditAction.Submit, AuditAction = OnboardingAuditAction.Submit,
AuditOpinion = "员工提交入职资料", AuditOpinion = "员工提交入职资料,并确认姓名、身份证号码和手机号码用于实名认证",
SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new { info, materials }), SnapshotJson = global::System.Text.Json.JsonSerializer.Serialize(new
AuditedAt = DateTime.Now {
info,
materials,
identityConfirmation = new
{
confirmedAt = submittedAt,
summaryHash = identityConfirmationHash
}
}),
AuditedAt = submittedAt
}).ExecuteCommandAsync(); }).ExecuteCommandAsync();
} }
@@ -190,10 +234,12 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
if (suffixNotAllowed) if (suffixNotAllowed)
throw Oops.Bah("该材料不允许上传此文件类型"); throw Oops.Bah("该材料不允许上传此文件类型");
var fileTooLarge = requirement.MaxFileSizeMb.HasValue var maxFileSizeMb = requirement.MaxFileSizeMb.HasValue
&& input.FileLength > requirement.MaxFileSizeMb.Value * 1024L * 1024L; ? Math.Min(requirement.MaxFileSizeMb.Value, PublicMaterialMaxFileSizeMb)
: PublicMaterialMaxFileSizeMb;
var fileTooLarge = input.FileLength > maxFileSizeMb * 1024L * 1024L;
if (fileTooLarge) if (fileTooLarge)
throw Oops.Bah("文件超过允许的大小"); throw Oops.Bah($"单个文件不能超过{maxFileSizeMb}MB,请压缩后重新上传");
var materials = await _db.Queryable<ObTaskMaterial>() var materials = await _db.Queryable<ObTaskMaterial>()
.Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId) .Where(item => item.TaskId == task.Id && item.MaterialTypeId == input.MaterialTypeId)
@@ -214,29 +260,36 @@ public class OnboardingPublicSubmissionService : IOnboardingPublicSubmissionServ
throw Oops.Bah("该材料已达到最大上传数量"); 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>() if (!input.IdentityInformationConfirmed)
.Where(item => item.TaskId == task.Id) throw Oops.Bah("请逐项确认姓名、身份证号码和手机号码无误");
.ToListAsync();
var contractIds = contracts.Select(contract => contract.Id).ToList(); var confirmedEmployeeName = input.ConfirmedEmployeeName?.Trim();
var templateVersionIds = contracts.Select(contract => contract.TemplateVersionId).ToList(); var confirmedIdCardNumber = input.ConfirmedIdCardNumber?.Trim().ToUpperInvariant();
var variables = await _db.Queryable<ObTaskContractVariable>() var confirmedPhone = input.ConfirmedPhone?.Trim();
.Where(item => contractIds.Contains(item.TaskContractId))
.ToListAsync(); if (!string.Equals(confirmedEmployeeName, info.EmployeeName, StringComparison.Ordinal))
var definitions = await _db.Queryable<ObTemplateVariable>() throw Oops.Bah("姓名在确认后发生了变化,请返回基本信息重新核对并确认");
.Where(item => templateVersionIds.Contains(item.TemplateVersionId) && item.Required) if (!string.Equals(confirmedIdCardNumber, info.IdCardNumber, StringComparison.Ordinal))
.ToListAsync(); throw Oops.Bah("身份证号码在确认后发生了变化,请返回基本信息重新核对并确认");
var missing = contracts if (!string.Equals(confirmedPhone, info.Phone, StringComparison.Ordinal))
.SelectMany(contract => definitions throw Oops.Bah("手机号码在确认后发生了变化,请返回基本信息重新核对并确认");
.Where(definition => definition.TemplateVersionId == contract.TemplateVersionId) }
.Select(definition => new { contract, definition }))
.FirstOrDefault(item => string.IsNullOrWhiteSpace( /// <summary>
variables.FirstOrDefault(variable => variable.TaskContractId == item.contract.Id /// 审核快照只记录实名信息摘要,完整原始值仍以本轮员工信息快照为准。
&& variable.VariableCode == item.definition.VariableCode) /// </summary>
?.VariableValue)); private static string BuildIdentityConfirmationHash(ObTaskEmployeeInfo info)
if (missing != null) {
throw Oops.Bah($"合同“{missing.contract.ContractName}”的必填变量“{missing.definition.VariableName}”尚未填写"); var value = $"{info.EmployeeName}\n{info.IdCardNumber}\n{info.Phone}";
var bytes = global::System.Text.Encoding.UTF8.GetBytes(value);
return Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(bytes));
} }
private Task<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId) private Task<List<ObTaskMaterialRequirement>> GetMaterialRequirements(long taskId)
@@ -0,0 +1,68 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 生成并读取入职登记表内置员工字段的任务级必填快照。
/// </summary>
internal static class OnboardingEmployeeFieldRequirementSnapshot
{
private const string EmployeeVariablePrefix = "employee.";
/// <summary>
/// 根据当前内置员工变量配置创建一份任务快照。
/// </summary>
public static async Task<List<ObTaskFormRequirement>> BuildFromCurrentDefinitions(
SqlSugarScope db,
long taskId)
{
var definitions = await db.Queryable<ObVariableDefinition>()
.Where(item => item.IsBuiltIn
&& item.VariableCategory == ContractVariableType.Employee)
.OrderBy(item => item.SortCode)
.OrderBy(item => item.Id)
.ToListAsync();
return definitions.Select(definition => new ObTaskFormRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
FieldId = definition.Id,
FieldCode = definition.VariableCode,
FieldName = definition.VariableName,
DataType = definition.DataType,
Required = definition.Required,
SortCode = definition.SortCode
}).ToList();
}
/// <summary>
/// 读取任务冻结配置;历史任务没有员工字段快照时使用当前配置兼容。
/// </summary>
public static async Task<List<ObTaskFormRequirement>> GetForTask(
SqlSugarScope db,
long taskId)
{
var snapshots = await db.Queryable<ObTaskFormRequirement>()
.Where(item => item.TaskId == taskId)
.OrderBy(item => item.SortCode)
.ToListAsync();
if (snapshots.Any(IsEmployeeField))
return snapshots;
var compatibilitySnapshots = await BuildFromCurrentDefinitions(db, taskId);
if (snapshots.Count == 0)
return compatibilitySnapshots;
snapshots.AddRange(compatibilitySnapshots);
return snapshots.OrderBy(item => item.SortCode).ToList();
}
/// <summary>
/// 判断快照是否对应入职登记表内置员工字段。
/// </summary>
public static bool IsEmployeeField(ObTaskFormRequirement requirement)
{
return requirement?.FieldCode?.StartsWith(
EmployeeVariablePrefix,
StringComparison.OrdinalIgnoreCase) == true;
}
}
@@ -0,0 +1,350 @@
namespace SimpleAdmin.Application;
/// <summary>
/// 统一校验并规范化员工端固定入职信息。
/// </summary>
internal static class OnboardingEmployeeInfoValidator
{
private static readonly int[] IdCardWeights =
{
7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2
};
private static readonly char[] IdCardCheckCodes =
{
'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'
};
/// <summary>
/// 规范化文本,并按照任务冻结配置校验固定登记表必填项及字段格式。
/// </summary>
public static void ValidateAndNormalize(
ObTaskEmployeeInfo input,
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements = null)
{
input.EmployeeName = input.EmployeeName?.Trim();
input.Phone = input.Phone?.Trim();
input.Email = input.Email?.Trim();
input.IdCardNumber = input.IdCardNumber?.Trim().ToUpperInvariant();
input.BankCardNumber = global::System.Text.RegularExpressions.Regex.Replace(
input.BankCardNumber ?? string.Empty,
@"[\s-]",
string.Empty);
input.BankName = input.BankName?.Trim();
input.BankAccountName = input.EmployeeName;
ValidateRequiredFields(input, fieldRequirements);
// 电子签约实名认证始终依赖以下三项,不能通过后台配置关闭。
if (string.IsNullOrWhiteSpace(input.EmployeeName) || input.EmployeeName.Length > 100)
throw Oops.Bah("员工姓名不能为空且不能超过 100 个字符");
if (!string.IsNullOrWhiteSpace(input.Gender)
&& input.Gender != "男"
&& input.Gender != "女")
{
throw Oops.Bah("请选择正确的性别");
}
if (!global::System.Text.RegularExpressions.Regex.IsMatch(
input.Phone ?? string.Empty,
@"^1[3-9]\d{9}$"))
{
throw Oops.Bah("请输入正确的 11 位手机号码");
}
if (!TryValidateIdCard(input.IdCardNumber, out var idCardBirthday, out var idCardError))
throw Oops.Bah(idCardError);
ValidateBirthday(input.Birthday, idCardBirthday);
ValidateOptionalEmail(input.Email);
ValidateOptionalNumber(input.Height, "身高", 100M, 250M, "厘米");
ValidateOptionalNumber(input.Weight, "体重", 30M, 300M, "公斤");
if (!string.IsNullOrWhiteSpace(input.BankCardNumber))
ValidateBankCard(input.BankCardNumber);
if (!string.IsNullOrWhiteSpace(input.BankName) && input.BankName.Length > 100)
throw Oops.Bah("开户银行名称不能超过 100 个字符");
ValidateOptionalContactPhone(input.EmergencyPhone);
ValidateEducationHistory(input.EducationHistoryJson);
ValidateWorkHistory(input.WorkHistoryJson);
ValidateFamilyMembers(input.FamilyMembersJson);
ValidateJson(input.CustomVariablesJson, global::System.Text.Json.JsonValueKind.Object, "员工补充变量");
}
/// <summary>
/// 按任务快照校验后台配置为必填的内置员工字段。
/// </summary>
private static void ValidateRequiredFields(
ObTaskEmployeeInfo input,
IReadOnlyCollection<ObTaskFormRequirement> fieldRequirements)
{
if (fieldRequirements == null || fieldRequirements.Count == 0)
return;
foreach (var requirement in fieldRequirements.Where(item =>
item.Required
&& OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField(item)))
{
var separatorIndex = requirement.FieldCode.LastIndexOf('.');
var propertyName = separatorIndex >= 0
? requirement.FieldCode[(separatorIndex + 1)..]
: requirement.FieldCode;
var property = typeof(ObTaskEmployeeInfo).GetProperty(
propertyName,
global::System.Reflection.BindingFlags.Instance
| global::System.Reflection.BindingFlags.Public
| global::System.Reflection.BindingFlags.IgnoreCase);
if (property == null)
{
throw Oops.Bah(
$"内置员工变量“{requirement.FieldCode}”没有对应的入职登记表字段,请联系管理员");
}
var value = property.GetValue(input)?.ToString();
if (string.IsNullOrWhiteSpace(value))
throw Oops.Bah($"请填写入职登记表“{requirement.FieldName}”");
}
}
private static bool TryValidateIdCard(
string idCardNumber,
out DateTime birthday,
out string errorMessage)
{
birthday = default;
errorMessage = null;
var structureIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
idCardNumber ?? string.Empty,
"^\\d{17}[0-9X]$");
if (!structureIsValid)
{
errorMessage = "请输入正确的 18 位身份证号码";
return false;
}
var birthdayText = idCardNumber.Substring(6, 8);
var birthdayIsValid = DateTime.TryParseExact(
birthdayText,
"yyyyMMdd",
global::System.Globalization.CultureInfo.InvariantCulture,
global::System.Globalization.DateTimeStyles.None,
out birthday);
if (!birthdayIsValid || birthday.Date > DateTime.Today)
{
errorMessage = "身份证号码中的出生日期不正确";
return false;
}
var total = 0;
for (var index = 0; index < IdCardWeights.Length; index++)
{
total += (idCardNumber[index] - '0') * IdCardWeights[index];
}
if (IdCardCheckCodes[total % 11] == idCardNumber[17])
return true;
errorMessage = "身份证号码校验位不正确";
return false;
}
private static void ValidateBirthday(string birthdayText, DateTime? idCardBirthday)
{
if (string.IsNullOrWhiteSpace(birthdayText))
return;
var birthdayIsValid = DateTime.TryParseExact(
birthdayText.Trim(),
"yyyy-MM-dd",
global::System.Globalization.CultureInfo.InvariantCulture,
global::System.Globalization.DateTimeStyles.None,
out var birthday);
if (!birthdayIsValid || birthday.Date > DateTime.Today)
throw Oops.Bah("请输入正确的出生日期");
if (idCardBirthday.HasValue && birthday.Date != idCardBirthday.Value.Date)
throw Oops.Bah("出生日期与身份证号码不一致");
}
private static void ValidateOptionalEmail(string email)
{
if (string.IsNullOrWhiteSpace(email))
return;
var emailIsValid = email.Length <= 100
&& global::System.Net.Mail.MailAddress.TryCreate(email, out _);
if (!emailIsValid)
throw Oops.Bah("请输入正确的电子邮箱");
}
private static void ValidateOptionalNumber(
string value,
string fieldName,
decimal min,
decimal max,
string unit)
{
if (string.IsNullOrWhiteSpace(value))
return;
var numberIsValid = decimal.TryParse(
value.Trim(),
global::System.Globalization.NumberStyles.Number,
global::System.Globalization.CultureInfo.InvariantCulture,
out var number);
if (!numberIsValid || number < min || number > max)
{
throw Oops.Bah($"{fieldName}应为 {min:0} 至 {max:0} {unit}之间的数字");
}
}
private static void ValidateBankCard(string bankCardNumber)
{
var bankCardIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
bankCardNumber ?? string.Empty,
"^\\d{12,19}$");
if (!bankCardIsValid)
throw Oops.Bah("请输入 12 至 19 位银行卡号");
}
private static void ValidateOptionalContactPhone(string phone)
{
if (string.IsNullOrWhiteSpace(phone))
return;
var contactPhoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
phone.Trim(),
"^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$");
if (!contactPhoneIsValid)
throw Oops.Bah("请输入正确的紧急联系人手机号码或座机号码");
}
private static void ValidateEducationHistory(string json)
{
ValidateHistoryRows(json, "教育经历", (row, rowNumber) =>
{
GetRequiredRowValue(row, "timeRange", rowNumber, "教育经历", "时间范围");
GetRequiredRowValue(row, "location", rowNumber, "教育经历", "学校或机构");
GetRequiredRowValue(row, "content", rowNumber, "教育经历", "专业或学习内容");
GetRequiredRowValue(row, "result", rowNumber, "教育经历", "结果或学位");
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条教育学习经历");
}
private static void ValidateWorkHistory(string json)
{
ValidateHistoryRows(json, "工作经历", (row, rowNumber) =>
{
GetRequiredRowValue(row, "timeRange", rowNumber, "工作经历", "起止时间");
GetRequiredRowValue(row, "companyName", rowNumber, "工作经历", "工作单位");
GetRequiredRowValue(row, "position", rowNumber, "工作经历", "担任职务");
GetRequiredRowValue(row, "leaveReason", rowNumber, "工作经历", "离职原因");
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一条工作经历(应届生可填写兼职、在校或实习经历)");
}
private static void ValidateFamilyMembers(string json)
{
ValidateHistoryRows(json, "家庭成员", (row, rowNumber) =>
{
GetRequiredRowValue(row, "name", rowNumber, "家庭成员", "姓名");
GetRequiredRowValue(row, "relationship", rowNumber, "家庭成员", "关系");
var ageText = GetRequiredRowValue(row, "age", rowNumber, "家庭成员", "年龄");
var phone = GetRequiredRowValue(row, "phone", rowNumber, "家庭成员", "联系电话");
var ageIsValid = int.TryParse(ageText, out var age) && age >= 0 && age <= 120;
if (!ageIsValid)
throw Oops.Bah($"第 {rowNumber} 条家庭成员的年龄应为 0 至 120 之间的整数");
var phoneIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
phone,
"^(1[3-9]\\d{9}|0\\d{2,3}-?\\d{7,8})$");
if (!phoneIsValid)
throw Oops.Bah($"第 {rowNumber} 条家庭成员的联系电话格式不正确");
}, requireAtLeastOne: true, emptyErrorMessage: "请至少添加一位家庭成员信息");
}
private static void ValidateHistoryRows(
string json,
string fieldName,
Action<global::System.Text.Json.JsonElement, int> validateRow,
bool requireAtLeastOne = false,
string emptyErrorMessage = null)
{
if (string.IsNullOrWhiteSpace(json))
{
if (requireAtLeastOne)
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
return;
}
try
{
using var document = global::System.Text.Json.JsonDocument.Parse(json);
if (document.RootElement.ValueKind != global::System.Text.Json.JsonValueKind.Array)
throw new global::System.Text.Json.JsonException();
var rowNumber = 0;
foreach (var row in document.RootElement.EnumerateArray())
{
rowNumber++;
if (row.ValueKind != global::System.Text.Json.JsonValueKind.Object)
throw Oops.Bah($"第 {rowNumber} 条{fieldName}格式不正确,请删除后重新添加");
validateRow(row, rowNumber);
}
if (requireAtLeastOne && rowNumber == 0)
{
throw Oops.Bah(emptyErrorMessage ?? $"请至少添加一条{fieldName}");
}
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写");
}
}
private static string GetRequiredRowValue(
global::System.Text.Json.JsonElement row,
string propertyName,
int rowNumber,
string fieldName,
string displayName)
{
if (!row.TryGetProperty(propertyName, out var property)
|| property.ValueKind is global::System.Text.Json.JsonValueKind.Null
or global::System.Text.Json.JsonValueKind.Undefined)
{
throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}");
}
var value = property.ValueKind == global::System.Text.Json.JsonValueKind.String
? property.GetString()
: property.ToString();
if (string.IsNullOrWhiteSpace(value))
throw Oops.Bah($"第 {rowNumber} 条{fieldName}请填写{displayName}");
return value.Trim();
}
private static void ValidateJson(
string json,
global::System.Text.Json.JsonValueKind expectedKind,
string fieldName)
{
if (string.IsNullOrWhiteSpace(json))
return;
try
{
using var document = global::System.Text.Json.JsonDocument.Parse(json);
if (document.RootElement.ValueKind != expectedKind)
throw new global::System.Text.Json.JsonException();
}
catch (global::System.Text.Json.JsonException)
{
throw Oops.Bah($"{fieldName}格式不正确,请刷新页面后重新填写");
}
}
}
@@ -8,25 +8,36 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IOnboardingContractSnapshotService _snapshotService; private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingContractDocumentService _contractDocumentService; private readonly IOnboardingContractDocumentService _contractDocumentService;
private readonly IOnboardingIdentityValidationService _identityValidationService;
public OnboardingPublicTaskService( public OnboardingPublicTaskService(
IOnboardingContractSnapshotService snapshotService, IOnboardingContractSnapshotService snapshotService,
IOnboardingContractDocumentService contractDocumentService) IOnboardingContractDocumentService contractDocumentService,
IOnboardingIdentityValidationService identityValidationService)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_snapshotService = snapshotService; _snapshotService = snapshotService;
_contractDocumentService = contractDocumentService; _contractDocumentService = contractDocumentService;
_identityValidationService = identityValidationService;
} }
/// <summary> /// <summary>
/// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。 /// 通过有效 Token 获取任务创建时冻结的材料、合同和新版入职登记表信息。
/// </summary> /// </summary>
public async Task<OnboardingTaskPublicDetailOutput> Detail(OnboardingTaskPublicAccessInput input) 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 link = await GetActiveLink(input.Token);
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
// 员工端必须展示当前 Token 的实际到期时间,不能依赖任务表中的冗余时间。
task.LinkExpireAt = link.ExpiresAt;
link.AccessCount++; link.AccessCount++;
link.LastAccessedAt = DateTime.Now; link.LastAccessedAt = DateTime.Now;
await _db.Updateable(link).ExecuteCommandAsync(); await _db.Updateable(link).ExecuteCommandAsync();
@@ -34,6 +45,8 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
.Where(item => item.TaskId == task.Id) .Where(item => item.TaskId == task.Id)
.OrderBy(item => item.SortCode) .OrderBy(item => item.SortCode)
.ToListAsync(); .ToListAsync();
var formRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, task.Id);
var contracts = await _db.Queryable<ObTaskContract>() var contracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id) .Where(item => item.TaskId == task.Id)
@@ -100,7 +113,17 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
Contracts = contracts, Contracts = contracts,
DynamicValues = new List<ObTaskFormValue>(), DynamicValues = new List<ObTaskFormValue>(),
Materials = materialOutputs, Materials = materialOutputs,
FormFields = new List<OnboardingPublicFormFieldOutput>(), FormFields = formRequirements
.Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField)
.Select(requirement => new OnboardingPublicFormFieldOutput
{
FieldId = requirement.FieldId,
FieldCode = requirement.FieldCode,
FieldName = requirement.FieldName,
DataType = requirement.DataType,
OptionsJson = requirement.OptionsJson,
Required = requirement.Required
}).ToList(),
MaterialRequirements = materialRequirements.Select(requirement => new OnboardingPublicMaterialOutput MaterialRequirements = materialRequirements.Select(requirement => new OnboardingPublicMaterialOutput
{ {
MaterialTypeId = requirement.MaterialTypeId, MaterialTypeId = requirement.MaterialTypeId,
@@ -118,6 +141,11 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。 /// 保存候选人的固定基础信息并刷新尚未确认的合同变量快照。
/// </summary> /// </summary>
public async Task SaveEmployeeInfo(OnboardingTaskPublicSaveInput input) 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 link = await GetActiveLink(input.Token);
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(link.TaskId)
@@ -128,6 +156,14 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
throw Oops.Bah("当前任务不可编辑"); throw Oops.Bah("当前任务不可编辑");
} }
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>() var old = await _db.Queryable<ObTaskEmployeeInfo>()
.FirstAsync(item => item.TaskId == task.Id); .FirstAsync(item => item.TaskId == task.Id);
var entity = input.Adapt<ObTaskEmployeeInfo>(); var entity = input.Adapt<ObTaskEmployeeInfo>();
@@ -166,6 +202,12 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// </summary> /// </summary>
public async Task<OnboardingPublicContractPrepareOutput> PrepareContract( public async Task<OnboardingPublicContractPrepareOutput> PrepareContract(
OnboardingPublicContractSourceInput input) 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 link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>() var contract = await _db.Queryable<ObTaskContract>()
@@ -188,10 +230,9 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
} }
await _snapshotService.Build(link.TaskId); await _snapshotService.Build(link.TaskId);
var fileId = await _contractDocumentService.Generate(new OnboardingGenerateContractInput var fileId = await _contractDocumentService.GenerateForPublicAccess(
{ contract.Id,
TaskContractId = contract.Id link.TaskId);
});
var generatedAt = await _db.Queryable<ObTaskContract>() var generatedAt = await _db.Queryable<ObTaskContract>()
.Where(item => item.Id == contract.Id) .Where(item => item.Id == contract.Id)
@@ -208,15 +249,26 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
/// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。 /// 返回已经准备完成的合同文件,下载阶段不再执行变量校验和文档生成。
/// </summary> /// </summary>
public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input) public async Task<long> GetContractSourceFileId(OnboardingPublicContractSourceInput input)
{
return await OnboardingConcurrency.RunPublic(_db, input.Token, () => GetContractSourceFileIdCore(input));
}
private async Task<long> GetContractSourceFileIdCore(OnboardingPublicContractSourceInput input)
{ {
var link = await GetActiveLink(input.Token); var link = await GetActiveLink(input.Token);
var contract = await _db.Queryable<ObTaskContract>() var contract = await _db.Queryable<ObTaskContract>()
.FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == link.TaskId) .FirstAsync(item => item.Id == input.TaskContractId && item.TaskId == link.TaskId)
?? throw Oops.Bah("合同不存在"); ?? throw Oops.Bah("合同不存在");
var fileId = contract.GeneratedDocxFileId var fileId = contract.GeneratedDocxFileId;
?? throw Oops.Bah("合同尚未准备完成,请刷新页面后重试"); if (!fileId.HasValue)
{
// 下载入口增加服务端兜底,避免前端状态与数据库短暂不同步时打开 JSON 错误页。
var prepared = await PrepareContract(input);
fileId = prepared.FileId;
}
var fileExists = await _db.Queryable<SysFile>() var fileExists = await _db.Queryable<SysFile>()
.AnyAsync(item => item.Id == fileId); .AnyAsync(item => item.Id == fileId.Value);
if (!fileExists) if (!fileExists)
throw Oops.Bah("合同文件不存在,请重新准备合同"); throw Oops.Bah("合同文件不存在,请重新准备合同");
@@ -227,7 +279,7 @@ public class OnboardingPublicTaskService : IOnboardingPublicTaskService
await _db.Updateable(contract) await _db.Updateable(contract)
.UpdateColumns(item => new { item.ReadAt, item.ContractStatus }) .UpdateColumns(item => new { item.ReadAt, item.ContractStatus })
.ExecuteCommandAsync(); .ExecuteCommandAsync();
return fileId; return fileId.Value;
} }
/// <summary> /// <summary>
@@ -1,4 +1,6 @@
namespace SimpleAdmin.Application; using System.ComponentModel.DataAnnotations;
namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 创建新员工入职任务参数。 /// 创建新员工入职任务参数。
@@ -11,11 +13,23 @@ public class OnboardingTaskCreateInput
[IdNotNull(ErrorMessage = "公司Id不能为空")] [IdNotNull(ErrorMessage = "公司Id不能为空")]
public long CompanyId { get; set; } public long CompanyId { get; set; }
/// <summary>
/// 入职业务部门 Id。
/// </summary>
[IdNotNull(ErrorMessage = "部门Id不能为空")]
public long DepartmentId { get; set; }
/// <summary> /// <summary>
/// 入职任务所属岗位 Id。 /// 入职任务所属岗位 Id。
/// </summary> /// </summary>
[IdNotNull(ErrorMessage = "岗位Id不能为空")] [IdNotNull(ErrorMessage = "岗位Id不能为空")]
public long PositionId { get; set; } public long PositionId { get; set; }
/// <summary>
/// 用工类型:full_time 正式用工,part_time 兼职用工。
/// </summary>
public string EmploymentType { get; set; } = EmployeeEmploymentType.FullTime;
/// <summary> /// <summary>
/// 本次任务使用的入职模板 Id。 /// 本次任务使用的入职模板 Id。
/// </summary> /// </summary>
@@ -40,6 +54,41 @@ public class OnboardingTaskCreateInput
/// <summary> /// <summary>
/// 业务备注。 /// 业务备注。
/// </summary> /// </summary>
[Required(ErrorMessage = "人事备注不能为空")]
public string Remark { get; set; }
}
/// <summary>
/// 批量复制入职任务参数。
/// </summary>
public class OnboardingTaskCopyInput
{
/// <summary>
/// 被复制的原入职任务 Id。
/// </summary>
[IdNotNull(ErrorMessage = "原入职任务Id不能为空")]
public long SourceTaskId { get; set; }
/// <summary>
/// 在原任务之外需要新建的任务数量。
/// </summary>
public int Count { get; set; }
/// <summary>
/// 每条新任务分别填写的人事备注。
/// </summary>
public List<OnboardingTaskCopyItemInput> Items { get; set; } = new();
}
/// <summary>
/// 单条复制任务的个性化参数。
/// </summary>
public class OnboardingTaskCopyItemInput
{
/// <summary>
/// 新任务的人事备注,用于区分候选人和专属链接。
/// </summary>
[Required(ErrorMessage = "人事备注不能为空")]
public string Remark { get; set; } public string Remark { get; set; }
} }
@@ -90,3 +139,13 @@ public class OnboardingTaskCreatedOutput
/// </summary> /// </summary>
public DateTime ExpiresAt { get; set; } public DateTime ExpiresAt { get; set; }
} }
/// <summary>
/// 批量复制入职任务结果。
/// </summary>
public class OnboardingTaskCopyOutput
{
/// <summary>
/// 本次复制生成的全部新任务及独立链接信息。
/// </summary>
public List<OnboardingTaskCreatedOutput> Tasks { get; set; } = new();
}
@@ -79,24 +79,6 @@ public class OnboardingTaskPublicSaveInput : ObTaskEmployeeInfo
/// </summary> /// </summary>
[global::System.ComponentModel.DataAnnotations.Required] [global::System.ComponentModel.DataAnnotations.Required]
public string Token { get; set; } public string Token { get; set; }
/// <summary>
/// 员工姓名。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public new string EmployeeName { get; set; }
/// <summary>
/// 手机号码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public new string Phone { get; set; }
/// <summary>
/// 身份证号码。
/// </summary>
[global::System.ComponentModel.DataAnnotations.Required]
public new string IdCardNumber { get; set; }
} }
/// <summary> /// <summary>
@@ -9,4 +9,9 @@ public interface IOnboardingTaskService : ITransient
/// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。 /// 校验公司、岗位和合同范围后创建入职任务、链接及任务合同。
/// </summary> /// </summary>
Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input); Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input);
/// <summary>
/// 复制原任务的业务配置和人事参数,批量创建独立的新入职任务。
/// </summary>
Task<OnboardingTaskCopyOutput> Copy(OnboardingTaskCopyInput input);
} }
@@ -8,14 +8,17 @@ public class OnboardingTaskService : IOnboardingTaskService
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IConfigService _configService; private readonly IConfigService _configService;
private readonly IOnboardingContractSnapshotService _snapshotService; private readonly IOnboardingContractSnapshotService _snapshotService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskService( public OnboardingTaskService(
IConfigService configService, IConfigService configService,
IOnboardingContractSnapshotService snapshotService) IOnboardingContractSnapshotService snapshotService,
IOnboardingDataPermissionService dataPermission)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_configService = configService; _configService = configService;
_snapshotService = snapshotService; _snapshotService = snapshotService;
_dataPermission = dataPermission;
} }
/// <summary> /// <summary>
@@ -23,23 +26,35 @@ public class OnboardingTaskService : IOnboardingTaskService
/// </summary> /// </summary>
public async Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input) public async Task<OnboardingTaskCreatedOutput> Create(OnboardingTaskCreateInput input)
{ {
if (string.IsNullOrWhiteSpace(input.Remark))
throw Oops.Bah("请填写人事备注");
ValidateEmploymentType(input.EmploymentType);
var companyEnabled = await _db.Queryable<ObCompany>() var companyEnabled = await _db.Queryable<ObCompany>()
.AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable"); .AnyAsync(item => item.Id == input.CompanyId && item.Status == "enable");
if (!companyEnabled) if (!companyEnabled)
throw Oops.Bah("公司不存在或未启用"); 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>() var positionEnabled = await _db.Queryable<ObPosition>()
.AnyAsync(item => item.Id == input.PositionId .AnyAsync(item => item.Id == input.PositionId
&& item.CompanyId == input.CompanyId && item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == "enable"); && item.Status == "enable");
if (!positionEnabled) if (!positionEnabled)
throw Oops.Bah("岗位不存在、未启用或不属于该公司"); throw Oops.Bah("岗位不存在、未启用或不属于该部门/公司");
var onboardingTemplate = await _db.Queryable<ObOnboardingTemplate>() var onboardingTemplate = await _db.Queryable<ObOnboardingTemplate>()
.FirstAsync(item => item.Id == input.OnboardingTemplateId .FirstAsync(item => item.Id == input.OnboardingTemplateId
&& item.CompanyId == input.CompanyId && item.CompanyId == input.CompanyId
&& item.DepartmentId == input.DepartmentId
&& item.Status == "enable") && item.Status == "enable")
?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司"); ?? throw Oops.Bah("入职模板不存在、未启用或不属于该公司、部门");
var templateSupportsPosition = await _db.Queryable<ObOnboardingTemplatePosition>() var templateSupportsPosition = await _db.Queryable<ObOnboardingTemplatePosition>()
.AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id .AnyAsync(item => item.OnboardingTemplateId == onboardingTemplate.Id
&& item.PositionId == input.PositionId); && item.PositionId == input.PositionId);
@@ -62,12 +77,31 @@ public class OnboardingTaskService : IOnboardingTaskService
var contractTemplates = await _db.Queryable<ObContractTemplate>() var contractTemplates = await _db.Queryable<ObContractTemplate>()
.Where(item => templateIds.Contains(item.Id)) .Where(item => templateIds.Contains(item.Id))
.ToListAsync(); .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("入职模板包含未启用或不属于当前公司、部门的合同模板");
}
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 versionIds = contractConfigs.Select(item => item.ContractTemplateVersionId).Distinct().ToList();
var enabledVersionCount = await _db.Queryable<ObContractTemplateVersion>() var now = DateTime.Now;
.CountAsync(item => versionIds.Contains(item.Id) var taskVersions = await _db.Queryable<ObContractTemplateVersion>()
&& item.VersionStatus == ContractTemplateVersionStatus.Enabled); .Where(item => versionIds.Contains(item.Id))
if (enabledVersionCount != versionIds.Count) .ToListAsync();
throw Oops.Bah("入职模板包含未启用的合同版本,请先调整模板配置"); 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 materialIds = materialConfigs.Select(item => item.MaterialTypeId).Distinct().ToList();
var materialTypes = materialIds.Count == 0 var materialTypes = materialIds.Count == 0
@@ -94,12 +128,14 @@ public class OnboardingTaskService : IOnboardingTaskService
Id = taskId, Id = taskId,
TaskNo = $"OB{DateTime.Now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}", TaskNo = $"OB{DateTime.Now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}",
CompanyId = input.CompanyId, CompanyId = input.CompanyId,
DepartmentId = input.DepartmentId,
PositionId = input.PositionId, PositionId = input.PositionId,
EmploymentType = input.EmploymentType,
OnboardingTemplateId = onboardingTemplate.Id, OnboardingTemplateId = onboardingTemplate.Id,
AssignedReviewerUserId = input.AssignedReviewerUserId, AssignedReviewerUserId = input.AssignedReviewerUserId,
LinkExpireAt = expiresAt, LinkExpireAt = expiresAt,
TaskStatus = OnboardingTaskStatus.PendingFill, TaskStatus = OnboardingTaskStatus.PendingFill,
Remark = input.Remark Remark = input.Remark.Trim()
}; };
ValidateInitialContractDateRanges(input.ContractVariables); ValidateInitialContractDateRanges(input.ContractVariables);
@@ -127,9 +163,15 @@ public class OnboardingTaskService : IOnboardingTaskService
CreateTime = DateTime.Now CreateTime = DateTime.Now
}).ExecuteCommandAsync(); }).ExecuteCommandAsync();
var employeeFieldRequirements =
await OnboardingEmployeeFieldRequirementSnapshot.BuildFromCurrentDefinitions(_db, taskId);
if (employeeFieldRequirements.Count > 0)
await _db.Insertable(employeeFieldRequirements).ExecuteCommandAsync();
var contracts = contractConfigs.Select(config => var contracts = contractConfigs.Select(config =>
{ {
var template = contractTemplates.First(item => item.Id == config.ContractTemplateId); var template = contractTemplates.First(item => item.Id == config.ContractTemplateId);
var version = taskVersions.First(item => item.Id == config.ContractTemplateVersionId);
return new ObTaskContract return new ObTaskContract
{ {
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
@@ -139,6 +181,8 @@ public class OnboardingTaskService : IOnboardingTaskService
ContractName = template.TemplateName, ContractName = template.TemplateName,
ContractType = template.ContractType, ContractType = template.ContractType,
Required = config.Required, Required = config.Required,
CompanySealEnabled = version.CompanySealEnabled,
AcrossPageSealEnabled = version.AcrossPageSealEnabled,
PrintCopies = config.PrintCopies PrintCopies = config.PrintCopies
}; };
}).ToList(); }).ToList();
@@ -185,6 +229,265 @@ public class OnboardingTaskService : IOnboardingTaskService
}; };
} }
/// <summary>
/// 复制原任务锁定的业务配置与人事参数,并原子化创建多条独立任务。
/// </summary>
public async Task<OnboardingTaskCopyOutput> Copy(OnboardingTaskCopyInput input)
{
const int maxCopyCount = 50;
if (input.Count < 1 || input.Count > maxCopyCount)
throw Oops.Bah($"复制数量必须在1到{maxCopyCount}之间");
input.Items ??= new List<OnboardingTaskCopyItemInput>();
if (input.Items.Count != input.Count)
throw Oops.Bah("复制数量与人事备注数量不一致");
var missingRemarkIndex = input.Items.FindIndex(item =>
string.IsNullOrWhiteSpace(item.Remark));
if (missingRemarkIndex >= 0)
throw Oops.Bah($"请填写第{missingRemarkIndex + 1}份任务的人事备注");
var sourceTask = await _db.Queryable<ObOnboardingTask>()
.InSingleAsync(input.SourceTaskId)
?? throw Oops.Bah("原入职任务不存在");
await _dataPermission.EnsureCanManage(
sourceTask.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
if (!sourceTask.DepartmentId.HasValue)
throw Oops.Bah("原入职任务未设置部门,不能复制");
var companyEnabled = await _db.Queryable<ObCompany>()
.AnyAsync(item => item.Id == sourceTask.CompanyId && item.Status == "enable");
if (!companyEnabled)
throw Oops.Bah("原任务所属公司不存在或未启用");
var departmentEnabled = await _db.Queryable<ObDepartment>()
.AnyAsync(item => item.Id == sourceTask.DepartmentId.Value
&& item.CompanyId == sourceTask.CompanyId
&& item.Status == "enable");
if (!departmentEnabled)
throw Oops.Bah("原任务所属部门不存在或未启用");
var positionEnabled = await _db.Queryable<ObPosition>()
.AnyAsync(item => item.Id == sourceTask.PositionId
&& item.CompanyId == sourceTask.CompanyId
&& item.DepartmentId == sourceTask.DepartmentId.Value
&& item.Status == "enable");
if (!positionEnabled)
throw Oops.Bah("原任务所属岗位不存在或未启用");
var onboardingTemplateEnabled = await _db.Queryable<ObOnboardingTemplate>()
.AnyAsync(item => item.Id == sourceTask.OnboardingTemplateId
&& item.CompanyId == sourceTask.CompanyId
&& item.DepartmentId == sourceTask.DepartmentId.Value
&& item.Status == "enable");
if (!onboardingTemplateEnabled)
throw Oops.Bah("原任务使用的入职模板不存在或未启用");
var sourceContracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == sourceTask.Id)
.ToListAsync();
if (sourceContracts.Count == 0)
throw Oops.Bah("原入职任务没有合同快照,不能复制");
var versionIds = sourceContracts
.Select(item => item.TemplateVersionId)
.Distinct()
.ToList();
var now = DateTime.Now;
var copyVersions = await _db.Queryable<ObContractTemplateVersion>()
.Where(item => versionIds.Contains(item.Id))
.ToListAsync();
if (copyVersions.Count != versionIds.Count
|| copyVersions.Any(item => !ContractTemplateVersionValidity.IsAvailable(item, now)))
throw Oops.Bah("原任务包含未生效、已失效或未启用的签署材料版本,不能继续复制");
var sourceMaterialRequirements = await _db.Queryable<ObTaskMaterialRequirement>()
.Where(item => item.TaskId == sourceTask.Id)
.OrderBy(item => item.SortCode)
.ToListAsync();
var sourceEmployeeFieldRequirements =
(await OnboardingEmployeeFieldRequirementSnapshot.GetForTask(_db, sourceTask.Id))
.Where(OnboardingEmployeeFieldRequirementSnapshot.IsEmployeeField)
.ToList();
var sourceContractIds = sourceContracts.Select(item => item.Id).ToList();
var sourceManualVariables = await _db.Queryable<ObTaskContractVariable>()
.Where(item => sourceContractIds.Contains(item.TaskContractId)
&& item.SourceType == ContractVariableSourceType.HrInput
&& item.IsManualOverride)
.ToListAsync();
var initialContractVariables = (
from contract in sourceContracts
join variable in sourceManualVariables
on contract.Id equals variable.TaskContractId
select new OnboardingInitialContractVariableInput
{
ContractTemplateVersionId = contract.TemplateVersionId,
VariableCode = variable.VariableCode,
VariableValue = variable.VariableValue
}).ToList();
ValidateInitialContractDateRanges(initialContractVariables);
var expiryConfig = await _configService.GetByConfigKey(
CateGoryConst.CONFIG_BIZ_DEFINE,
"onboard.link.expireHours");
if (expiryConfig == null
|| !int.TryParse(expiryConfig.ConfigValue, out var expiryHours)
|| expiryHours <= 0)
{
throw Oops.Bah("请在系统配置中设置 onboard.link.expireHours(正整数小时)");
}
var outputs = new List<OnboardingTaskCreatedOutput>();
await _db.Ado.BeginTranAsync();
try
{
foreach (var item in input.Items)
{
var output = await CreateCopiedTask(
sourceTask,
sourceContracts,
sourceMaterialRequirements,
sourceEmployeeFieldRequirements,
initialContractVariables,
expiryHours,
item.Remark.Trim());
outputs.Add(output);
}
await _db.Ado.CommitTranAsync();
}
catch
{
await _db.Ado.RollbackTranAsync();
throw;
}
return new OnboardingTaskCopyOutput
{
Tasks = outputs
};
}
/// <summary>
/// 根据原任务快照创建一条全新的待填写任务,不复制任何候选人业务数据。
/// </summary>
private async Task<OnboardingTaskCreatedOutput> CreateCopiedTask(
ObOnboardingTask sourceTask,
List<ObTaskContract> sourceContracts,
List<ObTaskMaterialRequirement> sourceMaterialRequirements,
List<ObTaskFormRequirement> sourceEmployeeFieldRequirements,
List<OnboardingInitialContractVariableInput> initialContractVariables,
int expiryHours,
string remark)
{
var now = DateTime.Now;
var taskId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
var expiresAt = now.AddHours(expiryHours);
var token = Guid.NewGuid().ToString("N");
var task = new ObOnboardingTask
{
Id = taskId,
TaskNo = $"OB{now:yyyyMMddHHmmss}{taskId.ToString()[^4..]}",
CompanyId = sourceTask.CompanyId,
DepartmentId = sourceTask.DepartmentId,
PositionId = sourceTask.PositionId,
EmploymentType = string.IsNullOrWhiteSpace(sourceTask.EmploymentType)
? EmployeeEmploymentType.FullTime
: sourceTask.EmploymentType,
OnboardingTemplateId = sourceTask.OnboardingTemplateId,
AssignedReviewerUserId = sourceTask.AssignedReviewerUserId,
LinkExpireAt = expiresAt,
TaskStatus = OnboardingTaskStatus.PendingFill,
Remark = remark
};
await _db.Insertable(task).ExecuteCommandAsync();
await _db.Insertable(new ObTaskLink
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
PlainToken = token,
TokenHash = Convert.ToHexString(global::System.Security.Cryptography.SHA256.HashData(
global::System.Text.Encoding.UTF8.GetBytes(token))),
ExpiresAt = expiresAt
}).ExecuteCommandAsync();
await _db.Insertable(new ObTaskEmployeeInfo
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
Status = CommonStatusConst.ENABLE,
CreateTime = now
}).ExecuteCommandAsync();
if (sourceEmployeeFieldRequirements.Count > 0)
{
var copiedEmployeeFieldRequirements = sourceEmployeeFieldRequirements.Select(source =>
new ObTaskFormRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
FieldId = source.FieldId,
FieldCode = source.FieldCode,
FieldName = source.FieldName,
DataType = source.DataType,
OptionsJson = source.OptionsJson,
Required = source.Required,
SortCode = source.SortCode
}).ToList();
await _db.Insertable(copiedEmployeeFieldRequirements).ExecuteCommandAsync();
}
var copiedContracts = sourceContracts.Select(source => new ObTaskContract
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
TemplateId = source.TemplateId,
TemplateVersionId = source.TemplateVersionId,
ContractName = source.ContractName,
ContractType = source.ContractType,
Required = source.Required,
CompanySealEnabled = source.CompanySealEnabled,
AcrossPageSealEnabled = source.AcrossPageSealEnabled,
PrintCopies = source.PrintCopies
}).ToList();
await _db.Insertable(copiedContracts).ExecuteCommandAsync();
if (sourceMaterialRequirements.Count > 0)
{
var copiedRequirements = sourceMaterialRequirements.Select(source =>
new ObTaskMaterialRequirement
{
Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(),
TaskId = taskId,
MaterialTypeId = source.MaterialTypeId,
MaterialCode = source.MaterialCode,
MaterialName = source.MaterialName,
AllowedExtensions = source.AllowedExtensions,
MaxFileCount = source.MaxFileCount,
MaxFileSizeMb = source.MaxFileSizeMb,
Required = source.Required,
SortCode = source.SortCode
}).ToList();
await _db.Insertable(copiedRequirements).ExecuteCommandAsync();
}
// 重新生成公司、系统和空员工变量,再覆盖原任务的人事参数。
await _snapshotService.Build(taskId);
await ApplyInitialContractVariables(initialContractVariables, copiedContracts);
return new OnboardingTaskCreatedOutput
{
TaskId = taskId,
TaskNo = task.TaskNo,
LinkToken = token,
ExpiresAt = expiresAt
};
}
/// <summary> /// <summary>
/// 校验并保存任务创建时填写的人事合同变量。 /// 校验并保存任务创建时填写的人事合同变量。
/// </summary> /// </summary>
@@ -236,6 +539,12 @@ public class OnboardingTaskService : IOnboardingTaskService
if (inputs.Count == 0) if (inputs.Count == 0)
return; 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 contractByVersion = contracts.ToDictionary(item => item.TemplateVersionId);
var contractIds = contracts.Select(item => item.Id).ToList(); var contractIds = contracts.Select(item => item.Id).ToList();
var rows = await _db.Queryable<ObTaskContractVariable>() var rows = await _db.Queryable<ObTaskContractVariable>()
@@ -261,7 +570,7 @@ public class OnboardingTaskService : IOnboardingTaskService
/// <summary> /// <summary>
/// 校验人事创建任务时填写的合同日期先后关系。 /// 校验人事创建任务时填写的合同日期先后关系。
/// </summary> /// </summary>
private static void ValidateInitialContractDateRanges( internal static void ValidateInitialContractDateRanges(
List<OnboardingInitialContractVariableInput> inputs) List<OnboardingInitialContractVariableInput> inputs)
{ {
inputs ??= new List<OnboardingInitialContractVariableInput>(); inputs ??= new List<OnboardingInitialContractVariableInput>();
@@ -295,4 +604,13 @@ public class OnboardingTaskService : IOnboardingTaskService
if (endDate.Date < startDate.Date) if (endDate.Date < startDate.Date)
throw Oops.Bah($"{rangeName}结束日期不能早于开始日期"); throw Oops.Bah($"{rangeName}结束日期不能早于开始日期");
} }
private static void ValidateEmploymentType(string employmentType)
{
if (employmentType != EmployeeEmploymentType.FullTime
&& employmentType != EmployeeEmploymentType.PartTime)
{
throw Oops.Bah("用工类型不正确,请选择正式员工或兼职员工");
}
}
} }
@@ -103,6 +103,11 @@ public class OnboardingTaskContractVariableItemOutput
/// </summary> /// </summary>
public string DataType { get; set; } public string DataType { get; set; }
/// <summary>
/// 任务创建时保存的变量展示顺序。
/// </summary>
public int SortCode { get; set; }
/// <summary> /// <summary>
/// 任务合同变量最终值。 /// 任务合同变量最终值。
/// </summary> /// </summary>
@@ -6,9 +6,13 @@ namespace SimpleAdmin.Application;
public class OnboardingTaskContractVariableService : IOnboardingTaskContractVariableService public class OnboardingTaskContractVariableService : IOnboardingTaskContractVariableService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskContractVariableService() public OnboardingTaskContractVariableService(IOnboardingDataPermissionService dataPermission)
=> _db = DbContext.DB; {
_db = DbContext.DB;
_dataPermission = dataPermission;
}
/// <summary> /// <summary>
/// 查询任务下全部合同及其变量当前值。 /// 查询任务下全部合同及其变量当前值。
@@ -43,7 +47,8 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
Variables = variables Variables = variables
.Where(variable => variable.TaskContractId == contract.Id) .Where(variable => variable.TaskContractId == contract.Id)
.OrderBy(variable => variable.VariableCode) .OrderBy(variable => variable.SortCode)
.ThenBy(variable => variable.VariableCode)
.Select(variable => .Select(variable =>
{ {
var definition = definitions.FirstOrDefault(x => var definition = definitions.FirstOrDefault(x =>
@@ -59,6 +64,7 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
VariableName = variable.VariableName, VariableName = variable.VariableName,
VariableType = variable.VariableType, VariableType = variable.VariableType,
DataType = variable.DataType, DataType = variable.DataType,
SortCode = variable.SortCode,
VariableValue = variable.VariableValue, VariableValue = variable.VariableValue,
Required = definition?.Required == true, Required = definition?.Required == true,
HrEditable = hrEditable, HrEditable = hrEditable,
@@ -75,6 +81,11 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
/// 保存允许人事编辑的任务合同变量值。 /// 保存允许人事编辑的任务合同变量值。
/// </summary> /// </summary>
public async Task Save(OnboardingTaskContractVariableSaveInput input) 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>() var contract = await _db.Queryable<ObTaskContract>()
.InSingleAsync(input.TaskContractId) .InSingleAsync(input.TaskContractId)
@@ -83,6 +94,10 @@ public class OnboardingTaskContractVariableService : IOnboardingTaskContractVari
var task = await _db.Queryable<ObOnboardingTask>() var task = await _db.Queryable<ObOnboardingTask>()
.InSingleAsync(contract.TaskId) .InSingleAsync(contract.TaskId)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
if (task.TaskStatus != OnboardingTaskStatus.PendingFill && if (task.TaskStatus != OnboardingTaskStatus.PendingFill &&
task.TaskStatus != OnboardingTaskStatus.Rejected) task.TaskStatus != OnboardingTaskStatus.Rejected)
@@ -19,6 +19,69 @@ public class OnboardingTaskPageInput : BasePageInput
/// 入职业务公司 Id。 /// 入职业务公司 Id。
/// </summary> /// </summary>
public long? CompanyId { get; set; } public long? CompanyId { get; set; }
/// <summary>
/// 入职业务部门 Id。
/// </summary>
public long? DepartmentId { get; set; }
/// <summary>
/// 入职业务岗位 Id。
/// </summary>
public long? PositionId { get; set; }
}
/// <summary>
/// 入职任务分页数据及当前查询条件下的状态统计。
/// </summary>
public class OnboardingTaskPageOutput : SqlSugarPagedList<ObOnboardingTask>
{
/// <summary>
/// 不受状态筛选影响的任务状态统计。
/// </summary>
public OnboardingTaskStatisticsOutput Statistics { get; set; } = new();
}
/// <summary>
/// 入职任务状态统计。
/// </summary>
public class OnboardingTaskStatisticsOutput
{
/// <summary>
/// 待候选人填写数量。
/// </summary>
public int PendingFill { get; set; }
/// <summary>
/// 待人事审核数量。
/// </summary>
public int Reviewing { get; set; }
/// <summary>
/// 已退回待修改数量。
/// </summary>
public int Rejected { get; set; }
/// <summary>
/// 审核通过或已归档数量。
/// </summary>
public int Approved { get; set; }
}
/// <summary>
/// 数据库按任务状态分组后的内部统计行。
/// </summary>
public class OnboardingTaskStatusCountOutput
{
/// <summary>
/// 任务状态。
/// </summary>
public string TaskStatus { get; set; }
/// <summary>
/// 该状态的任务数量。
/// </summary>
public int Count { get; set; }
} }
/// <summary> /// <summary>
@@ -71,4 +134,9 @@ public class OnboardingTaskMaterialDetailOutput : ObTaskMaterial
/// 材料文件后缀,用于判断是否支持图片预览。 /// 材料文件后缀,用于判断是否支持图片预览。
/// </summary> /// </summary>
public string FileSuffix { get; set; } public string FileSuffix { get; set; }
/// <summary>
/// 格式化后的文件大小,例如 1.5MB。
/// </summary>
public string FileSizeInfo { get; set; }
} }
@@ -8,7 +8,7 @@ public interface IOnboardingTaskQueryService : ITransient
/// <summary> /// <summary>
/// 分页查询后台入职任务。 /// 分页查询后台入职任务。
/// </summary> /// </summary>
Task<SqlSugarPagedList<ObOnboardingTask>> Page(OnboardingTaskPageInput input); Task<OnboardingTaskPageOutput> Page(OnboardingTaskPageInput input);
/// <summary> /// <summary>
/// 查询任务基础信息、人员资料、合同、材料、表单值、链接和审核记录。 /// 查询任务基础信息、人员资料、合同、材料、表单值、链接和审核记录。
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 实现入职任务查询业务能力。 /// 实现入职任务查询业务能力。
@@ -7,31 +7,92 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private readonly IConfigService _configService; private readonly IConfigService _configService;
private readonly IOnboardingDataPermissionService _dataPermission;
public OnboardingTaskQueryService(IConfigService configService) public OnboardingTaskQueryService(
IConfigService configService,
IOnboardingDataPermissionService dataPermission)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_configService = configService; _configService = configService;
_dataPermission = dataPermission;
} }
/// <summary> /// <summary>
/// 分页查询后台入职任务。 /// 分页查询后台入职任务。
/// </summary> /// </summary>
public async Task<SqlSugarPagedList<ObOnboardingTask>> Page(OnboardingTaskPageInput input) public async Task<OnboardingTaskPageOutput> Page(OnboardingTaskPageInput input)
{ {
const string approvedGroupStatus = "approved_or_archived";
var filterApprovedGroup = string.Equals(
input.TaskStatus,
approvedGroupStatus,
StringComparison.OrdinalIgnoreCase);
var list = await _db.Queryable<ObOnboardingTask>() var list = await _db.Queryable<ObOnboardingTask>()
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo), item => item.TaskNo.Contains(input.TaskNo)) .WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo),
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskStatus), item => item.TaskStatus == input.TaskStatus) item => item.TaskNo.Contains(input.TaskNo))
.WhereIF(input.CompanyId.HasValue, item => item.CompanyId == input.CompanyId.Value) .WhereIF(!string.IsNullOrWhiteSpace(input.TaskStatus) && !filterApprovedGroup,
item => item.TaskStatus == input.TaskStatus)
.WhereIF(filterApprovedGroup,
item => item.TaskStatus == OnboardingTaskStatus.Approved
|| item.TaskStatus == OnboardingTaskStatus.Archived)
.WhereIF(input.CompanyId.HasValue,
item => item.CompanyId == input.CompanyId.Value)
.WhereIF(input.DepartmentId.HasValue,
item => item.DepartmentId == input.DepartmentId.Value)
.WhereIF(input.PositionId.HasValue,
item => item.PositionId == input.PositionId.Value)
.OrderByDescending(item => item.CreateTime) .OrderByDescending(item => item.CreateTime)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
if (list.List != null && list.List.Any()) if (list.List != null && list.List.Any())
{
await FillTaskMetadata(list.List); await FillTaskMetadata(list.List);
}
return list; // 统计继续应用组织、岗位和任务编号条件,但不受当前状态筛选影响。
var statusRows = await _db.Queryable<ObOnboardingTask>()
.WhereIF(!string.IsNullOrWhiteSpace(input.TaskNo),
item => item.TaskNo.Contains(input.TaskNo))
.WhereIF(input.CompanyId.HasValue,
item => item.CompanyId == input.CompanyId.Value)
.WhereIF(input.DepartmentId.HasValue,
item => item.DepartmentId == input.DepartmentId.Value)
.WhereIF(input.PositionId.HasValue,
item => item.PositionId == input.PositionId.Value)
.GroupBy(item => item.TaskStatus)
.Select(item => new OnboardingTaskStatusCountOutput
{
TaskStatus = item.TaskStatus,
Count = SqlFunc.AggregateCount(item.Id)
})
.ToListAsync();
var statusCountMap = statusRows.ToDictionary(
item => item.TaskStatus,
item => item.Count);
return new OnboardingTaskPageOutput
{
PageNum = list.PageNum,
PageSize = list.PageSize,
Total = list.Total,
Pages = list.Pages,
List = list.List,
HasPrevPages = list.HasPrevPages,
HasNextPages = list.HasNextPages,
Statistics = new OnboardingTaskStatisticsOutput
{
PendingFill = statusCountMap.GetValueOrDefault(
OnboardingTaskStatus.PendingFill),
Reviewing = statusCountMap.GetValueOrDefault(
OnboardingTaskStatus.Reviewing),
Rejected = statusCountMap.GetValueOrDefault(
OnboardingTaskStatus.Rejected),
Approved = statusCountMap.GetValueOrDefault(
OnboardingTaskStatus.Approved)
+ statusCountMap.GetValueOrDefault(
OnboardingTaskStatus.Archived)
}
};
} }
/// <summary> /// <summary>
@@ -67,6 +128,7 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
{ {
output.FileName = file.Name; output.FileName = file.Name;
output.FileSuffix = file.Suffix; output.FileSuffix = file.Suffix;
output.FileSizeInfo = file.SizeInfo;
} }
return output; return output;
@@ -95,9 +157,18 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
/// 获取或重新生成任务有效填写链接。 /// 获取或重新生成任务有效填写链接。
/// </summary> /// </summary>
public async Task<OnboardingTaskCreatedOutput> GetTaskLink(BaseIdInput input) public async Task<OnboardingTaskCreatedOutput> GetTaskLink(BaseIdInput input)
{
return await OnboardingConcurrency.RunTask(_db, input.Id, () => GetTaskLinkCore(input));
}
private async Task<OnboardingTaskCreatedOutput> GetTaskLinkCore(BaseIdInput input)
{ {
var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(input.Id) var task = await _db.Queryable<ObOnboardingTask>().InSingleAsync(input.Id)
?? throw Oops.Bah("入职任务不存在"); ?? throw Oops.Bah("入职任务不存在");
await _dataPermission.EnsureCanManage(
task.CreateUserId,
OnboardingPermissionCodes.TaskManageAll,
"入职任务");
var canShareLink = task.TaskStatus == OnboardingTaskStatus.PendingFill var canShareLink = task.TaskStatus == OnboardingTaskStatus.PendingFill
|| task.TaskStatus == OnboardingTaskStatus.Rejected; || task.TaskStatus == OnboardingTaskStatus.Rejected;
if (!canShareLink) if (!canShareLink)
@@ -115,6 +186,16 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
if (activeLink != null) if (activeLink != null)
{ {
// 修复历史数据或旧页面造成的冗余到期时间不一致。
var activeExpiresAt = activeLink.ExpiresAt;
if (task.LinkExpireAt != activeExpiresAt)
{
await _db.Updateable<ObOnboardingTask>()
.SetColumns(item => item.LinkExpireAt == activeExpiresAt)
.Where(item => item.Id == task.Id)
.ExecuteCommandAsync();
}
return new OnboardingTaskCreatedOutput return new OnboardingTaskCreatedOutput
{ {
TaskId = task.Id, TaskId = task.Id,
@@ -191,20 +272,26 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService
{ {
var taskList = tasks.ToList(); var taskList = tasks.ToList();
var companyIds = taskList.Select(t => t.CompanyId).Distinct().ToList(); var companyIds = taskList.Select(t => t.CompanyId).Distinct().ToList();
var departmentIds = taskList.Where(t => t.DepartmentId.HasValue).Select(t => t.DepartmentId.Value).Distinct().ToList();
var positionIds = taskList.Select(t => t.PositionId).Distinct().ToList(); var positionIds = taskList.Select(t => t.PositionId).Distinct().ToList();
var templateIds = taskList.Select(t => t.OnboardingTemplateId).Distinct().ToList(); var templateIds = taskList.Select(t => t.OnboardingTemplateId).Distinct().ToList();
var companies = await _db.Queryable<ObCompany>().Where(c => companyIds.Contains(c.Id)).ToListAsync(); var companies = await _db.Queryable<ObCompany>().Where(c => companyIds.Contains(c.Id)).ToListAsync();
var departments = departmentIds.Count == 0
? new List<ObDepartment>()
: await _db.Queryable<ObDepartment>().Where(d => departmentIds.Contains(d.Id)).ToListAsync();
var positions = await _db.Queryable<ObPosition>().Where(p => positionIds.Contains(p.Id)).ToListAsync(); var positions = await _db.Queryable<ObPosition>().Where(p => positionIds.Contains(p.Id)).ToListAsync();
var templates = await _db.Queryable<ObOnboardingTemplate>().Where(t => templateIds.Contains(t.Id)).ToListAsync(); var templates = await _db.Queryable<ObOnboardingTemplate>().Where(t => templateIds.Contains(t.Id)).ToListAsync();
var companyMap = companies.ToDictionary(c => c.Id, c => c.CompanyName); var companyMap = companies.ToDictionary(c => c.Id, c => c.CompanyName);
var departmentMap = departments.ToDictionary(d => d.Id, d => d.DepartmentName);
var positionMap = positions.ToDictionary(p => p.Id, p => p.PositionName); var positionMap = positions.ToDictionary(p => p.Id, p => p.PositionName);
var templateMap = templates.ToDictionary(t => t.Id, t => t.TemplateName); var templateMap = templates.ToDictionary(t => t.Id, t => t.TemplateName);
foreach (var task in taskList) foreach (var task in taskList)
{ {
if (companyMap.TryGetValue(task.CompanyId, out var cName)) task.CompanyName = cName; if (companyMap.TryGetValue(task.CompanyId, out var cName)) task.CompanyName = cName;
if (task.DepartmentId.HasValue && departmentMap.TryGetValue(task.DepartmentId.Value, out var dName)) task.DepartmentName = dName;
if (positionMap.TryGetValue(task.PositionId, out var pName)) task.PositionName = pName; if (positionMap.TryGetValue(task.PositionId, out var pName)) task.PositionName = pName;
if (templateMap.TryGetValue(task.OnboardingTemplateId, out var tName)) task.OnboardingTemplateName = tName; if (templateMap.TryGetValue(task.OnboardingTemplateId, out var tName)) task.OnboardingTemplateName = tName;
} }
@@ -1,10 +1,32 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary>
/// 合同变量字典服务。
/// </summary>
public interface IOnboardingVariableDefinitionService : ITransient public interface IOnboardingVariableDefinitionService : ITransient
{ {
/// <summary>
/// 分页查询合同变量定义。
/// </summary>
Task<SqlSugarPagedList<ObVariableDefinition>> Page(OnboardingVariableDefinitionPageInput input); Task<SqlSugarPagedList<ObVariableDefinition>> Page(OnboardingVariableDefinitionPageInput input);
/// <summary>
/// 查询全部合同变量定义。
/// </summary>
Task<List<ObVariableDefinition>> ListAll(); Task<List<ObVariableDefinition>> ListAll();
/// <summary>
/// 新增合同变量定义。
/// </summary>
Task Add(OnboardingVariableDefinitionAddInput input); Task Add(OnboardingVariableDefinitionAddInput input);
/// <summary>
/// 编辑合同变量定义。
/// </summary>
Task Edit(OnboardingVariableDefinitionEditInput input); Task Edit(OnboardingVariableDefinitionEditInput input);
/// <summary>
/// 删除非内置合同变量定义。
/// </summary>
Task Delete(BaseIdListInput input); Task Delete(BaseIdListInput input);
} }
@@ -2,32 +2,79 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary>
/// 合同变量字典分页查询参数。
/// </summary>
public class OnboardingVariableDefinitionPageInput : BasePageInput public class OnboardingVariableDefinitionPageInput : BasePageInput
{ {
/// <summary>
/// 变量编码或名称关键字。
/// </summary>
public string Keyword { get; set; } public string Keyword { get; set; }
/// <summary>
/// 变量分类:employee、hr、company 或 system。
/// </summary>
public string VariableCategory { get; set; } public string VariableCategory { get; set; }
} }
/// <summary>
/// 新增合同变量定义参数。
/// </summary>
public class OnboardingVariableDefinitionAddInput public class OnboardingVariableDefinitionAddInput
{ {
/// <summary>
/// 完整变量编码,例如 employee.EmployeeName 或 hr.BaseSalary。
/// </summary>
[Required(ErrorMessage = "变量编码不能为空")] [Required(ErrorMessage = "变量编码不能为空")]
public string VariableCode { get; set; } public string VariableCode { get; set; }
/// <summary>
/// 后台展示的变量名称。
/// </summary>
[Required(ErrorMessage = "变量名称不能为空")] [Required(ErrorMessage = "变量名称不能为空")]
public string VariableName { get; set; } public string VariableName { get; set; }
/// <summary>
/// 变量分类:employee、hr、company 或 system。
/// </summary>
[Required(ErrorMessage = "变量分类不能为空")] [Required(ErrorMessage = "变量分类不能为空")]
public string VariableCategory { get; set; } public string VariableCategory { get; set; }
/// <summary>
/// 数据类型:string、number 或 date。
/// </summary>
public string DataType { get; set; } = "string"; public string DataType { get; set; } = "string";
/// <summary>
/// 未提供变量值时使用的默认值。
/// </summary>
public string DefaultValue { get; set; } public string DefaultValue { get; set; }
/// <summary>
/// 是否必须填写。
/// </summary>
public bool Required { get; set; } public bool Required { get; set; }
/// <summary>
/// 展示排序值。
/// </summary>
public int SortCode { get; set; } = 100; public int SortCode { get; set; } = 100;
/// <summary>
/// 变量用途说明。
/// </summary>
public string Remark { get; set; } public string Remark { get; set; }
} }
/// <summary>
/// 编辑合同变量定义参数。
/// </summary>
public class OnboardingVariableDefinitionEditInput : OnboardingVariableDefinitionAddInput public class OnboardingVariableDefinitionEditInput : OnboardingVariableDefinitionAddInput
{ {
/// <summary>
/// 变量定义主键。
/// </summary>
[IdNotNull(ErrorMessage = "Id不能为空")] [IdNotNull(ErrorMessage = "Id不能为空")]
public long Id { get; set; } public long Id { get; set; }
} }
@@ -1,26 +1,39 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary>
/// 实现合同变量字典的查询和维护。
/// </summary>
public class OnboardingVariableDefinitionService : IOnboardingVariableDefinitionService public class OnboardingVariableDefinitionService : IOnboardingVariableDefinitionService
{ {
private readonly SqlSugarScope _db; private readonly SqlSugarScope _db;
private static bool _initialized = false; private readonly IOnboardingDataPermissionService _dataPermission;
private static readonly object _lock = new();
public OnboardingVariableDefinitionService() /// <summary>
/// 初始化合同变量字典服务。
/// </summary>
public OnboardingVariableDefinitionService(IOnboardingDataPermissionService dataPermission)
{ {
_db = DbContext.DB; _db = DbContext.DB;
_dataPermission = dataPermission;
} }
/// <inheritdoc />
public async Task<SqlSugarPagedList<ObVariableDefinition>> Page(OnboardingVariableDefinitionPageInput input) public async Task<SqlSugarPagedList<ObVariableDefinition>> Page(OnboardingVariableDefinitionPageInput input)
{ {
return await _db.Queryable<ObVariableDefinition>() return await _db.Queryable<ObVariableDefinition>()
.WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), it => it.VariableCode.Contains(input.Keyword) || it.VariableName.Contains(input.Keyword)) .WhereIF(
.WhereIF(!string.IsNullOrWhiteSpace(input.VariableCategory), it => it.VariableCategory == input.VariableCategory) !string.IsNullOrWhiteSpace(input.Keyword),
item => item.VariableCode.Contains(input.Keyword)
|| item.VariableName.Contains(input.Keyword))
.WhereIF(
!string.IsNullOrWhiteSpace(input.VariableCategory),
item => item.VariableCategory == input.VariableCategory)
.OrderBy(it => it.SortCode) .OrderBy(it => it.SortCode)
.OrderBy(it => it.Id) .OrderBy(it => it.Id)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
} }
/// <inheritdoc />
public async Task<List<ObVariableDefinition>> ListAll() public async Task<List<ObVariableDefinition>> ListAll()
{ {
return await _db.Queryable<ObVariableDefinition>() return await _db.Queryable<ObVariableDefinition>()
@@ -29,26 +42,38 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition
.ToListAsync(); .ToListAsync();
} }
/// <inheritdoc />
public async Task Add(OnboardingVariableDefinitionAddInput input) public async Task Add(OnboardingVariableDefinitionAddInput input)
{ {
var code = input.VariableCode.Trim(); NormalizeAndValidate(input);
var exists = await _db.Queryable<ObVariableDefinition>().AnyAsync(it => it.VariableCode == code);
var exists = await _db.Queryable<ObVariableDefinition>()
.AnyAsync(item => item.VariableCode == input.VariableCode);
if (exists) if (exists)
throw Oops.Bah($"变量编码“{code}”已存在"); throw Oops.Bah($"变量编码“{input.VariableCode}”已存在");
var entity = input.Adapt<ObVariableDefinition>(); var entity = input.Adapt<ObVariableDefinition>();
entity.VariableCode = code;
entity.IsBuiltIn = false; entity.IsBuiltIn = false;
await _db.Insertable(entity).ExecuteCommandAsync(); await _db.Insertable(entity).ExecuteCommandAsync();
} }
/// <inheritdoc />
public async Task Edit(OnboardingVariableDefinitionEditInput input) public async Task Edit(OnboardingVariableDefinitionEditInput input)
{ {
NormalizeAndValidate(input);
var entity = await _db.Queryable<ObVariableDefinition>().InSingleAsync(input.Id) var entity = await _db.Queryable<ObVariableDefinition>().InSingleAsync(input.Id)
?? throw Oops.Bah("变量不存在"); ?? throw Oops.Bah("变量不存在");
await _dataPermission.EnsureCanManage(
entity.CreateUserId,
OnboardingPermissionCodes.EntryConfigManageAll,
"合同变量");
if (!string.Equals(entity.VariableCode, input.VariableCode, StringComparison.Ordinal))
throw Oops.Bah("变量编码创建后不能修改");
entity.VariableName = input.VariableName; entity.VariableName = input.VariableName;
entity.DataType = input.DataType ?? "string"; entity.DataType = input.DataType;
entity.DefaultValue = input.DefaultValue; entity.DefaultValue = input.DefaultValue;
entity.Required = input.Required; entity.Required = input.Required;
entity.SortCode = input.SortCode; entity.SortCode = input.SortCode;
@@ -61,9 +86,21 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition
await _db.Updateable(entity).ExecuteCommandAsync(); await _db.Updateable(entity).ExecuteCommandAsync();
} }
/// <inheritdoc />
public async Task Delete(BaseIdListInput input) public async Task Delete(BaseIdListInput input)
{ {
if (input.Ids.Count == 0) return; if (input.Ids.Count == 0)
return;
var variables = await _db.Queryable<ObVariableDefinition>()
.Where(item => input.Ids.Contains(item.Id))
.ToListAsync();
if (variables.Count != input.Ids.Distinct().Count())
throw Oops.Bah("变量不存在");
await _dataPermission.EnsureCanManage(
variables.Select(item => item.CreateUserId),
OnboardingPermissionCodes.EntryConfigManageAll,
"合同变量");
var hasBuiltIn = await _db.Queryable<ObVariableDefinition>() var hasBuiltIn = await _db.Queryable<ObVariableDefinition>()
.Where(it => input.Ids.Contains(it.Id) && it.IsBuiltIn) .Where(it => input.Ids.Contains(it.Id) && it.IsBuiltIn)
@@ -75,4 +112,40 @@ public class OnboardingVariableDefinitionService : IOnboardingVariableDefinition
.Where(it => input.Ids.Contains(it.Id)) .Where(it => input.Ids.Contains(it.Id))
.ExecuteCommandAsync(); .ExecuteCommandAsync();
} }
/// <summary>
/// 规范化并校验合同变量定义,防止无效编码在生成合同时才暴露。
/// </summary>
private static void NormalizeAndValidate(OnboardingVariableDefinitionAddInput input)
{
input.VariableCode = input.VariableCode?.Trim();
input.VariableName = input.VariableName?.Trim();
input.VariableCategory = input.VariableCategory?.Trim().ToLowerInvariant();
input.DataType = string.IsNullOrWhiteSpace(input.DataType)
? "string"
: input.DataType.Trim().ToLowerInvariant();
input.DefaultValue = input.DefaultValue?.Trim();
input.Remark = input.Remark?.Trim();
if (string.IsNullOrWhiteSpace(input.VariableCode))
throw Oops.Bah("变量编码不能为空");
if (string.IsNullOrWhiteSpace(input.VariableName))
throw Oops.Bah("变量名称不能为空");
var validCategories = new[] { "employee", "hr", "company", "system" };
if (!validCategories.Contains(input.VariableCategory))
throw Oops.Bah("变量分类只能是员工填写、人事填写、公司信息或系统生成");
var codePattern = $@"^{input.VariableCategory}\.[A-Za-z][A-Za-z0-9_]*$";
var codeIsValid = global::System.Text.RegularExpressions.Regex.IsMatch(
input.VariableCode,
codePattern);
if (!codeIsValid)
throw Oops.Bah($"变量编码必须以“{input.VariableCategory}.”开头,后半段只能包含字母、数字和下划线");
var validDataTypes = new[] { "string", "number", "date" };
if (!validDataTypes.Contains(input.DataType))
throw Oops.Bah("数据类型只能是文本、数字或日期");
}
} }
+32 -21
View File
@@ -1,4 +1,4 @@
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛 // Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
// //
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款: // SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。 // 1.请不要删除和修改根目录下的LICENSE文件。
@@ -45,26 +45,37 @@ public class BasePageInput : IValidatableObject
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{ {
//配合小诺排序参数 SortOrder = (string.IsNullOrWhiteSpace(SortOrder) ? "desc" : SortOrder.Trim().ToLowerInvariant()) switch
if (SortOrder == "descend")
{ {
SortOrder = "desc"; "ascend" => "asc",
} "descend" => "desc",
else if (SortOrder == "ascend") var value => value
{ };
SortOrder = "asc"; if (SortOrder != "asc" && SortOrder != "desc")
} yield return new ValidationResult("排序方向仅支持 asc/desc", new[] { nameof(SortOrder) });
if (!string.IsNullOrEmpty(SortField))
{ if (!string.IsNullOrEmpty(SortField)
//分割排序字段 && !global::System.Text.RegularExpressions.Regex.IsMatch(
var fields = SortField.Split(" "); SortField, @"\A[A-Za-z_][A-Za-z0-9_]{0,63}\z"))
if (fields.Length > 1) yield return new ValidationResult("排序字段格式不正确", new[] { nameof(SortField) });
{
yield return new ValidationResult("排序字段错误", new[]
{
nameof(SortField)
});
}
}
} }
/// <summary>
/// 将客户端字段映射为服务端白名单列,禁止把排序表达式传入 ORM。
/// 服务层再次校验,避免非 HTTP 调用绕过 MVC 参数校验。
/// </summary>
public string GetSafeOrderBy(params string[] allowedFields)
{
var error = Validate(new ValidationContext(this)).FirstOrDefault();
if (error != null)
throw Oops.Bah(error.ErrorMessage);
if (string.IsNullOrEmpty(SortField))
return string.Empty;
var field = allowedFields.FirstOrDefault(item =>
string.Equals(item, SortField, StringComparison.OrdinalIgnoreCase));
if (field == null)
throw Oops.Bah("不支持按此字段排序");
return $"{field} {SortOrder}";
}
} }
@@ -5,7 +5,7 @@
"ConnectionStrings": [ "ConnectionStrings": [
{ {
"ConfigId": "SimpleAdmin", //租户ID "ConfigId": "SimpleAdmin", //租户ID
"ConnectionString": "AllowLoadLocalInfile=true;Server=116.168.197.2;User ID=admin;Password=so^867z$4S;Port=16306;Database=OnboardX;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串 "ConnectionString": "AllowLoadLocalInfile=true;Server=onboardx-mysql.cnzhyx.cn;User ID=onboardx;Password=@gFYj78eNaTm3BS!JU)aUQ;Port=3306;Database=onboardx;CharSet=utf8mb4;pooling=true;SslMode=None;min pool size=1;Connection Timeout=30;Pooling=false", //连接字符串
"DbType": "MySql", //数据库类型 "DbType": "MySql", //数据库类型
"IsAutoCloseConnection": true //是否自动释放 "IsAutoCloseConnection": true //是否自动释放
} }
@@ -66,6 +66,11 @@ public class LoginOutPut
/// </summary> /// </summary>
public class LoginUserOutput public class LoginUserOutput
{ {
/// <summary>
/// 用户Id
/// </summary>
public long Id { get; set; }
/// <summary> /// <summary>
/// 头像 /// 头像
///</summary> ///</summary>
@@ -17,11 +17,16 @@ namespace SimpleAdmin.System;
/// </summary> /// </summary>
public class FileService : DbRepository<SysFile>, IFileService public class FileService : DbRepository<SysFile>, IFileService
{ {
private static readonly HttpClient OssPublicHttpClient = new();
private readonly IConfigService _configService; private readonly IConfigService _configService;
private readonly IAliyunOssClientProvider _ossClientProvider;
public FileService(IConfigService configService) public FileService(
IConfigService configService,
IAliyunOssClientProvider ossClientProvider)
{ {
_configService = configService; _configService = configService;
_ossClientProvider = ossClientProvider;
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -29,7 +34,7 @@ public class FileService : DbRepository<SysFile>, IFileService
{ {
var query = Context.Queryable<SysFile>().WhereIF(!string.IsNullOrEmpty(input.Engine), it => it.Engine == input.Engine)//根据关键字查询 var query = Context.Queryable<SysFile>().WhereIF(!string.IsNullOrEmpty(input.Engine), it => it.Engine == input.Engine)//根据关键字查询
.WhereIF(!string.IsNullOrEmpty(input.SearchKey), it => it.Name.Contains(input.SearchKey))//根据关键字查询 .WhereIF(!string.IsNullOrEmpty(input.SearchKey), it => it.Name.Contains(input.SearchKey))//根据关键字查询
.OrderByIF(!string.IsNullOrEmpty(input.SortField), $"{input.SortField} {input.SortOrder}")//排序 .OrderByIF(!string.IsNullOrEmpty(input.SortField), input.GetSafeOrderBy("Id", "Name", "Engine", "Bucket", "Suffix", "SizeKb", "SizeInfo", "CreateTime", "UpdateTime", "CreateUser"))//排序
.OrderBy(it => it.Id); .OrderBy(it => it.Id);
var pageInfo = await query.ToPagedListAsync(input.PageNum, input.PageSize);//分页 var pageInfo = await query.ToPagedListAsync(input.PageNum, input.PageSize);//分页
return pageInfo; return pageInfo;
@@ -110,10 +115,26 @@ public class FileService : DbRepository<SysFile>, IFileService
IFormFile file = new FormFile(stream, 0, content.Length, "file", fileName) IFormFile file = new FormFile(stream, 0, content.Length, "file", fileName)
{ {
Headers = new HeaderDictionary(), Headers = new HeaderDictionary(),
ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ContentType = ResolveGeneratedContentType(fileName)
}; };
return await StorageFile(engine, file); return await StorageFile(engine, file);
} }
/// <summary>
/// 根据归档文件扩展名设置 OSS 元数据,避免 PDF、ZIP 被错误标记为 DOCX。
/// </summary>
private static string ResolveGeneratedContentType(string fileName)
{
return Path.GetExtension(fileName).ToLowerInvariant() switch
{
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".pdf" => "application/pdf",
".zip" => "application/zip",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
_ => "application/octet-stream"
};
}
#region 方法 #region 方法
/// <summary> /// <summary>
@@ -258,7 +279,7 @@ public class FileService : DbRepository<SysFile>, IFileService
metadata.ContentType = file.ContentType; metadata.ContentType = file.ContentType;
await using var stream = file.OpenReadStream(); await using var stream = file.OpenReadStream();
var client = CreateOssClient(setting); var client = await _ossClientProvider.GetClientAsync();
client.PutObject(setting.Bucket, objectName, stream, metadata); client.PutObject(setting.Bucket, objectName, stream, metadata);
var downloadUrl = string.IsNullOrWhiteSpace(setting.OSSUrl) var downloadUrl = string.IsNullOrWhiteSpace(setting.OSSUrl)
@@ -274,22 +295,63 @@ public class FileService : DbRepository<SysFile>, IFileService
private async Task<FileStreamResult> GetFileStreamResultFromAliyunOss(SysFile file) private async Task<FileStreamResult> GetFileStreamResultFromAliyunOss(SysFile file)
{ {
var setting = GetOssSetting(); var setting = GetOssSetting();
var client = CreateOssClient(setting); try
var ossObject = client.GetObject(file.Bucket, file.ObjName);
var memory = new MemoryStream();
await using (ossObject.Content)
{ {
await ossObject.Content.CopyToAsync(memory); var client = await _ossClientProvider.GetClientAsync();
var ossObject = client.GetObject(file.Bucket, file.ObjName);
await using (ossObject.Content)
{
return await BuildFileStreamResult(ossObject.Content, file.Name);
}
} }
catch
{
// STS/OSS SDK 暂时不可用时,只通过已公开的对象地址读取文件。
// 后台下载接口仍由系统鉴权保护,且该降级过程不会使用或暴露长期 AK。
var publicUrl = BuildOssPublicUrl(file, setting);
using var response = await OssPublicHttpClient.GetAsync(
publicUrl,
HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
await using var content = await response.Content.ReadAsStreamAsync();
return await BuildFileStreamResult(content, file.Name);
}
}
/// <summary>
/// 将远程文件流复制到独立内存流,避免远程响应释放后影响文件下载。
/// </summary>
private static async Task<FileStreamResult> BuildFileStreamResult(
Stream source,
string originalFileName)
{
var memory = new MemoryStream();
await source.CopyToAsync(memory);
memory.Position = 0; memory.Position = 0;
var fileName = HttpUtility.UrlEncode(file.Name, Encoding.GetEncoding("UTF-8")); var fileName = HttpUtility.UrlEncode(
originalFileName,
Encoding.GetEncoding("UTF-8"));
return new FileStreamResult(memory, "application/octet-stream") return new FileStreamResult(memory, "application/octet-stream")
{ {
FileDownloadName = fileName FileDownloadName = fileName
}; };
} }
/// <summary>
/// 根据受信任的 OSS 配置生成公开对象地址,不使用数据库中的任意外部地址。
/// </summary>
private static string BuildOssPublicUrl(SysFile file, OssSettingOptions setting)
{
var baseUrl = string.IsNullOrWhiteSpace(setting.OSSUrl)
? $"https://{file.Bucket}.{setting.EndPoint.Trim().TrimEnd('/')}"
: setting.OSSUrl.Trim().TrimEnd('/');
var objectPath = string.Join(
"/",
file.ObjName.Split('/', StringSplitOptions.RemoveEmptyEntries)
.Select(Uri.EscapeDataString));
return $"{baseUrl}/{objectPath}";
}
/// <summary> /// <summary>
/// 读取并校验阿里云 OSS 配置。 /// 读取并校验阿里云 OSS 配置。
/// </summary> /// </summary>
@@ -307,47 +369,6 @@ public class FileService : DbRepository<SysFile>, IFileService
return setting; return setting;
} }
/// <summary>
/// 通过 STS 临时凭证创建阿里云 OSS 客户端。
/// </summary>
private static OssClient CreateOssClient(OssSettingOptions setting)
{
var endpoint = setting.EndPoint.Trim();
if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
&& !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
endpoint = "https://" + endpoint;
}
if (string.IsNullOrWhiteSpace(setting.RoleArn))
return new OssClient(endpoint, setting.AccessKeyID, setting.AccessKeySecret);
var stsConfig = new AlibabaCloud.OpenApiClient.Models.Config
{
AccessKeyId = setting.AccessKeyID,
AccessKeySecret = setting.AccessKeySecret,
Endpoint = setting.StsEndpoint
};
var stsClient = new AlibabaCloud.SDK.Sts20150401.Client(stsConfig);
var request = new AlibabaCloud.SDK.Sts20150401.Models.AssumeRoleRequest
{
RoleArn = setting.RoleArn,
RoleSessionName = string.IsNullOrWhiteSpace(setting.RoleSessionName)
? "onboardx-server"
: setting.RoleSessionName,
DurationSeconds = 3600
};
var credentials = stsClient.AssumeRole(request).Body.Credentials;
if (credentials == null)
throw Oops.Bah("获取阿里云 OSS 临时凭证失败");
return new OssClient(
endpoint,
credentials.AccessKeyId,
credentials.AccessKeySecret,
credentials.SecurityToken);
}
/// <summary> /// <summary>
/// 获取文件大小 /// 获取文件大小
/// </summary> /// </summary>
@@ -0,0 +1,194 @@
using Aliyun.Credentials.Models;
using Aliyun.OSS;
using CredentialClient = Aliyun.Credentials.Client;
namespace SimpleAdmin.System;
/// <summary>
/// 在单机进程内缓存阿里云 STS 凭证及 OSS 客户端。
/// </summary>
public sealed class AliyunOssClientProvider : IAliyunOssClientProvider, IDisposable
{
/// <summary>
/// STS 临时凭证有效期,单位为秒。
/// </summary>
private const int RoleSessionExpirationSeconds = 3600;
/// <summary>
/// 单次刷新失败时的最大尝试次数。
/// </summary>
private const int MaxRefreshAttempts = 3;
/// <summary>
/// 在凭证到期前提前刷新,官方凭证客户端会继续维护其内部缓存。
/// </summary>
private static readonly TimeSpan RefreshAhead = TimeSpan.FromMinutes(2);
/// <summary>
/// 防止多个上传或下载请求同时刷新临时凭证。
/// </summary>
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private readonly ILogger<AliyunOssClientProvider> _logger;
private readonly OssSettingOptions _setting;
private readonly string _endpoint;
private readonly CredentialClient _credentialClient;
private OssClient _cachedClient;
private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
public AliyunOssClientProvider(ILogger<AliyunOssClientProvider> logger)
{
_logger = logger;
_setting = GetAndValidateSetting();
_endpoint = NormalizeEndpoint(_setting.EndPoint);
if (string.IsNullOrWhiteSpace(_setting.RoleArn))
{
_cachedClient = new OssClient(
_endpoint,
_setting.AccessKeyID,
_setting.AccessKeySecret);
_expiresAt = DateTimeOffset.MaxValue;
return;
}
var credentialConfig = new Config
{
Type = "ram_role_arn",
AccessKeyId = _setting.AccessKeyID,
AccessKeySecret = _setting.AccessKeySecret,
RoleArn = _setting.RoleArn,
RoleSessionName = string.IsNullOrWhiteSpace(_setting.RoleSessionName)
? "onboardx-server"
: _setting.RoleSessionName,
RoleSessionExpiration = RoleSessionExpirationSeconds,
STSEndpoint = _setting.StsEndpoint,
ConnectTimeout = 10000,
Timeout = 10000
};
_credentialClient = new CredentialClient(credentialConfig);
}
/// <inheritdoc />
public async Task<OssClient> GetClientAsync()
{
if (_credentialClient == null)
return _cachedClient;
var now = DateTimeOffset.UtcNow;
if (_cachedClient != null && now < _expiresAt - RefreshAhead)
return _cachedClient;
await _refreshLock.WaitAsync();
try
{
now = DateTimeOffset.UtcNow;
if (_cachedClient != null && now < _expiresAt - RefreshAhead)
return _cachedClient;
try
{
var credential = await GetCredentialWithRetryAsync();
var credentialIncomplete = credential == null
|| string.IsNullOrWhiteSpace(credential.AccessKeyId)
|| string.IsNullOrWhiteSpace(credential.AccessKeySecret)
|| string.IsNullOrWhiteSpace(credential.SecurityToken);
if (credentialIncomplete)
throw Oops.Bah("获取阿里云 OSS 临时凭证失败");
_cachedClient = new OssClient(
_endpoint,
credential.AccessKeyId,
credential.AccessKeySecret,
credential.SecurityToken);
_expiresAt = ResolveExpiration(credential.Expiration);
return _cachedClient;
}
catch (Exception exception) when (
_cachedClient != null && DateTimeOffset.UtcNow < _expiresAt)
{
// 刷新失败时继续使用尚未过期的旧凭证,避免短时网络波动中断业务。
_logger.LogWarning(
exception,
"刷新阿里云 OSS STS 凭证失败,将继续使用未过期的内存凭证,有效期至 {ExpiresAt}",
_expiresAt);
return _cachedClient;
}
}
finally
{
_refreshLock.Release();
}
}
/// <summary>
/// 获取凭证时执行有限重试,避免一次瞬时 TLS 抖动直接中断业务。
/// </summary>
private async Task<CredentialModel> GetCredentialWithRetryAsync()
{
for (var attempt = 1; ; attempt++)
{
try
{
return await _credentialClient.GetCredentialAsync();
}
catch (Exception exception) when (attempt < MaxRefreshAttempts)
{
_logger.LogWarning(
exception,
"第 {Attempt} 次获取阿里云 OSS STS 凭证失败,准备重试",
attempt);
await Task.Delay(TimeSpan.FromMilliseconds(200 * attempt));
}
}
}
/// <summary>
/// 将官方凭证返回的 Unix 时间戳转换为到期时间。
/// </summary>
private static DateTimeOffset ResolveExpiration(long expiration)
{
if (expiration <= 0)
return DateTimeOffset.UtcNow.AddMinutes(30);
return expiration > 253402300799
? DateTimeOffset.FromUnixTimeMilliseconds(expiration)
: DateTimeOffset.FromUnixTimeSeconds(expiration);
}
/// <summary>
/// 读取并校验创建客户端所需的配置。
/// </summary>
private static OssSettingOptions GetAndValidateSetting()
{
var setting = App.GetConfig<OssSettingOptions>("OssSetting", true);
var configurationIncomplete = setting == null
|| string.IsNullOrWhiteSpace(setting.AccessKeyID)
|| string.IsNullOrWhiteSpace(setting.AccessKeySecret)
|| string.IsNullOrWhiteSpace(setting.EndPoint);
if (configurationIncomplete)
throw Oops.Bah("阿里云 OSS 配置不完整");
return setting;
}
/// <summary>
/// 统一补充 OSS Endpoint 的 HTTPS 协议。
/// </summary>
private static string NormalizeEndpoint(string endpoint)
{
endpoint = endpoint.Trim();
if (!endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
&& !endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
endpoint = "https://" + endpoint;
}
return endpoint;
}
public void Dispose()
{
_refreshLock.Dispose();
}
}
@@ -0,0 +1,12 @@
namespace SimpleAdmin.System;
/// <summary>
/// 提供可复用的阿里云 OSS 客户端。
/// </summary>
public interface IAliyunOssClientProvider
{
/// <summary>
/// 获取使用当前有效凭证创建的 OSS 客户端。
/// </summary>
Task<Aliyun.OSS.OssClient> GetClientAsync();
}
@@ -25,6 +25,10 @@ public class ConfigService : DbRepository<SysConfig>, IConfigService
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<SysConfig>> GetConfigsByCategory(string category) public async Task<List<SysConfig>> GetConfigsByCategory(string category)
{ {
// 业务自定义配置需立即生效,始终以数据库中的最新数据为准。
if (category == CateGoryConst.CONFIG_BIZ_DEFINE)
return await GetListAsync(it => it.Category == category);
var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key
//先从redis拿配置 //先从redis拿配置
var configList = _simpleCacheService.HashGetAll<SysConfig>(key).Values.ToList(); var configList = _simpleCacheService.HashGetAll<SysConfig>(key).Values.ToList();
@@ -51,6 +55,10 @@ public class ConfigService : DbRepository<SysConfig>, IConfigService
/// <inheritdoc/> /// <inheritdoc/>
public async Task<SysConfig> GetByConfigKey(string category, string configKey) public async Task<SysConfig> GetByConfigKey(string category, string configKey)
{ {
// 业务自定义配置不走缓存,避免修改后仍读取到本机旧值。
if (category == CateGoryConst.CONFIG_BIZ_DEFINE)
return await GetFirstAsync(it => it.Category == category && it.ConfigKey == configKey);
var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key var key = SystemConst.CACHE_DEV_CONFIG + category;//系统配置key
//先从redis拿配置 //先从redis拿配置
var config = _simpleCacheService.HashGetOne<SysConfig>(key, configKey); var config = _simpleCacheService.HashGetOne<SysConfig>(key, configKey);
@@ -1,4 +1,4 @@
// Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛 // Copyright (c) 2022-Now 少林寺驻北固山办事处大神父王喇嘛
// //
// SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款: // SimpleAdmin 基于 Apache License Version 2.0 协议发布,可用于商业项目,但必须遵守以下补充条款:
// 1.请不要删除和修改根目录下的LICENSE文件。 // 1.请不要删除和修改根目录下的LICENSE文件。
@@ -183,15 +183,18 @@ public class ResourceService : DbRepository<SysResource>, IResourceService
foreach (var controller in controllerTypes) foreach (var controller in controllerTypes)
{ {
var apiAttribute = controller.GetCustomAttribute<ApiDescriptionSettingsAttribute>();//获取接口描述特性 var apiAttribute = controller.GetCustomAttribute<ApiDescriptionSettingsAttribute>();//获取接口描述特性
var tag = apiAttribute.Tag;//获取标签 var tag = apiAttribute?.Tag;//获取标签
//获取数据权限特性 //获取数据权限特性
var routeAttributes = controller.GetCustomAttributes<RouteAttribute>().ToList(); var routeAttributes = controller.GetCustomAttributes<RouteAttribute>().ToList();
if (routeAttributes == null) if (routeAttributes == null || routeAttributes.Count == 0)
continue; continue;
var route = routeAttributes[0];//取第一个值 var route = routeAttributes[0];//取第一个值作为生成接口路由的基础
var routeName = GetRouteName(controller.Name, route.Template);//赋值路由名称 var routeName = GetRouteName(controller.Name, route.Template);//赋值路由名称
//如果路由包含在路由列表中 //如果路由包含在路由列表中,或支持前缀/多路由匹配
if (routes.Contains(routeName)) var isMatched = routeAttributes.Any(ra => routes.Contains(GetRouteName(controller.Name, ra.Template)))
|| routes.Contains(routeName)
|| routes.Any(r => !string.IsNullOrEmpty(r) && routeName.StartsWith(r));
if (isMatched)
{ {
//获取所有方法 //获取所有方法
var methods = controller.GetMethods(); var methods = controller.GetMethods();
+1 -1
View File
@@ -2,7 +2,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AlibabaCloud.SDK.Sts20150401" Version="1.2.0"/> <PackageReference Include="Aliyun.Credentials" Version="1.5.3"/>
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.0"/> <PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.0"/>
<PackageReference Include="IP2Region.Net" Version="2.0.2"/> <PackageReference Include="IP2Region.Net" Version="2.0.2"/>
<PackageReference Include="UAParser" Version="3.1.47"/> <PackageReference Include="UAParser" Version="3.1.47"/>
+2
View File
@@ -29,6 +29,8 @@ public class Startup : AppStartup
{ {
// 配置ip2region // 配置ip2region
services.AddSingleton<ISearcher>(new Searcher(CachePolicy.Content, Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ip2region.xdb"))); services.AddSingleton<ISearcher>(new Searcher(CachePolicy.Content, Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ip2region.xdb")));
// 单机进程内统一缓存并刷新 OSS STS 临时凭证。
services.AddSingleton<IAliyunOssClientProvider, AliyunOssClientProvider>();
//系统配置转实体 //系统配置转实体
services.AddConfigurableOptions<SystemSettingsOptions>(); services.AddConfigurableOptions<SystemSettingsOptions>();
+2 -2
View File
@@ -32,10 +32,10 @@
"RoleSessionName": "onboardx-server", "RoleSessionName": "onboardx-server",
"StsEndpoint": "sts.cn-beijing.aliyuncs.com", "StsEndpoint": "sts.cn-beijing.aliyuncs.com",
"Bucket": "onboardx", "Bucket": "onboardx",
"EndPoint": "oss-cn-beijing.aliyuncs.com", "EndPoint": "cn-beijing.oss.aliyuncs.com",
"IMMEndpoint": "", "IMMEndpoint": "",
"IMMProject": "", "IMMProject": "",
"OSSUrl": "https://onboardx.oss-cn-beijing.aliyuncs.com", "OSSUrl": "https://onboardx.cn-beijing.oss.aliyuncs.com",
"RegionId": "oss-cn-beijing" "RegionId": "oss-cn-beijing"
} }
} }

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