diff --git a/SimpleAdmin.Application/Application.Development.json b/SimpleAdmin.Application/Application.Development.json index 6affcb7..0b95cce 100644 --- a/SimpleAdmin.Application/Application.Development.json +++ b/SimpleAdmin.Application/Application.Development.json @@ -6,8 +6,28 @@ "Title": "业务模块" //配置分组标题,string 类型 }, //系统层设置 - "ApplicationSettings": { - "InitTable": false, //是否初始化表结构 - "InitSeedData": false //是否初始化种子数据 + "ApplicationSettings": { + "InitTable": false, //是否初始化表结构 + "InitSeedData": false //是否初始化种子数据 + }, + // 契约锁测试环境配置。AppToken、AppSecret 由部署人员填写,禁止提交真实密钥。 + "QiyuesuoSetting": { + "Enabled": true, + "ServerUrl": "https://openapi.qiyuesuo.cn", + "AppToken": "rMKU99gkts", + "AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O", + "SignatureVersion": "", + "CategoryId": "3533041076673835142", + "SealId": "3533379903657587306", + "CreatorName": "袁林", + "CreatorMobile": "15123135384", + "CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev", + "SignPageCallbackUrl": "", + "SignUrlExpireMinutes": 10, + "EmployeeSignKeyword": "乙方(劳动者)签字:", + "EmployeeSignKeywordIndex": 1, + "CompanySealKeyword": "甲方(盖章):", + "CompanySealKeywordIndex": 1, + "TimeoutSeconds": 90 } -} \ No newline at end of file +} diff --git a/SimpleAdmin.Application/Application.Production.json b/SimpleAdmin.Application/Application.Production.json index 3f68a37..92185cf 100644 --- a/SimpleAdmin.Application/Application.Production.json +++ b/SimpleAdmin.Application/Application.Production.json @@ -8,6 +8,26 @@ //系统层设置 "ApplicationSettings": { "InitTable": false, //是否初始化表结构 - "InitSeedData": false, //是否初始化种子数据0 - } -} \ No newline at end of file + "InitSeedData": false //是否初始化种子数据 + }, + // 契约锁正式环境配置。上线前填写正式 AppToken/AppSecret,并把 ServerUrl 切换为 .com。 + "QiyuesuoSetting": { + "Enabled": true, + "ServerUrl": "https://openapi.qiyuesuo.com", + "AppToken": "rMKU99gkts", + "AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O", + "SignatureVersion": "", + "CategoryId": "3533041076673835142", + "SealId": "3533042752893948009", + "CreatorName": "袁林", + "CreatorMobile": "15123135384", + "CallbackUrl": "https://onboardx-api.cnzhyx.cn/api/open/onboarding/qiyuesuo/callback", + "SignPageCallbackUrl": "", + "SignUrlExpireMinutes": 10, + "EmployeeSignKeyword": "乙方(劳动者)签字:", + "EmployeeSignKeywordIndex": 1, + "CompanySealKeyword": "甲方(盖章):", + "CompanySealKeywordIndex": 1, + "TimeoutSeconds": 90 + } +} diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignDocument.cs b/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignDocument.cs new file mode 100644 index 0000000..0c3e104 --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignDocument.cs @@ -0,0 +1,64 @@ +namespace SimpleAdmin.Application; + +/// +/// 电子签流程中的合同文件映射,一条任务合同对应一个契约锁文档。 +/// +[SugarTable("ob_electronic_sign_document", TableDescription = "入职电子签文档")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObElectronicSignDocument : BaseEntity +{ + /// + /// 所属电子签流程 Id。 + /// + public long SignProcessId { get; set; } + + /// + /// 对应的入职任务合同 Id。 + /// + public long TaskContractId { get; set; } + + /// + /// 契约锁文档 Id。 + /// + public string ProviderDocumentId { get; set; } + + /// + /// 发起签署时冻结的文档名称。 + /// + public string DocumentName { get; set; } + + /// + /// 文档在同一签署流程中的顺序。 + /// + public int DocumentSort { get; set; } + + /// + /// 上传契约锁的原始 DOCX 系统文件 Id。 + /// + public long OriginalDocxFileId { get; set; } + + /// + /// 签署完成后归档的 PDF 系统文件 Id。 + /// + public long? SignedPdfFileId { get; set; } + + /// + /// 员工签名关键字快照。 + /// + public string EmployeeSignKeyword { get; set; } + + /// + /// 企业盖章关键字快照。 + /// + public string CompanySealKeyword { get; set; } + + /// + /// 契约锁员工签署动作 Id。 + /// + public string EmployeeActionId { get; set; } + + /// + /// 契约锁企业盖章动作 Id。 + /// + public string CompanyActionId { get; set; } +} diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignEvent.cs b/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignEvent.cs new file mode 100644 index 0000000..89b18ba --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignEvent.cs @@ -0,0 +1,55 @@ +namespace SimpleAdmin.Application; + +/// +/// 契约锁回调与主动同步事件,用于幂等处理和完整审计。 +/// +[SugarTable("ob_electronic_sign_event", TableDescription = "入职电子签事件")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObElectronicSignEvent : BaseEntity +{ + /// + /// 所属电子签流程 Id;无法识别流程的异常回调可为空。 + /// + public long? SignProcessId { get; set; } + + /// + /// 回调内容 SHA-256,用于识别完全重复的通知。 + /// + public string PayloadHash { get; set; } + + /// + /// 事件来源,例如 callback 或 manual_sync。 + /// + public string EventSource { get; set; } + + /// + /// 契约锁回调事件类型。 + /// + public string EventType { get; set; } + + /// + /// 回调携带或主动查询得到的契约锁状态。 + /// + public string ProviderStatus { get; set; } + + /// + /// 契约锁原始请求内容,保留用于审计排查。 + /// + [SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)] + public string PayloadJson { get; set; } + + /// + /// 本次事件处理结果。 + /// + public string ProcessResult { get; set; } + + /// + /// 接收到事件的时间。 + /// + public DateTime ReceivedAt { get; set; } + + /// + /// 事件处理完成时间。 + /// + public DateTime? ProcessedAt { get; set; } +} diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignProcess.cs b/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignProcess.cs new file mode 100644 index 0000000..3c1683c --- /dev/null +++ b/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignProcess.cs @@ -0,0 +1,162 @@ +namespace SimpleAdmin.Application; + +/// +/// 入职任务的一次电子签流程,一次流程可以包含多份合同文件。 +/// +[SugarTable("ob_electronic_sign_process", TableDescription = "入职电子签流程")] +[Tenant(SqlSugarConst.DB_DEFAULT)] +public class ObElectronicSignProcess : BaseEntity +{ + /// + /// 所属入职任务 Id。 + /// + public long TaskId { get; set; } + + /// + /// 审核通过后生成的正式员工 Id。 + /// + public long EmployeeId { get; set; } + + /// + /// 电子签服务商编码。 + /// + public string Provider { get; set; } = ElectronicSignProvider.Qiyuesuo; + + /// + /// 本系统生成的幂等业务编号。 + /// + public string BizId { get; set; } + + /// + /// 是否为该入职任务当前使用的电子签流程。 + /// 历史流程保存为空,当前流程保存为 true,并通过数据库唯一索引防止重复发起。 + /// + public bool? IsCurrent { get; set; } = true; + + /// + /// 契约锁合同流程 Id。 + /// + public string ProviderContractId { get; set; } + + /// + /// 契约锁业务分类 Id 快照。 + /// + public string CategoryId { get; set; } + + /// + /// 契约锁企业印章 Id 快照。 + /// + public string SealId { get; set; } + + /// + /// 电子签流程标题。 + /// + public string Subject { get; set; } + + /// + /// 员工姓名快照。 + /// + public string EmployeeName { get; set; } + + /// + /// 员工手机号码快照。 + /// + public string EmployeeMobile { get; set; } + + /// + /// 员工身份证号码快照,仅用于锁定本次电子签署人的实名信息。 + /// + public string EmployeeIdCardNumber { get; set; } + + /// + /// 契约锁合同创建人姓名快照。 + /// + public string CreatorName { get; set; } + + /// + /// 契约锁合同创建人手机号快照。 + /// + public string CreatorMobile { get; set; } + + /// + /// 本系统电子签状态。 + /// + public string SignStatus { get; set; } = ElectronicSignStatus.Creating; + + /// + /// 契约锁返回的原始合同状态。 + /// + public string ProviderStatus { get; set; } + + /// + /// 员工签署页面地址。 + /// + [SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)] + public string SignUrl { get; set; } + + /// + /// 员工签署页面地址到期时间。 + /// + public DateTime? SignUrlExpireAt { get; set; } + + /// + /// 管理员点击发起电子签的时间。 + /// + public DateTime StartedAt { get; set; } + + /// + /// 合同流程正式发送的时间。 + /// + public DateTime? SentAt { get; set; } + + /// + /// 所有签署方完成签署的时间。 + /// + public DateTime? CompletedAt { get; set; } + + /// + /// 签署文件与签署日志保存到 OSS 的时间。 + /// + public DateTime? ArchivedAt { get; set; } + + /// + /// 契约锁完整归档 ZIP 在系统文件表中的 Id。 + /// + public long? ArchiveFileId { get; set; } + + /// + /// 最后一次主动查询契约锁状态的时间。 + /// + public DateTime? LastSyncedAt { get; set; } + + /// + /// 最后一次收到契约锁回调的时间。 + /// + public DateTime? LastCallbackAt { get; set; } + + /// + /// 最近一次失败原因,供管理员排查和重试。 + /// + [SugarColumn(ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)] + public string LastError { get; set; } + + /// + /// 创建或同步失败后的重试次数。 + /// + public int RetryCount { get; set; } + + /// + /// 管理员重新发起本任务电子签时填写的原因。 + /// + public string ReissueReason { get; set; } + + /// + /// 当前流程被新流程替代的时间。 + /// + public DateTime? ReissuedAt { get; set; } + + /// + /// 替代当前流程的新电子签流程 Id。 + /// + public long? ReissuedByProcessId { get; set; } +} diff --git a/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs b/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs index dd9bc6e..57e7188 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs @@ -128,12 +128,89 @@ public static class TaskContractStatus /// public const string OfflinePendingSign = "offline_pending_sign"; + /// + /// 已发起第三方电子签,等待员工签署或企业盖章。 + /// + public const string ElectronicSigning = "electronic_signing"; + + /// + /// 第三方电子签已完成,签署文件正在或已经归档。 + /// + public const string ElectronicSigned = "electronic_signed"; + /// /// 已归档,任务流程结束且历史数据只读。 /// public const string Archived = "archived"; } +/// +/// 电子签服务商。 +/// +public static class ElectronicSignProvider +{ + /// + /// 契约锁开放平台。 + /// + public const string Qiyuesuo = "qiyuesuo"; +} + +/// +/// 入职电子签流程状态。 +/// +public static class ElectronicSignStatus +{ + /// + /// 正在调用第三方创建流程。 + /// + public const string Creating = "creating"; + + /// + /// 已创建草稿,尚未成功发送。 + /// + public const string Draft = "draft"; + + /// + /// 已发送,等待员工签署。 + /// + public const string PendingEmployeeSign = "pending_employee_sign"; + + /// + /// 员工已签,等待企业自动盖章或平台完成流程。 + /// + public const string PendingCompanySeal = "pending_company_seal"; + + /// + /// 所有签署已完成,等待文件归档。 + /// + public const string Completed = "completed"; + + /// + /// 签署文件和签署日志已经归档。 + /// + public const string Archived = "archived"; + + /// + /// 流程被拒签。 + /// + public const string Rejected = "rejected"; + + /// + /// 流程已作废。 + /// + public const string Invalid = "invalid"; + + /// + /// 流程已过期。 + /// + public const string Expired = "expired"; + + /// + /// 创建、发送、同步或归档失败,可由管理员重试。 + /// + public const string Failed = "failed"; +} + /// /// 合同变量类型。 /// diff --git a/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs b/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs new file mode 100644 index 0000000..28090d7 --- /dev/null +++ b/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs @@ -0,0 +1,92 @@ +namespace SimpleAdmin.Application; + +/// +/// 契约锁开放平台连接及入职电子签业务配置。 +/// +public class QiyuesuoSettingOptions +{ + /// + /// 是否启用契约锁电子签功能。 + /// + public bool Enabled { get; set; } = true; + + /// + /// 契约锁开放平台地址;测试环境为 https://openapi.qiyuesuo.cn。 + /// + public string ServerUrl { get; set; } = "https://openapi.qiyuesuo.cn"; + + /// + /// 契约锁应用 App Token。 + /// + public string AppToken { get; set; } + + /// + /// 契约锁应用 App Secret。 + /// + public string AppSecret { get; set; } + + /// + /// 签名版本;留空使用兼容性最好的 MD5,设置 HMACSHA256 使用新版签名。 + /// + public string SignatureVersion { get; set; } + + /// + /// 默认业务分类 Id。 + /// + public string CategoryId { get; set; } + + /// + /// 企业自动盖章使用的印章 Id。 + /// + public string SealId { get; set; } + + /// + /// 契约锁企业成员中的合同创建人姓名。 + /// + public string CreatorName { get; set; } + + /// + /// 契约锁企业成员中的合同创建人手机号。 + /// + public string CreatorMobile { get; set; } + + /// + /// 契约锁合同状态回调地址。 + /// + public string CallbackUrl { get; set; } + + /// + /// 员工从契约锁签署页面返回时跳转的前端地址;可为空。 + /// + public string SignPageCallbackUrl { get; set; } + + /// + /// 员工签署页链接有效分钟数。 + /// + public int SignUrlExpireMinutes { get; set; } = 10; + + /// + /// DOCX 中定位员工签名位置的关键字。 + /// + public string EmployeeSignKeyword { get; set; } = "乙方(劳动者)签字:"; + + /// + /// 员工签名关键字在单份文档中重复时使用第几个,从 1 开始。 + /// + public int EmployeeSignKeywordIndex { get; set; } = 1; + + /// + /// DOCX 中定位企业盖章位置的关键字。 + /// + public string CompanySealKeyword { get; set; } = "甲方(盖章):"; + + /// + /// 企业盖章关键字在单份文档中重复时使用第几个,从 1 开始。 + /// + public int CompanySealKeywordIndex { get; set; } = 1; + + /// + /// 单次 HTTP 请求超时秒数。 + /// + public int TimeoutSeconds { get; set; } = 90; +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/Dto/OnboardingContractDocumentInput.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/Dto/OnboardingContractDocumentInput.cs index 7c04691..2c06791 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/Dto/OnboardingContractDocumentInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/Dto/OnboardingContractDocumentInput.cs @@ -41,6 +41,11 @@ public class OnboardingContractPreviewInput [IdNotNull(ErrorMessage = "入职模板Id不能为空")] public long OnboardingTemplateId { get; set; } + /// + /// 本次需要预览的合同模板版本 Id;批量预览时无需传入。 + /// + public long? ContractTemplateVersionId { get; set; } + /// /// 人事当前在创建表单中填写的合同变量。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/IOnboardingContractDocumentService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/IOnboardingContractDocumentService.cs index 8f450ff..3965a58 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/IOnboardingContractDocumentService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/IOnboardingContractDocumentService.cs @@ -11,6 +11,12 @@ public interface IOnboardingContractDocumentService : ITransient Task PreviewBeforeCreate( OnboardingContractPreviewInput input); + /// + /// 创建入职任务前,按模板顺序生成全部合同预览并打包为 ZIP。 + /// + Task PreviewAllBeforeCreate( + OnboardingContractPreviewInput input); + /// /// 根据已审核通过的任务合同生成并保存 DOCX 文件。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs index 2bafdf5..4dca707 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractDocument/OnboardingContractDocumentService.cs @@ -15,10 +15,62 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ } /// - /// 在任务创建前生成单份临时 DOCX,仅通过响应返回,不写文件表和 OSS。 + /// 在任务创建前生成指定合同的临时 DOCX,仅通过响应返回,不写文件表和 OSS。 /// public async Task 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); + } + + /// + /// 在任务创建前生成全部合同预览,并按模板顺序打包为 ZIP。 + /// + public async Task 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" + }; + } + + /// + /// 校验预览上下文,并读取入职模板锁定的全部合同配置。 + /// + private async Task LoadPreviewContext( + OnboardingContractPreviewInput input) { var company = await _db.Queryable() .FirstAsync(item => item.Id == input.CompanyId @@ -53,32 +105,97 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ if (!supportsPosition) throw Oops.Bah("所选入职模板不适用于该岗位"); - // 当前业务只预览入职模板中排序第一的合同,后续多合同可在此扩展。 - var contractConfig = await _db.Queryable() + var contractConfigs = await _db.Queryable() .Where(item => item.OnboardingTemplateId == onboardingTemplate.Id) .OrderBy(item => item.SortCode) - .FirstAsync() - ?? throw Oops.Bah("入职模板尚未配置合同文件"); + .OrderBy(item => item.Id) + .ToListAsync(); + if (contractConfigs.Count == 0) + throw Oops.Bah("入职模板尚未配置合同文件"); - var contractTemplate = await _db.Queryable() - .FirstAsync(item => item.Id == contractConfig.ContractTemplateId - && item.CompanyId == input.CompanyId - && item.DepartmentId == input.DepartmentId - && item.Status == CommonStatusConst.ENABLE) - ?? throw Oops.Bah("合同模板不存在、未启用或不属于当前公司、部门"); + var duplicateVersion = contractConfigs + .GroupBy(item => item.ContractTemplateVersionId) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateVersion != null) + throw Oops.Bah("入职模板中存在重复的合同版本,请先调整模板配置"); - var contractSupportsPosition = await _db.Queryable() - .AnyAsync(item => item.ContractTemplateId == contractTemplate.Id - && item.PositionId == input.PositionId); - if (!contractSupportsPosition) - throw Oops.Bah("合同模板不适用于当前岗位"); + var templateIds = contractConfigs + .Select(item => item.ContractTemplateId) + .Distinct() + .ToList(); + var contractTemplates = await _db.Queryable() + .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 version = await _db.Queryable() - .FirstAsync(item => item.Id == contractConfig.ContractTemplateVersionId - && item.TemplateId == contractTemplate.Id - && item.VersionStatus == ContractTemplateVersionStatus.Enabled) - ?? throw Oops.Bah("合同版本不存在、未启用或不属于当前合同模板"); + var supportedTemplateCount = await _db.Queryable() + .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() + .Where(item => versionIds.Contains(item.Id)) + .ToListAsync(); + var versionsValid = versions.Count == versionIds.Count + && contractConfigs.All(config => versions.Any(version => + version.Id == config.ContractTemplateVersionId + && version.TemplateId == config.ContractTemplateId + && version.VersionStatus == ContractTemplateVersionStatus.Enabled)); + if (!versionsValid) + throw Oops.Bah("入职模板包含不存在、未启用或归属错误的合同版本"); + + var inputs = input.ContractVariables + ?? new List(); + 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 + }; + } + + /// + /// 根据一条合同配置和当前人事参数渲染一份 DOCX。 + /// + private async Task 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() .Where(item => item.TemplateVersionId == version.Id) .OrderBy(item => item.SortCode) @@ -89,31 +206,20 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ if (duplicateDefinition != null) throw Oops.Bah($"合同变量“{duplicateDefinition.Key}”存在重复定义"); - var inputs = input.ContractVariables - ?? new List(); - 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 inputs = (input.ContractVariables + ?? new List()) + .Where(item => item.ContractTemplateVersionId == version.Id) + .ToList(); var editableDefinitions = definitions .Where(IsHrEditableDefinition) .ToList(); foreach (var submitted in inputs) { - var definitionExists = submitted.ContractTemplateVersionId == version.Id - && editableDefinitions.Any(definition => - string.Equals( - definition.VariableCode, - submitted.VariableCode, - StringComparison.OrdinalIgnoreCase)); + var definitionExists = editableDefinitions.Any(definition => + string.Equals( + definition.VariableCode, + submitted.VariableCode, + StringComparison.OrdinalIgnoreCase)); if (!definitionExists) throw Oops.Bah($"合同变量“{submitted.VariableCode}”不存在或不允许人事填写"); } @@ -124,8 +230,7 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ return false; var submitted = inputs.FirstOrDefault(item => - item.ContractTemplateVersionId == version.Id - && string.Equals( + string.Equals( item.VariableCode, definition.VariableCode, StringComparison.OrdinalIgnoreCase)); @@ -150,8 +255,7 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ definition => { var submitted = inputs.FirstOrDefault(item => - item.ContractTemplateVersionId == version.Id - && string.Equals( + string.Equals( item.VariableCode, definition.VariableCode, StringComparison.OrdinalIgnoreCase)); @@ -162,7 +266,7 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ definition, null, emptyDynamicValues, - company, + context.Company, previewTask, true) ?? string.Empty; @@ -181,7 +285,7 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ FileBytes = DocxTemplateProcessor.Render( sourceBytes.ToArray(), values), - FileName = BuildPreviewFileName(contractTemplate.TemplateName) + FileName = BuildPreviewFileName(contractTemplate.TemplateName, fileOrder) }; } @@ -197,7 +301,8 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ var statusAllowsGeneration = task.TaskStatus == OnboardingTaskStatus.PendingFill || task.TaskStatus == OnboardingTaskStatus.Rejected || task.TaskStatus == OnboardingTaskStatus.Reviewing - || task.TaskStatus == OnboardingTaskStatus.Approved; + || task.TaskStatus == OnboardingTaskStatus.Approved + || task.TaskStatus == OnboardingTaskStatus.Archived; if (!statusAllowsGeneration) throw Oops.Bah("当前任务状态不能生成合同"); if (contract.GeneratedDocxFileId.HasValue) @@ -275,12 +380,27 @@ public class OnboardingContractDocumentService : IOnboardingContractDocumentServ /// /// 生成兼容 Windows 和 Linux 的合同预览下载文件名。 /// - private static string BuildPreviewFileName(string contractName) + 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()); - return $"{safeName}_人事预览_{DateTime.Now:yyyyMMddHHmmss}.docx"; + var orderPrefix = fileOrder.HasValue ? $"{fileOrder.Value:D2}_" : string.Empty; + return $"{orderPrefix}{safeName}_人事预览_{DateTime.Now:yyyyMMddHHmmss}.docx"; + } + + /// + /// 创建任务前预览合同所需的已校验数据。 + /// + private sealed class ContractPreviewContext + { + public ObCompany Company { get; init; } + + public List ContractConfigs { get; init; } + + public List ContractTemplates { get; init; } + + public List Versions { get; init; } } } diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignInput.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignInput.cs new file mode 100644 index 0000000..bea5d2b --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignInput.cs @@ -0,0 +1,39 @@ +using System.ComponentModel.DataAnnotations; + +namespace SimpleAdmin.Application; + +/// +/// 对指定入职任务执行电子签操作的参数。 +/// +public class OnboardingElectronicSignTaskInput +{ + /// + /// 入职任务 Id。 + /// + [IdNotNull(ErrorMessage = "入职任务Id不能为空")] + public long TaskId { get; set; } +} + +/// +/// 作废入职电子签流程的参数。 +/// +public class OnboardingElectronicSignInvalidInput : OnboardingElectronicSignTaskInput +{ + /// + /// 作废原因。 + /// + [Required(ErrorMessage = "请填写作废原因")] + public string Reason { get; set; } +} + +/// +/// 重新发起入职电子签流程的参数。 +/// +public class OnboardingElectronicSignReinitiateInput : OnboardingElectronicSignTaskInput +{ + /// + /// 管理员确认重新发起的原因,随旧流程保留用于审计。 + /// + [Required(ErrorMessage = "请填写重新发起原因")] + public string Reason { get; set; } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs new file mode 100644 index 0000000..bb09b24 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs @@ -0,0 +1,152 @@ +namespace SimpleAdmin.Application; + +/// +/// 管理端查看的入职电子签流程详情。 +/// +public class OnboardingElectronicSignDetailOutput +{ + /// 入职任务 Id。 + public long TaskId { get; set; } + + /// 入职任务编号。 + public string TaskNo { get; set; } + + /// 当前是否允许发起或重试电子签。 + public bool CanInitiate { get; set; } + + /// 不允许发起时的原因。 + public string CannotInitiateReason { get; set; } + + /// 当前流程是否允许作为一次新签约重新发起。 + public bool CanReinitiate { get; set; } + + /// 不允许重新发起时的原因。 + public string CannotReinitiateReason { get; set; } + + /// 是否已经存在电子签流程。 + public bool HasProcess { get; set; } + + /// 本地电子签流程 Id。 + public long? ProcessId { get; set; } + + /// 契约锁合同流程 Id。 + public string ProviderContractId { get; set; } + + /// 本系统幂等业务编号。 + public string BizId { get; set; } + + /// 电子签流程标题。 + public string Subject { get; set; } + + /// 本系统电子签状态。 + public string SignStatus { get; set; } + + /// 契约锁原始状态。 + public string ProviderStatus { get; set; } + + /// 员工姓名。 + public string EmployeeName { get; set; } + + /// 员工手机号。 + public string EmployeeMobile { get; set; } + + /// 员工签署页面地址。 + public string SignUrl { get; set; } + + /// 员工签署页面到期时间。 + public DateTime? SignUrlExpireAt { get; set; } + + /// 员工签署页面当前是否有效。 + public bool SignUrlValid { get; set; } + + /// 发起时间。 + public DateTime? StartedAt { get; set; } + + /// 发送时间。 + public DateTime? SentAt { get; set; } + + /// 签署完成时间。 + public DateTime? CompletedAt { get; set; } + + /// 归档完成时间。 + public DateTime? ArchivedAt { get; set; } + + /// 完整归档 ZIP 的系统文件 Id。 + public long? ArchiveFileId { get; set; } + + + /// 最近一次失败原因。 + public string LastError { get; set; } + + /// 签约流程包含的文档。 + public List Documents { get; set; } = new(); + + /// 该入职任务已经结束的历史电子签流程。 + public List HistoryProcesses { get; set; } = new(); +} + + +/// +/// 已被新流程替代的历史电子签流程摘要。 +/// +public class OnboardingElectronicSignHistoryOutput +{ + /// 本地电子签流程 Id。 + public long ProcessId { get; set; } + + /// 本系统幂等业务编号。 + public string BizId { get; set; } + + /// 契约锁合同流程 Id。 + public string ProviderContractId { get; set; } + + /// 本系统电子签状态。 + public string SignStatus { get; set; } + + /// 契约锁原始状态。 + public string ProviderStatus { get; set; } + + /// 员工姓名快照。 + public string EmployeeName { get; set; } + + /// 员工手机号快照。 + public string EmployeeMobile { get; set; } + + /// 被新流程替代的时间。 + public DateTime? ReissuedAt { get; set; } + + /// 管理员填写的重新发起原因。 + public string ReissueReason { get; set; } + + /// 流程发起时间。 + public DateTime? StartedAt { get; set; } + + /// 流程结束或失败原因。 + public string LastError { get; set; } +} +/// +/// 电子签流程中的单份合同文档。 +/// +public class OnboardingElectronicSignDocumentOutput +{ + /// 本地电子签文档 Id。 + public long Id { get; set; } + + /// 任务合同 Id。 + public long TaskContractId { get; set; } + + /// 契约锁文档 Id。 + public string ProviderDocumentId { get; set; } + + /// 文档名称。 + public string DocumentName { get; set; } + + /// 文档顺序。 + public int DocumentSort { get; set; } + + /// 发起签约的原始 DOCX 文件 Id。 + public long OriginalDocxFileId { get; set; } + + /// 签署完成后的 PDF 文件 Id。 + public long? SignedPdfFileId { get; set; } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/IOnboardingElectronicSignService.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/IOnboardingElectronicSignService.cs new file mode 100644 index 0000000..b987d4f --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/IOnboardingElectronicSignService.cs @@ -0,0 +1,35 @@ +namespace SimpleAdmin.Application; + +/// +/// 定义入职电子签流程管理能力。 +/// +public interface IOnboardingElectronicSignService : ITransient +{ + /// 查询指定入职任务的电子签详情。 + Task Detail(BaseIdInput input); + + /// 创建或重试电子签流程。 + Task Initiate( + OnboardingElectronicSignTaskInput input); + + /// 保留旧流程并创建一次全新的电子签流程。 + Task Reinitiate( + OnboardingElectronicSignReinitiateInput input); + + /// 主动同步契约锁状态并在完成后归档。 + Task Sync( + OnboardingElectronicSignTaskInput input); + + /// 获取或刷新员工签署地址。 + Task GetSignLink( + OnboardingElectronicSignTaskInput input); + + /// 催办当前待签员工。 + Task Notice(OnboardingElectronicSignTaskInput input); + + /// 作废尚未结束的签约流程。 + Task Invalid(OnboardingElectronicSignInvalidInput input); + + /// 处理契约锁服务器明文回调。 + Task HandleCallback(string payloadJson); +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs new file mode 100644 index 0000000..4c7fb91 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs @@ -0,0 +1,1284 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace SimpleAdmin.Application; + +/// +/// 实现入职合同“员工先签、企业自动盖章”的契约锁电子签流程。 +/// +public class OnboardingElectronicSignService : IOnboardingElectronicSignService +{ + private readonly SqlSugarScope _db; + private readonly IQiyuesuoClient _qiyuesuoClient; + private readonly IOnboardingContractDocumentService _contractDocumentService; + private readonly IFileService _fileService; + private readonly IConfigService _configService; + private readonly QiyuesuoSettingOptions _setting; + + public OnboardingElectronicSignService( + IQiyuesuoClient qiyuesuoClient, + IOnboardingContractDocumentService contractDocumentService, + IFileService fileService, + IConfigService configService) + { + _db = DbContext.DB; + _qiyuesuoClient = qiyuesuoClient; + _contractDocumentService = contractDocumentService; + _fileService = fileService; + _configService = configService; + _setting = App.GetConfig( + "QiyuesuoSetting", + true) + ?? new QiyuesuoSettingOptions(); + } + + /// + public async Task Detail( + BaseIdInput input) + { + var task = await GetAuthorizedTask(input.Id); + return await BuildDetail(task); + } + + /// + public async Task Initiate( + OnboardingElectronicSignTaskInput input) + { + var task = await GetAuthorizedTask(input.TaskId); + ValidateTaskCanSign(task); + ValidateSettings(); + + var process = await GetLatestProcess(task.Id); + var isNewProcess = process == null; + var creatingAt = process?.UpdateTime ?? process?.StartedAt; + if (process?.SignStatus == ElectronicSignStatus.Creating + && creatingAt > DateTime.Now.AddMinutes(-5)) + { + return await BuildDetail(task, process); + } + if (process != null + && process.SignStatus != ElectronicSignStatus.Failed + && process.SignStatus != ElectronicSignStatus.Creating + && process.SignStatus != ElectronicSignStatus.Draft) + { + return await BuildDetail(task, process); + } + + var context = await GetInitiationContext(task); + if (process == null) + { + var created = await CreateProcess(task, context.Employee); + process = created.Process; + isNewProcess = created.Created; + } + + return await StartProcess( + task, + context.CompanyName, + context.TaskContracts, + process, + isNewProcess); + } + + /// + public async Task Reinitiate( + OnboardingElectronicSignReinitiateInput input) + { + var task = await GetAuthorizedTask(input.TaskId); + ValidateTaskCanSign(task); + ValidateSettings(); + + var previousProcess = await GetLatestProcess(task.Id) + ?? throw Oops.Bah("该入职任务尚未发起电子签,无需重新发起"); + ValidateProcessCanReinitiate(previousProcess); + + var context = await GetInitiationContext(task); + var process = await CreateReplacementProcess( + task, + context.Employee, + previousProcess, + input.Reason.Trim()); + + return await StartProcess( + task, + context.CompanyName, + context.TaskContracts, + process, + true); + } + + /// + /// 加载并校验电子签发起所需的员工、公司和合同数据。 + /// + private async Task<( + ObEmployee Employee, + string CompanyName, + List TaskContracts)> GetInitiationContext( + ObOnboardingTask task) + { + var employee = await _db.Queryable() + .InSingleAsync(task.EmployeeId.Value) + ?? throw Oops.Bah("正式员工档案不存在,不能发起电子签"); + if (string.IsNullOrWhiteSpace(employee.EmployeeName)) + throw Oops.Bah("正式员工姓名为空,不能发起电子签"); + if (string.IsNullOrWhiteSpace(employee.Phone)) + throw Oops.Bah("正式员工手机号为空,不能发起电子签"); + + var employeeInfo = await _db.Queryable() + .FirstAsync(item => item.TaskId == task.Id) + ?? throw Oops.Bah("审核通过的入职登记资料不存在,不能发起电子签"); + ValidateSignerIdentity(employee, employeeInfo); + + var company = await _db.Queryable() + .InSingleAsync(task.CompanyId) + ?? throw Oops.Bah("任务所属公司不存在,不能发起电子签"); + var companyName = string.IsNullOrWhiteSpace(company.LegalName) + ? company.CompanyName + : company.LegalName; + companyName = companyName?.Trim(); + if (string.IsNullOrWhiteSpace(companyName)) + throw Oops.Bah("任务所属公司法定名称为空,不能发起电子签"); + + var taskContracts = await _db.Queryable() + .Where(item => item.TaskId == task.Id) + .OrderBy(item => item.Id) + .ToListAsync(); + if (taskContracts.Count == 0) + throw Oops.Bah("入职任务没有可发起签约的合同"); + + var unconfirmed = taskContracts.FirstOrDefault(item => + item.Required && !item.ConfirmedAt.HasValue); + if (unconfirmed != null) + throw Oops.Bah($"合同“{unconfirmed.ContractName}”尚未由员工确认"); + + return (employee, companyName, taskContracts); + } + + /// + /// 从本地断点创建或继续契约锁流程,并统一保存失败原因。 + /// + private async Task StartProcess( + ObOnboardingTask task, + string companyName, + List taskContracts, + ObElectronicSignProcess process, + bool isNewProcess) + { + var creatingAt = process.UpdateTime ?? process.StartedAt; + if (!isNewProcess + && process.SignStatus == ElectronicSignStatus.Creating + && creatingAt > DateTime.Now.AddMinutes(-5)) + { + return await BuildDetail(task, process); + } + if (!isNewProcess) + { + var originalStatus = process.SignStatus; + var originalUpdateTime = process.UpdateTime; + var claimedAt = DateTime.Now; + var claimedRows = await _db.Updateable() + .SetColumns(item => new ObElectronicSignProcess + { + SignStatus = ElectronicSignStatus.Creating, + LastError = null, + UpdateTime = claimedAt, + UpdateUserId = UserManager.UserId, + UpdateUser = UserManager.Name + }) + .Where(item => item.Id == process.Id + && item.IsCurrent == true + && item.SignStatus == originalStatus + && item.UpdateTime == originalUpdateTime) + .ExecuteCommandAsync(); + if (claimedRows != 1) + throw Oops.Bah("电子签流程正在由其他请求处理,请稍后刷新"); + + process.SignStatus = ElectronicSignStatus.Creating; + process.LastError = null; + process.UpdateTime = claimedAt; + process.UpdateUserId = UserManager.UserId; + process.UpdateUser = UserManager.Name; + } + + try + { + await StartOrResumeProcess( + task, + companyName, + taskContracts, + process); + } + catch (Exception exception) + { + // 第三方已经完成签署、仅归档下载失败时,保留“已完成”状态,方便后续通过同步重试归档。 + process.SignStatus = process.CompletedAt.HasValue + ? ElectronicSignStatus.Completed + : ElectronicSignStatus.Failed; + process.LastError = LimitError(exception.Message); + process.RetryCount += 1; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + throw; + } + + return await BuildDetail(task, process); + } + + /// + public async Task Sync( + OnboardingElectronicSignTaskInput input) + { + var task = await GetAuthorizedTask(input.TaskId); + var process = await GetRequiredProcess(task.Id); + await SyncProcess(process, "manual_sync"); + return await BuildDetail(task, process); + } + + /// + public async Task GetSignLink( + OnboardingElectronicSignTaskInput input) + { + var task = await GetAuthorizedTask(input.TaskId); + var process = await GetRequiredProcess(task.Id); + EnsureCanOpenEmployeeSignPage(process); + + var linkStillValid = !string.IsNullOrWhiteSpace(process.SignUrl) + && process.SignUrlExpireAt > DateTime.Now.AddMinutes(1); + if (!linkStillValid) + { + process.SignUrl = await CreateSignPageUrl(process); + process.SignUrlExpireAt = DateTime.Now.AddMinutes( + NormalizeSignUrlExpireMinutes()); + process.LastError = null; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + } + + return await BuildDetail(task, process); + } + + /// + public async Task Notice(OnboardingElectronicSignTaskInput input) + { + var task = await GetAuthorizedTask(input.TaskId); + var process = await GetRequiredProcess(task.Id); + if (process.SignStatus != ElectronicSignStatus.PendingEmployeeSign) + throw Oops.Bah("当前流程不在员工待签状态,无需催签"); + + await _qiyuesuoClient.Notice(process.ProviderContractId); + process.LastError = null; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + } + + /// + public async Task Invalid(OnboardingElectronicSignInvalidInput input) + { + var task = await GetAuthorizedTask(input.TaskId); + var process = await GetRequiredProcess(task.Id); + if (IsTerminal(process.SignStatus)) + throw Oops.Bah("已完成、已归档或已结束的电子签流程不能作废"); + if (string.IsNullOrWhiteSpace(process.ProviderContractId)) + throw Oops.Bah("契约锁流程尚未创建,不能调用作废接口"); + + await _qiyuesuoClient.Invalid( + process.ProviderContractId, + input.Reason.Trim()); + process.SignStatus = ElectronicSignStatus.Invalid; + process.ProviderStatus = "INVALIDED"; + process.SignUrl = null; + process.SignUrlExpireAt = null; + process.LastError = null; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + } + + /// + public async Task HandleCallback(string payloadJson) + { + if (string.IsNullOrWhiteSpace(payloadJson)) + throw Oops.Bah("契约锁回调内容为空"); + + using var json = JsonDocument.Parse(payloadJson); + var root = json.RootElement; + var payloadHash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(payloadJson))) + .ToLowerInvariant(); + var callbackEvent = await _db.Queryable() + .FirstAsync(item => item.PayloadHash == payloadHash); + if (callbackEvent?.ProcessResult == "处理成功") + return; + + var providerContractId = FindString(root, "contractId"); + var bizId = FindString(root, "bizId"); + var eventType = FindString( + root, + "callbackType", + "eventType", + "type"); + var providerStatus = FindString( + root, + "contractStatus", + "status"); + var process = await FindProcess(providerContractId, bizId); + if (callbackEvent == null) + { + callbackEvent = new ObElectronicSignEvent + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + PayloadHash = payloadHash, + EventSource = "callback", + ReceivedAt = DateTime.Now, + CreateTime = DateTime.Now, + IsDelete = false + }; + try + { + await _db.Insertable(callbackEvent).ExecuteCommandAsync(); + } + catch + { + // 两次相同回调并发到达时,由唯一索引保证只保留一条事件记录。 + callbackEvent = await _db.Queryable() + .FirstAsync(item => item.PayloadHash == payloadHash); + if (callbackEvent == null) + throw; + if (callbackEvent.ProcessResult == "处理成功") + return; + } + } + + // 失败、未匹配的历史回调允许第三方用相同报文再次触发处理。 + callbackEvent.SignProcessId = process?.Id; + callbackEvent.EventType = eventType; + callbackEvent.ProviderStatus = providerStatus; + callbackEvent.PayloadJson = payloadJson; + callbackEvent.ProcessResult = process == null ? "未匹配到本地电子签流程" : "处理中"; + callbackEvent.ProcessedAt = null; + await _db.Updateable(callbackEvent).ExecuteCommandAsync(); + if (process == null) + { + callbackEvent.ProcessedAt = DateTime.Now; + await _db.Updateable(callbackEvent).ExecuteCommandAsync(); + return; + } + + try + { + process.LastCallbackAt = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + // 明文回调只作为刷新触发器,最终状态重新向契约锁查询,伪造回调不能改变业务状态。 + await SyncProcess(process, "callback"); + callbackEvent.ProviderStatus = process.ProviderStatus; + callbackEvent.ProcessResult = "处理成功"; + callbackEvent.ProcessedAt = DateTime.Now; + await _db.Updateable(callbackEvent).ExecuteCommandAsync(); + } + catch (Exception exception) + { + callbackEvent.ProcessResult = LimitError(exception.Message, 480); + callbackEvent.ProcessedAt = DateTime.Now; + await _db.Updateable(callbackEvent).ExecuteCommandAsync(); + throw; + } + } + + /// + /// 创建任务的第一条本地电子签流程。 + /// 当前流程唯一索引负责拦截并发双击,BizId 使用流程 Id 保证不会碰撞第三方历史合同。 + /// + private async Task<(ObElectronicSignProcess Process, bool Created)> CreateProcess( + ObOnboardingTask task, + ObEmployee employee) + { + var process = BuildProcess(task, employee); + try + { + await _db.Insertable(process).ExecuteCommandAsync(); + return (process, true); + } + catch + { + // 并发请求可能已经插入当前流程;读取胜出的记录,确保不会创建第二个收费流程。 + var existing = await GetLatestProcess(task.Id); + if (existing != null) + return (existing, false); + throw; + } + } + + /// + /// 将已经结束的当前流程转为历史记录,并在同一事务中创建新的当前流程。 + /// 数据库唯一索引保证多实例或连续点击时每个任务最多只有一条当前流程。 + /// + private async Task CreateReplacementProcess( + ObOnboardingTask task, + ObEmployee employee, + ObElectronicSignProcess previousProcess, + string reason) + { + var process = BuildProcess(task, employee); + var now = DateTime.Now; + + await _db.Ado.BeginTranAsync(); + try + { + var replacedRows = await _db.Updateable() + .SetColumns(item => new ObElectronicSignProcess + { + IsCurrent = null, + ReissueReason = LimitError(reason, 480), + ReissuedAt = now, + ReissuedByProcessId = process.Id, + UpdateTime = now, + UpdateUserId = UserManager.UserId, + UpdateUser = UserManager.Name + }) + .Where(item => item.Id == previousProcess.Id + && item.IsCurrent == true + && item.SignStatus == previousProcess.SignStatus) + .ExecuteCommandAsync(); + if (replacedRows != 1) + throw Oops.Bah("电子签流程状态已经变化,请刷新后再重新发起"); + + await _db.Insertable(process).ExecuteCommandAsync(); + await _db.Ado.CommitTranAsync(); + return process; + } + catch + { + await _db.Ado.RollbackTranAsync(); + throw; + } + } + + /// + /// 创建尚未发送到契约锁的当前流程快照。 + /// + private ObElectronicSignProcess BuildProcess( + ObOnboardingTask task, + ObEmployee employee) + { + var now = DateTime.Now; + var processId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); + return new ObElectronicSignProcess + { + Id = processId, + TaskId = task.Id, + EmployeeId = employee.Id, + Provider = ElectronicSignProvider.Qiyuesuo, + BizId = $"{task.TaskNo}-SIGN-{processId}", + IsCurrent = true, + CategoryId = _setting.CategoryId.Trim(), + SealId = _setting.SealId.Trim(), + Subject = $"{employee.EmployeeName}-{task.TaskNo}-入职合同", + EmployeeName = employee.EmployeeName.Trim(), + EmployeeMobile = employee.Phone.Trim(), + EmployeeIdCardNumber = employee.IdCardNumber?.Trim(), + CreatorName = _setting.CreatorName.Trim(), + CreatorMobile = _setting.CreatorMobile.Trim(), + SignStatus = ElectronicSignStatus.Creating, + StartedAt = now, + Status = CommonStatusConst.ENABLE, + CreateTime = now, + CreateUserId = UserManager.UserId, + CreateUser = UserManager.Name, + IsDelete = false + }; + } + + /// + /// 从本地断点继续创建草稿、上传全部合同并发起一次多文档签约流程。 + /// + private async Task StartOrResumeProcess( + ObOnboardingTask task, + string companyName, + List taskContracts, + ObElectronicSignProcess process) + { + foreach (var contract in taskContracts) + { + contract.GeneratedDocxFileId ??= await _contractDocumentService.Generate( + new OnboardingGenerateContractInput + { + TaskContractId = contract.Id + }); + } + + QiyuesuoContractResult providerContract; + if (string.IsNullOrWhiteSpace(process.ProviderContractId)) + { + providerContract = await _qiyuesuoClient.CreateDraft( + new QiyuesuoCreateDraftInput + { + BizId = process.BizId, + Subject = process.Subject, + CompanyName = companyName, + EmployeeName = process.EmployeeName, + EmployeeMobile = process.EmployeeMobile, + CategoryId = process.CategoryId, + SealId = process.SealId, + CreatorName = process.CreatorName, + CreatorMobile = process.CreatorMobile, + CallbackUrl = _setting.CallbackUrl + }); + process.ProviderContractId = GetContractId(providerContract); + process.ProviderStatus = providerContract.Status ?? "DRAFT"; + // ProviderStatus 可为 DRAFT,但本地仍保持“正在创建”,避免并发点击重复上传或发送。 + process.SignStatus = ElectronicSignStatus.Creating; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + } + else + { + providerContract = await _qiyuesuoClient.GetContractDetail( + process.ProviderContractId); + var providerState = ResolveSignStatus(providerContract); + if (providerState != ElectronicSignStatus.Draft) + { + await SyncProcess(process, "resume"); + return; + } + } + + var documents = await _db.Queryable() + .Where(item => item.SignProcessId == process.Id) + .ToListAsync(); + for (var index = 0; index < taskContracts.Count; index++) + { + var taskContract = taskContracts[index]; + if (documents.Any(item => item.TaskContractId == taskContract.Id)) + continue; + + var originalFileId = taskContract.GeneratedDocxFileId.Value; + var fileBytes = await ReadFile(originalFileId); + var documentName = string.IsNullOrWhiteSpace(taskContract.ContractName) + ? $"合同{index + 1}" + : taskContract.ContractName.Trim(); + var uploaded = await _qiyuesuoClient.AddDocument( + new QiyuesuoAddDocumentInput + { + ContractId = process.ProviderContractId, + BizId = process.BizId, + Title = documentName, + DocumentSort = index + 1, + FileName = $"{SafeFileName(documentName)}.docx", + FileBytes = fileBytes + }); + var document = new ObElectronicSignDocument + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + SignProcessId = process.Id, + TaskContractId = taskContract.Id, + ProviderDocumentId = GetDocumentId(uploaded), + DocumentName = documentName, + DocumentSort = index + 1, + OriginalDocxFileId = originalFileId, + EmployeeSignKeyword = _setting.EmployeeSignKeyword.Trim(), + CompanySealKeyword = _setting.CompanySealKeyword.Trim(), + Status = CommonStatusConst.ENABLE, + CreateTime = DateTime.Now, + CreateUserId = process.CreateUserId, + CreateUser = process.CreateUser, + IsDelete = false + }; + await _db.Insertable(document).ExecuteCommandAsync(); + documents.Add(document); + } + + await SendProcess(process, companyName, documents); + await _db.Updateable() + .SetColumns(item => item.ContractStatus == TaskContractStatus.ElectronicSigning) + .Where(item => item.TaskId == task.Id) + .ExecuteCommandAsync(); + } + + /// + /// 为每份文档分别配置员工签名与企业盖章位置,然后一次性发送整个流程。 + /// + private async Task SendProcess( + ObElectronicSignProcess process, + string companyName, + List documents) + { + var providerContract = await _qiyuesuoClient.GetContractDetail( + process.ProviderContractId); + var personalSignatory = providerContract.Signatories.FirstOrDefault(item => + string.Equals(item.TenantType, "PERSONAL", StringComparison.OrdinalIgnoreCase)) + ?? throw Oops.Bah("契约锁草稿未返回员工签署方"); + var companySignatory = providerContract.Signatories.FirstOrDefault(item => + string.Equals(item.TenantType, "COMPANY", StringComparison.OrdinalIgnoreCase)) + ?? throw Oops.Bah("契约锁草稿未返回企业签署方"); + var companyAction = companySignatory.Actions.FirstOrDefault(item => + string.Equals(item.Type, "COMPANY", StringComparison.OrdinalIgnoreCase)) + ?? throw Oops.Bah("契约锁草稿未返回企业自动盖章动作"); + var personalSignatoryId = GetSignatoryId(personalSignatory); + var companyActionId = GetActionId(companyAction); + var stampers = new List(); + foreach (var document in documents.OrderBy(item => item.DocumentSort)) + { + document.EmployeeActionId = personalSignatoryId; + document.CompanyActionId = companyActionId; + document.UpdateTime = DateTime.Now; + await _db.Updateable(document).ExecuteCommandAsync(); + stampers.Add(new QiyuesuoStamperInput + { + SignatoryId = personalSignatoryId, + Type = "PERSONAL", + DocumentId = document.ProviderDocumentId, + Keyword = document.EmployeeSignKeyword, + KeywordIndex = Math.Max(1, _setting.EmployeeSignKeywordIndex) + }); + stampers.Add(new QiyuesuoStamperInput + { + ActionId = companyActionId, + Type = "COMPANY", + DocumentId = document.ProviderDocumentId, + SealId = process.SealId, + Keyword = document.CompanySealKeyword, + KeywordIndex = Math.Max(1, _setting.CompanySealKeywordIndex) + }); + } + + var sent = await _qiyuesuoClient.Send(new QiyuesuoSendContractInput + { + ContractId = process.ProviderContractId, + BizId = process.BizId, + CompanyName = companyName, + Stampers = stampers + }); + process.ProviderStatus = sent.Status ?? "SIGNING"; + process.SignStatus = ElectronicSignStatus.PendingEmployeeSign; + process.SentAt ??= DateTime.Now; + process.LastSyncedAt = DateTime.Now; + process.LastError = null; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + + // 签署页获取失败不回滚已收费流程,管理员可通过“刷新签署链接”安全重试。 + try + { + process.SignUrl = await CreateSignPageUrl(process); + process.SignUrlExpireAt = DateTime.Now.AddMinutes( + NormalizeSignUrlExpireMinutes()); + await _db.Updateable(process).ExecuteCommandAsync(); + } + catch (Exception exception) + { + process.LastError = $"签约已发起,但签署链接获取失败:{LimitError(exception.Message)}"; + await _db.Updateable(process).ExecuteCommandAsync(); + } + } + + /// + /// 主动查询第三方详情并更新本地状态。 + /// + private async Task SyncProcess( + ObElectronicSignProcess process, + string eventSource) + { + if (string.IsNullOrWhiteSpace(process.ProviderContractId)) + throw Oops.Bah("契约锁流程尚未创建完成,可点击重新发起继续处理"); + + try + { + var providerContract = await _qiyuesuoClient.GetContractDetail( + process.ProviderContractId); + await ApplyProviderDetail(process, providerContract); + process.LastSyncedAt = DateTime.Now; + process.LastError = null; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + if (process.SignStatus == ElectronicSignStatus.Completed) + await ArchiveCompletedProcess(process); + } + catch (Exception exception) + { + process.LastError = $"{eventSource}:{LimitError(exception.Message)}"; + process.LastSyncedAt = DateTime.Now; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + throw; + } + } + + /// + /// 将契约锁状态转换为本系统可展示的业务状态。 + /// + private async Task ApplyProviderDetail( + ObElectronicSignProcess process, + QiyuesuoContractResult providerContract) + { + var localStatus = ResolveSignStatus(providerContract); + process.ProviderStatus = providerContract.Status; + process.SignStatus = localStatus; + if (localStatus == ElectronicSignStatus.Completed + || localStatus == ElectronicSignStatus.Archived) + { + process.CompletedAt ??= DateTime.Now; + process.SignUrl = null; + process.SignUrlExpireAt = null; + await _db.Updateable() + .SetColumns(item => item.ContractStatus == TaskContractStatus.ElectronicSigned) + .Where(item => item.TaskId == process.TaskId) + .ExecuteCommandAsync(); + } + else if (IsTerminal(localStatus)) + { + process.SignUrl = null; + process.SignUrlExpireAt = null; + } + } + + /// + /// 下载每份签署 PDF 和包含签署日志的 ZIP,并保存到业务 OSS。 + /// + private async Task ArchiveCompletedProcess(ObElectronicSignProcess process) + { + var documents = await _db.Queryable() + .Where(item => item.SignProcessId == process.Id) + .OrderBy(item => item.DocumentSort) + .ToListAsync(); + if (documents.Count == 0) + throw Oops.Bah("电子签流程没有文档映射,无法归档"); + + foreach (var document in documents) + { + if (document.SignedPdfFileId.HasValue) + continue; + var signedPdf = await _qiyuesuoClient.DownloadDocument( + document.ProviderDocumentId); + var pdfFileId = await _fileService.StoreGeneratedFile( + SysDictConst.FILE_ENGINE_ALIYUN_OSS, + $"{SafeFileName(process.BizId)}_{document.DocumentSort:D2}_{SafeFileName(document.DocumentName)}_已签署.pdf", + signedPdf); + document.SignedPdfFileId = pdfFileId; + document.UpdateTime = DateTime.Now; + await _db.Updateable(document).ExecuteCommandAsync(); + await _db.Updateable() + .SetColumns(item => new ObTaskContract + { + GeneratedPdfFileId = pdfFileId, + ContractStatus = TaskContractStatus.Archived + }) + .Where(item => item.Id == document.TaskContractId) + .ExecuteCommandAsync(); + } + + if (!process.ArchiveFileId.HasValue) + { + var archive = await _qiyuesuoClient.DownloadArchive( + process.ProviderContractId); + process.ArchiveFileId = await _fileService.StoreGeneratedFile( + SysDictConst.FILE_ENGINE_ALIYUN_OSS, + $"{SafeFileName(process.BizId)}_电子签完整归档.zip", + archive); + } + + process.SignStatus = ElectronicSignStatus.Archived; + process.ArchivedAt ??= DateTime.Now; + process.LastError = null; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + } + + /// + /// 组装管理端需要的当前流程、合同文件和历史签约记录。 + /// + private async Task BuildDetail( + ObOnboardingTask task, + ObElectronicSignProcess process = null) + { + process ??= await GetLatestProcess(task.Id); + var historyProcesses = await _db.Queryable() + .Where(item => item.TaskId == task.Id && item.IsCurrent == null) + .OrderBy(item => item.StartedAt, OrderByType.Desc) + .ToListAsync(); + + var taskAllowed = task.EmployeeId.HasValue + && (task.TaskStatus == OnboardingTaskStatus.Approved + || task.TaskStatus == OnboardingTaskStatus.Archived); + var creatingAt = process?.UpdateTime ?? process?.StartedAt; + var processAllowed = process == null + || process.SignStatus == ElectronicSignStatus.Failed + || process.SignStatus == ElectronicSignStatus.Draft + || (process.SignStatus == ElectronicSignStatus.Creating + && creatingAt <= DateTime.Now.AddMinutes(-5)); + var reinitiateAllowed = process != null + && CanCreateReplacementProcess(process); + var result = new OnboardingElectronicSignDetailOutput + { + TaskId = task.Id, + TaskNo = task.TaskNo, + CanInitiate = taskAllowed && processAllowed, + CannotInitiateReason = ResolveCannotInitiateReason( + task, + taskAllowed, + processAllowed), + CanReinitiate = taskAllowed && reinitiateAllowed, + CannotReinitiateReason = ResolveCannotReinitiateReason( + task, + process, + taskAllowed, + reinitiateAllowed), + HasProcess = process != null, + HistoryProcesses = historyProcesses.Select(item => + new OnboardingElectronicSignHistoryOutput + { + ProcessId = item.Id, + BizId = item.BizId, + ProviderContractId = item.ProviderContractId, + SignStatus = item.SignStatus, + ProviderStatus = item.ProviderStatus, + EmployeeName = item.EmployeeName, + EmployeeMobile = item.EmployeeMobile, + StartedAt = item.StartedAt, + ReissuedAt = item.ReissuedAt, + ReissueReason = item.ReissueReason, + LastError = item.LastError + }).ToList() + }; + if (process == null) + { + var pendingContracts = await _db.Queryable() + .Where(item => item.TaskId == task.Id) + .OrderBy(item => item.Id) + .ToListAsync(); + result.Documents = pendingContracts.Select((item, index) => + new OnboardingElectronicSignDocumentOutput + { + TaskContractId = item.Id, + DocumentName = item.ContractName, + DocumentSort = index + 1, + OriginalDocxFileId = item.GeneratedDocxFileId ?? 0, + SignedPdfFileId = item.GeneratedPdfFileId + }).ToList(); + return result; + } + + var documents = await _db.Queryable() + .Where(item => item.SignProcessId == process.Id) + .OrderBy(item => item.DocumentSort) + .ToListAsync(); + result.ProcessId = process.Id; + result.ProviderContractId = process.ProviderContractId; + result.BizId = process.BizId; + result.Subject = process.Subject; + result.SignStatus = process.SignStatus; + result.ProviderStatus = process.ProviderStatus; + result.EmployeeName = process.EmployeeName; + result.EmployeeMobile = process.EmployeeMobile; + result.SignUrl = process.SignUrl; + result.SignUrlExpireAt = process.SignUrlExpireAt; + result.SignUrlValid = !string.IsNullOrWhiteSpace(process.SignUrl) + && process.SignUrlExpireAt > DateTime.Now.AddMinutes(1); + result.StartedAt = process.StartedAt; + result.SentAt = process.SentAt; + result.CompletedAt = process.CompletedAt; + result.ArchivedAt = process.ArchivedAt; + result.ArchiveFileId = process.ArchiveFileId; + result.LastError = process.LastError; + if (documents.Count > 0) + { + result.Documents = documents.Select(item => + new OnboardingElectronicSignDocumentOutput + { + Id = item.Id, + TaskContractId = item.TaskContractId, + ProviderDocumentId = item.ProviderDocumentId, + DocumentName = item.DocumentName, + DocumentSort = item.DocumentSort, + OriginalDocxFileId = item.OriginalDocxFileId, + SignedPdfFileId = item.SignedPdfFileId + }).ToList(); + } + else + { + // 草稿创建前失败时尚无电子签文档记录,仍回显任务合同,允许管理员从断点继续重试。 + var pendingContracts = await _db.Queryable() + .Where(item => item.TaskId == task.Id) + .OrderBy(item => item.Id) + .ToListAsync(); + result.Documents = pendingContracts.Select((item, index) => + new OnboardingElectronicSignDocumentOutput + { + TaskContractId = item.Id, + DocumentName = item.ContractName, + DocumentSort = index + 1, + OriginalDocxFileId = item.GeneratedDocxFileId ?? 0, + SignedPdfFileId = item.GeneratedPdfFileId + }).ToList(); + } + return result; + } + + /// + /// 电子签管理沿用入职审核人权限,不新增另一套权限来源。 + /// + private async Task GetAuthorizedTask(long taskId) + { + var task = await _db.Queryable().InSingleAsync(taskId) + ?? throw Oops.Bah("入职任务不存在"); + var config = await _configService.GetByConfigKey( + CateGoryConst.CONFIG_BIZ_DEFINE, + "onboard.reviewer.userIds") + ?? throw Oops.Bah("请配置入职审核人"); + var reviewerIds = OnboardingReviewerConfigParser.Parse( + config.ConfigValue, + task.CompanyId); + if (!reviewerIds.Contains(UserManager.UserId)) + throw Oops.Bah("您不是该入职任务的审核人"); + return task; + } + + private async Task GetLatestProcess(long taskId) + { + return await _db.Queryable() + .Where(item => item.TaskId == taskId && item.IsCurrent == true) + .OrderBy(item => item.StartedAt, OrderByType.Desc) + .FirstAsync(); + } + + private async Task GetRequiredProcess(long taskId) + { + return await GetLatestProcess(taskId) + ?? throw Oops.Bah("该入职任务尚未发起电子签"); + } + + private async Task FindProcess( + string providerContractId, + string bizId) + { + if (!string.IsNullOrWhiteSpace(providerContractId)) + { + var byContract = await _db.Queryable() + .FirstAsync(item => item.ProviderContractId == providerContractId); + if (byContract != null) + return byContract; + } + if (string.IsNullOrWhiteSpace(bizId)) + return null; + return await _db.Queryable() + .FirstAsync(item => item.BizId == bizId); + } + + private async Task ReadFile(long fileId) + { + var file = await _fileService.Download(new BaseIdInput { Id = fileId }) + ?? throw Oops.Bah($"系统文件 {fileId} 不存在"); + await using var stream = file.FileStream; + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + return memory.ToArray(); + } + + private Task CreateSignPageUrl(ObElectronicSignProcess process) + { + return _qiyuesuoClient.GetSignPageUrl(new QiyuesuoSignPageInput + { + ContractId = process.ProviderContractId, + EmployeeMobile = process.EmployeeMobile, + CallbackPage = _setting.SignPageCallbackUrl + }); + } + + private static string ResolveSignStatus( + QiyuesuoContractResult providerContract) + { + var status = (providerContract.Status ?? string.Empty) + .Trim() + .ToUpperInvariant(); + if (status is "COMPLETE" or "COMPLETED" or "FINISHED") + return ElectronicSignStatus.Completed; + if (status is "REJECTED" or "REFUSED") + return ElectronicSignStatus.Rejected; + if (status is "INVALID" or "INVALIDING" or "INVALIDED" or "RECALLED") + return ElectronicSignStatus.Invalid; + if (status == "EXPIRED") + return ElectronicSignStatus.Expired; + if (status == "DRAFT") + return ElectronicSignStatus.Draft; + + var personal = providerContract.Signatories.FirstOrDefault(item => + string.Equals(item.TenantType, "PERSONAL", StringComparison.OrdinalIgnoreCase)); + var employeeSigned = string.Equals( + personal?.Status, + "SIGNED", + StringComparison.OrdinalIgnoreCase); + return employeeSigned + ? ElectronicSignStatus.PendingCompanySeal + : ElectronicSignStatus.PendingEmployeeSign; + } + + private static bool IsTerminal(string status) + { + return status == ElectronicSignStatus.Completed + || status == ElectronicSignStatus.Archived + || status == ElectronicSignStatus.Rejected + || status == ElectronicSignStatus.Invalid + || status == ElectronicSignStatus.Expired; + } + + private static string GetContractId(QiyuesuoContractResult result) + { + var id = result?.Id ?? result?.ContractId; + return !string.IsNullOrWhiteSpace(id) + ? id + : throw Oops.Bah("契约锁创建草稿后未返回合同流程 Id"); + } + + private static string GetDocumentId(QiyuesuoDocumentResult result) + { + var id = result?.Id ?? result?.DocumentId; + return !string.IsNullOrWhiteSpace(id) + ? id + : throw Oops.Bah("契约锁上传文档后未返回文档 Id"); + } + + private static string GetSignatoryId(QiyuesuoSignatoryResult result) + { + var id = result?.Id ?? result?.SignatoryId; + return !string.IsNullOrWhiteSpace(id) + ? id + : throw Oops.Bah("契约锁未返回员工签署方 Id"); + } + + private static string GetActionId(QiyuesuoActionResult result) + { + var id = result?.Id ?? result?.ActionId; + return !string.IsNullOrWhiteSpace(id) + ? id + : throw Oops.Bah("契约锁未返回企业盖章动作 Id"); + } + + /// + /// 校验正式员工主档与审核通过的入职登记资料是否属于同一实名人员。 + /// 原始登记资料用于审计追溯,不允许在电子签发起时静默覆盖。 + /// + private static void ValidateSignerIdentity( + ObEmployee employee, + ObTaskEmployeeInfo employeeInfo) + { + var differentFields = new List(); + if (!string.Equals( + NormalizeIdentityValue(employee.EmployeeName), + NormalizeIdentityValue(employeeInfo.EmployeeName), + StringComparison.Ordinal)) + { + differentFields.Add("姓名"); + } + if (!string.Equals( + NormalizeIdentityValue(employee.Phone), + NormalizeIdentityValue(employeeInfo.Phone), + StringComparison.Ordinal)) + { + differentFields.Add("手机号"); + } + if (!string.Equals( + NormalizeIdentityValue(employee.IdCardNumber).ToUpperInvariant(), + NormalizeIdentityValue(employeeInfo.IdCardNumber).ToUpperInvariant(), + StringComparison.Ordinal)) + { + differentFields.Add("身份证号"); + } + + if (differentFields.Count > 0) + throw Oops.Bah($"正式员工档案的{string.Join("、", differentFields)}与审核通过的入职登记资料不一致,请先核对并修正后再发起电子签"); + } + + private static string NormalizeIdentityValue(string value) + { + return value?.Trim() ?? string.Empty; + } + + /// + /// 判断当前流程能否在保留历史记录的前提下重新创建。 + /// + private static bool CanCreateReplacementProcess( + ObElectronicSignProcess process) + { + if (process.SignStatus == ElectronicSignStatus.Invalid + || process.SignStatus == ElectronicSignStatus.Rejected + || process.SignStatus == ElectronicSignStatus.Expired) + { + return true; + } + + return process.SignStatus == ElectronicSignStatus.Failed + && string.IsNullOrWhiteSpace(process.ProviderContractId); + } + + /// + /// 拦截可能造成重复收费或破坏已完成档案的重新发起操作。 + /// + private static void ValidateProcessCanReinitiate( + ObElectronicSignProcess process) + { + if (CanCreateReplacementProcess(process)) + return; + + if (process.SignStatus == ElectronicSignStatus.Completed + || process.SignStatus == ElectronicSignStatus.Archived) + { + throw Oops.Bah("已经签署完成的电子签流程不能重新发起"); + } + if (process.SignStatus == ElectronicSignStatus.Failed + && !string.IsNullOrWhiteSpace(process.ProviderContractId)) + { + throw Oops.Bah("当前失败流程已经在契约锁创建,请先重试或同步状态;仍需重开时请先作废原流程"); + } + + throw Oops.Bah("当前电子签流程仍在进行中,请先作废原流程后再重新发起"); + } + + private static void ValidateTaskCanSign(ObOnboardingTask task) + { + if (!task.EmployeeId.HasValue) + throw Oops.Bah("入职任务尚未生成正式员工档案,不能发起电子签"); + if (task.TaskStatus != OnboardingTaskStatus.Approved + && task.TaskStatus != OnboardingTaskStatus.Archived) + { + throw Oops.Bah("仅审核通过或已归档的入职任务可以发起电子签"); + } + } + + private void ValidateSettings() + { + if (!_setting.Enabled) + throw Oops.Bah("契约锁电子签功能尚未启用"); + if (string.IsNullOrWhiteSpace(_setting.AppToken)) + throw Oops.Bah("请先配置契约锁 AppToken"); + if (string.IsNullOrWhiteSpace(_setting.AppSecret)) + throw Oops.Bah("请先配置契约锁 AppSecret"); + if (string.IsNullOrWhiteSpace(_setting.CategoryId)) + throw Oops.Bah("请先配置契约锁默认业务分类 Id"); + if (string.IsNullOrWhiteSpace(_setting.SealId)) + throw Oops.Bah("请先配置契约锁企业印章 Id"); + if (string.IsNullOrWhiteSpace(_setting.CreatorName) + || string.IsNullOrWhiteSpace(_setting.CreatorMobile)) + { + throw Oops.Bah("请配置契约锁企业成员姓名和手机号"); + } + if (string.IsNullOrWhiteSpace(_setting.EmployeeSignKeyword) + || string.IsNullOrWhiteSpace(_setting.CompanySealKeyword)) + { + throw Oops.Bah("请配置员工签名和企业盖章定位关键字"); + } + } + + private static void EnsureCanOpenEmployeeSignPage( + ObElectronicSignProcess process) + { + if (process.SignStatus != ElectronicSignStatus.PendingEmployeeSign) + throw Oops.Bah("当前流程不在员工待签状态,不能获取员工签署链接"); + } + + private int NormalizeSignUrlExpireMinutes() + { + // 契约锁签署页面地址最长有效 10 分钟,本地不能继续返回已经失效的缓存链接。 + return Math.Clamp(_setting.SignUrlExpireMinutes, 5, 10); + } + + private static string ResolveCannotInitiateReason( + ObOnboardingTask task, + bool taskAllowed, + bool processAllowed) + { + if (!task.EmployeeId.HasValue) + return "审核通过并生成正式员工档案后才可发起电子签"; + if (!taskAllowed) + return "当前入职任务状态不能发起电子签"; + if (!processAllowed) + return "该任务已经存在电子签流程"; + return null; + } + + private static string ResolveCannotReinitiateReason( + ObOnboardingTask task, + ObElectronicSignProcess process, + bool taskAllowed, + bool reinitiateAllowed) + { + if (!task.EmployeeId.HasValue) + return "审核通过并生成正式员工档案后才可重新发起电子签"; + if (!taskAllowed) + return "当前入职任务状态不能重新发起电子签"; + if (process == null) + return "该任务尚未发起电子签"; + if (reinitiateAllowed) + return null; + if (process.SignStatus == ElectronicSignStatus.Completed + || process.SignStatus == ElectronicSignStatus.Archived) + { + return "已经签署完成的流程不能重新发起"; + } + if (process.SignStatus == ElectronicSignStatus.Failed + && !string.IsNullOrWhiteSpace(process.ProviderContractId)) + { + return "当前失败流程已在契约锁创建,请先重试或同步状态"; + } + + return "当前流程仍在进行中,请先作废后再重新发起"; + } + + /// + /// 在未知回调层级中递归读取常见字段,兼容契约锁不同事件的数据包装。 + /// + private static string FindString( + JsonElement element, + params string[] propertyNames) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + if (propertyNames.Any(name => string.Equals( + name, + property.Name, + StringComparison.OrdinalIgnoreCase)) + && property.Value.ValueKind != JsonValueKind.Null) + { + return property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : property.Value.ToString(); + } + } + foreach (var property in element.EnumerateObject()) + { + var nested = FindString(property.Value, propertyNames); + if (!string.IsNullOrWhiteSpace(nested)) + return nested; + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + var nested = FindString(item, propertyNames); + if (!string.IsNullOrWhiteSpace(nested)) + return nested; + } + } + return null; + } + + private static string SafeFileName(string value) + { + var source = string.IsNullOrWhiteSpace(value) ? "合同" : value.Trim(); + var invalidChars = Path.GetInvalidFileNameChars(); + return new string(source + .Select(character => invalidChars.Contains(character) ? '_' : character) + .ToArray()); + } + + private static string LimitError(string message, int maxLength = 1800) + { + var normalized = string.IsNullOrWhiteSpace(message) + ? "未知错误" + : message.Trim(); + return normalized.Length <= maxLength + ? normalized + : normalized[..maxLength]; + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/IQiyuesuoClient.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/IQiyuesuoClient.cs new file mode 100644 index 0000000..8d475e1 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/IQiyuesuoClient.cs @@ -0,0 +1,56 @@ +namespace SimpleAdmin.Application; + +/// +/// 定义契约锁开放平台所需的最小调用能力。 +/// +public interface IQiyuesuoClient : ITransient +{ + /// + /// 创建包含个人签署方和企业自动盖章动作的合同草稿。 + /// + Task CreateDraft( + QiyuesuoCreateDraftInput input); + + /// + /// 向合同草稿中上传一份 DOCX 文件。 + /// + Task AddDocument( + QiyuesuoAddDocumentInput input); + + /// + /// 发起合同并为每份文档指定员工签名与企业盖章位置。 + /// + Task Send( + QiyuesuoSendContractInput input); + + /// + /// 获取员工签署页面地址。 + /// + Task GetSignPageUrl( + QiyuesuoSignPageInput input); + + /// + /// 查询契约锁合同详情。 + /// + Task GetContractDetail(string contractId); + + /// + /// 催办当前待签署人。 + /// + Task Notice(string contractId); + + /// + /// 作废尚未完成的合同流程。 + /// + Task Invalid(string contractId, string reason); + + /// + /// 下载单份签署完成的 PDF 文档。 + /// + Task DownloadDocument(string documentId); + + /// + /// 下载合同原文、签署文件与签署日志组成的完整归档 ZIP。 + /// + Task DownloadArchive(string contractId); +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs new file mode 100644 index 0000000..1839e30 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs @@ -0,0 +1,692 @@ +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; + +/// +/// 直接调用契约锁开放平台,不依赖旧版 .NET Framework SDK。 +/// +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( + "QiyuesuoSetting", + true) + ?? new QiyuesuoSettingOptions(); + } + + /// + public async Task CreateDraft( + QiyuesuoCreateDraftInput input) + { + if (!long.TryParse(input.SealId, out var sealId)) + throw Oops.Bah("契约锁 SealId 配置格式不正确"); + + var body = new + { + bizId = input.BizId, + subject = input.Subject, + tenantName = input.CompanyName, + description = "OnboardX 入职电子签", + send = false, + callbackUrl = NullIfBlank(input.CallbackUrl), + category = new + { + id = input.CategoryId + }, + creator = new + { + name = input.CreatorName, + contact = input.CreatorMobile, + contactType = "MOBILE" + }, + signatories = new object[] + { + new + { + tenantType = "PERSONAL", + tenantName = input.EmployeeName, + receiver = new + { + name = input.EmployeeName, + contact = input.EmployeeMobile, + contactType = "MOBILE" + }, + serialNo = 1, + signatoryNo = "employee", + actions = new object[] + { + new + { + type = "PERSONAL", + name = "员工本人签署", + serialNo = 0, + actionNo = "employee_sign" + } + } + }, + new + { + tenantType = "COMPANY", + tenantName = input.CompanyName, + receiver = new + { + name = input.CreatorName, + contact = input.CreatorMobile, + contactType = "MOBILE" + }, + serialNo = 2, + signatoryNo = "company", + actions = new object[] + { + new + { + type = "COMPANY", + name = "企业自动盖章", + serialNo = 0, + autoSign = true, + sealId = input.SealId, + corpSealIds = new[] { sealId }, + actionNo = "company_seal" + } + } + } + } + }; + + return await PostJson( + "/v2/contract/draft", + body); + } + + /// + public async Task AddDocument( + QiyuesuoAddDocumentInput input) + { + var fields = new Dictionary + { + ["contractId"] = input.ContractId, + ["bizId"] = input.BizId, + ["title"] = input.Title, + ["fileSuffix"] = "docx", + ["documentSort"] = input.DocumentSort.ToString( + CultureInfo.InvariantCulture) + }; + return await PostFile( + "/v2/document/addbyfile", + fields, + input.FileBytes, + input.FileName); + } + + /// + public Task Send( + QiyuesuoSendContractInput input) + { + return PostJson( + "/v2/contract/send", + new + { + contractId = input.ContractId, + bizId = input.BizId, + tenantName = input.CompanyName, + locateAllStamperKeywords = false, + stampers = input.Stampers + }); + } + + /// + public async Task 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("契约锁未返回员工签署页面地址"); + } + + /// + public Task GetContractDetail(string contractId) + { + return GetJson( + "/v2/contract/detail", + new Dictionary + { + ["contractId"] = contractId, + ["queryActionOperate"] = "true", + ["queryActionOperator"] = "true", + ["queryLocation"] = "true" + }); + } + + /// + public async Task Notice(string contractId) + { + await PostJsonElement( + "/v2/contract/notice", + new + { + contractId, + onlySigning = true + }); + } + + /// + public async Task Invalid(string contractId, string reason) + { + await PostJsonElement( + "/v2/contract/invalid", + new + { + contractId, + sealId = _setting.SealId, + reason, + deleteDoc = false, + autoSign = true + }); + } + + /// + public Task DownloadDocument(string documentId) + { + return Download( + "/v2/document/download", + new Dictionary + { + ["documentId"] = documentId + }); + } + + /// + public Task DownloadArchive(string contractId) + { + return Download( + "/v2/contract/download", + new Dictionary + { + ["contractId"] = contractId, + ["downloadItems"] = "CONTRACT,SIGNLOG", + ["needCompressForOneFile"] = "true" + }); + } + + private async Task PostJson(string path, object body) + { + var result = await PostJsonElement(path, body); + return result.Deserialize(JsonOptions) + ?? throw Oops.Bah("契约锁返回数据为空"); + } + + private async Task 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 PostFile( + string path, + Dictionary 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(JsonOptions) + ?? throw Oops.Bah("契约锁返回数据为空"); + } + + private async Task GetJson( + string path, + Dictionary query) + { + ValidateSettings(); + using var request = new HttpRequestMessage( + HttpMethod.Get, + BuildUrl(path, query)); + AddAuthorizationHeaders( + request, + path, + HttpMethod.Get, + query); + var result = await SendForJsonResult(request); + return result.Deserialize(JsonOptions) + ?? throw Oops.Bah("契约锁返回数据为空"); + } + + private async Task Download( + string path, + Dictionary 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 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 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 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 parameters, + byte[] fileBytes, + string jsonBody) + { + var values = new List> + { + new(AccessTokenHeader, _setting.AppToken.Trim()), + new(TimestampHeader, timestamp), + new(NonceHeader, nonce), + new(SignatureVersionHeader, SignatureHmacSha256) + }; + if (!string.IsNullOrWhiteSpace(jsonBody)) + values.Add(new KeyValuePair( + 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( + "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 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(); + } + + /// + /// 构造兼容契约锁长整型 Id 和字符串 Id 两种返回形式的 JSON 配置。 + /// + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + options.Converters.Add(new FlexibleStringJsonConverter()); + return options; + } + + /// + /// 将第三方响应中的字符串或数字统一读取为字符串,避免长整型 Id 精度丢失。 + /// + private sealed class FlexibleStringJsonConverter : JsonConverter + { + 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); + } + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs new file mode 100644 index 0000000..9a28302 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs @@ -0,0 +1,214 @@ +namespace SimpleAdmin.Application; + +/// +/// 创建契约锁合同草稿所需参数。 +/// +public class QiyuesuoCreateDraftInput +{ + /// 本系统幂等业务编号。 + public string BizId { get; set; } + + /// 合同流程标题。 + public string Subject { get; set; } + + /// 签署企业法定名称。 + public string CompanyName { get; set; } + + /// 员工姓名。 + public string EmployeeName { get; set; } + + /// 员工手机号。 + public string EmployeeMobile { get; set; } + + /// 契约锁业务分类 Id。 + public string CategoryId { get; set; } + + /// 契约锁企业印章 Id。 + public string SealId { get; set; } + + /// 契约锁企业成员姓名。 + public string CreatorName { get; set; } + + /// 契约锁企业成员手机号。 + public string CreatorMobile { get; set; } + + /// 契约锁服务器回调地址。 + public string CallbackUrl { get; set; } +} + +/// +/// 上传契约锁合同文档所需参数。 +/// +public class QiyuesuoAddDocumentInput +{ + /// 契约锁合同流程 Id。 + public string ContractId { get; set; } + + /// 本系统幂等业务编号。 + public string BizId { get; set; } + + /// 文档标题。 + public string Title { get; set; } + + /// 文档排序。 + public int DocumentSort { get; set; } + + /// 上传文件名。 + public string FileName { get; set; } + + /// DOCX 文件内容。 + public byte[] FileBytes { get; set; } +} + +/// +/// 契约锁签章位置。 +/// +public class QiyuesuoStamperInput +{ + /// 企业签署动作 Id。 + public string ActionId { get; set; } + + /// 个人签署方 Id。 + public string SignatoryId { get; set; } + + /// 签章类型。 + public string Type { get; set; } + + /// 契约锁文档 Id。 + public string DocumentId { get; set; } + + /// 企业印章 Id。 + public string SealId { get; set; } + + /// 签署位置关键字。 + public string Keyword { get; set; } + + /// 同名关键字的序号,从 1 开始。 + public int KeywordIndex { get; set; } = 1; +} + +/// +/// 发起契约锁合同流程所需参数。 +/// +public class QiyuesuoSendContractInput +{ + /// 契约锁合同流程 Id。 + public string ContractId { get; set; } + + /// 本系统幂等业务编号。 + public string BizId { get; set; } + + /// 契约锁合同发起企业名称。 + public string CompanyName { get; set; } + + /// 所有文档的签名和盖章位置。 + public List Stampers { get; set; } = new(); +} + +/// +/// 获取契约锁员工签署页所需参数。 +/// +public class QiyuesuoSignPageInput +{ + /// 契约锁合同流程 Id。 + public string ContractId { get; set; } + + /// 员工手机号。 + public string EmployeeMobile { get; set; } + + /// 签署完成后的前端返回地址。 + public string CallbackPage { get; set; } +} + +/// +/// 契约锁合同返回对象。 +/// +public class QiyuesuoContractResult +{ + /// 契约锁合同流程 Id。 + public string Id { get; set; } + + /// 兼容部分接口返回的合同流程 Id 字段名。 + public string ContractId { get; set; } + + /// 本系统幂等业务编号。 + public string BizId { get; set; } + + /// 流程标题。 + public string Subject { get; set; } + + /// 契约锁合同状态。 + public string Status { get; set; } + + /// 签署方列表。 + public List Signatories { get; set; } = new(); + + /// 合同文档列表。 + public List Documents { get; set; } = new(); +} + +/// +/// 契约锁签署方返回对象。 +/// +public class QiyuesuoSignatoryResult +{ + /// 签署方 Id。 + public string Id { get; set; } + + /// 兼容部分接口返回的签署方 Id 字段名。 + public string SignatoryId { get; set; } + + /// 签署方类型。 + public string TenantType { get; set; } + + /// 签署方名称。 + public string TenantName { get; set; } + + /// 签署方状态。 + public string Status { get; set; } + + /// 签署顺序。 + public int? SerialNo { get; set; } + + /// 企业签署动作。 + public List Actions { get; set; } = new(); +} + +/// +/// 契约锁企业签署动作返回对象。 +/// +public class QiyuesuoActionResult +{ + /// 签署动作 Id。 + public string Id { get; set; } + + /// 兼容部分接口返回的签署动作 Id 字段名。 + public string ActionId { get; set; } + + /// 签署动作类型。 + public string Type { get; set; } + + /// 签署动作状态。 + public string Status { get; set; } + + /// 动作执行顺序。 + public int? SerialNo { get; set; } +} + +/// +/// 契约锁合同文档返回对象。 +/// +public class QiyuesuoDocumentResult +{ + /// 契约锁文档 Id。 + public string Id { get; set; } + + /// 兼容部分接口返回的文档 Id 字段名。 + public string DocumentId { get; set; } + + /// 文档标题。 + public string Title { get; set; } + + /// 文档页数。 + public int? PageCount { get; set; } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs b/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs index c0f97d6..54c2d04 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Employee/Dto/OnboardingEmployeeInput.cs @@ -56,6 +56,17 @@ public class OnboardingEmployeeDetailOutput /// public List Contracts { get; set; } = new List(); + /// + /// 员工入职时上传的身份证、学历证明、体检报告等资料。 + /// + public List Materials { get; set; } + = new List(); + + /// + /// 契约锁签署文件与签署日志组成的完整归档 ZIP 文件 Id。 + /// + public long? ElectronicSignArchiveFileId { get; set; } + /// /// 提交与审核记录列表。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs b/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs index dcd0d39..8c0661f 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Employee/OnboardingEmployeeService.cs @@ -67,6 +67,13 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService : await _db.Queryable() .Where(item => item.TaskId == task.Id) .ToListAsync(); + var materials = await GetEmployeeMaterials(task?.Id); + var electronicSignProcess = task == null + ? null + : await _db.Queryable() + .Where(item => item.TaskId == task.Id && item.ArchiveFileId.HasValue) + .OrderByDescending(item => item.Id) + .FirstAsync(); var audits = task == null ? new List() : await _db.Queryable() @@ -80,10 +87,60 @@ public class OnboardingEmployeeService : IOnboardingEmployeeService Task = task, EmployeeInfo = employeeInfo, Contracts = contracts, + Materials = materials, + ElectronicSignArchiveFileId = electronicSignProcess?.ArchiveFileId, Audits = audits }; } + /// + /// 查询员工来源任务中上传的全部材料,并补充原始文件名、后缀和文件大小。 + /// + private async Task> GetEmployeeMaterials(long? taskId) + { + if (!taskId.HasValue) + return new List(); + + var materials = await _db.Queryable() + .Where(item => item.TaskId == taskId.Value) + .ToListAsync(); + if (materials.Count == 0) + return new List(); + + var fileIds = materials + .Select(item => item.FileId) + .Distinct() + .ToList(); + var files = await _db.Queryable() + .Where(item => fileIds.Contains(item.Id)) + .ToListAsync(); + var fileMap = files.ToDictionary(item => item.Id); + + var requirements = await _db.Queryable() + .Where(item => item.TaskId == taskId.Value) + .ToListAsync(); + var sortMap = requirements + .GroupBy(item => item.MaterialTypeId) + .ToDictionary(group => group.Key, group => group.Min(item => item.SortCode)); + + return materials + .Select(material => + { + var output = material.Adapt(); + if (fileMap.TryGetValue(material.FileId, out var file)) + { + output.FileName = file.Name; + output.FileSuffix = file.Suffix; + output.FileSizeInfo = file.SizeInfo; + } + + return output; + }) + .OrderBy(item => sortMap.GetValueOrDefault(item.MaterialTypeId, int.MaxValue)) + .ThenBy(item => item.UploadedAt) + .ToList(); + } + /// /// 批量补充员工所属公司、部门和岗位名称,避免列表逐行查询数据库。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs index cb4665b..13b3007 100644 --- a/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/OnboardingTemplate/OnboardingTemplateService.cs @@ -299,7 +299,10 @@ public class OnboardingTemplateService : IOnboardingTemplateService if (input.Status == "enable") { - if (contractTemplates.Any(item => item.Status != "enable")) + if (contractTemplates.Any(item => !string.Equals( + item.Status, + "enable", + global::System.StringComparison.OrdinalIgnoreCase))) throw Oops.Bah("启用入职模板前,请先启用全部合同模板"); if (versions.Any(item => item.VersionStatus != ContractTemplateVersionStatus.Enabled)) throw Oops.Bah("启用入职模板前,请先启用全部合同版本"); diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs index 7112479..6cc99df 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs @@ -75,7 +75,10 @@ public class OnboardingTaskService : IOnboardingTaskService if (contractTemplates.Count != templateIds.Count || contractTemplates.Any(item => item.CompanyId != input.CompanyId || item.DepartmentId != input.DepartmentId - || item.Status != "enable")) + || !string.Equals( + item.Status, + "enable", + global::System.StringComparison.OrdinalIgnoreCase))) { throw Oops.Bah("入职模板包含未启用或不属于当前公司、部门的合同模板"); } diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs index fd00f16..d8ef946 100644 --- a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/Dto/OnboardingTaskQueryInput.cs @@ -134,4 +134,9 @@ public class OnboardingTaskMaterialDetailOutput : ObTaskMaterial /// 材料文件后缀,用于判断是否支持图片预览。 /// public string FileSuffix { get; set; } + + /// + /// 格式化后的文件大小,例如 1.5MB。 + /// + public string FileSizeInfo { get; set; } } \ No newline at end of file diff --git a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs index b63fbdc..792cd14 100644 --- a/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/TaskQuery/OnboardingTaskQueryService.cs @@ -124,6 +124,7 @@ public class OnboardingTaskQueryService : IOnboardingTaskQueryService { output.FileName = file.Name; output.FileSuffix = file.Suffix; + output.FileSizeInfo = file.SizeInfo; } return output; diff --git a/SimpleAdmin.System/Services/Dev/File/FileService.cs b/SimpleAdmin.System/Services/Dev/File/FileService.cs index 02af6e4..1bffd96 100644 --- a/SimpleAdmin.System/Services/Dev/File/FileService.cs +++ b/SimpleAdmin.System/Services/Dev/File/FileService.cs @@ -115,10 +115,26 @@ public class FileService : DbRepository, IFileService IFormFile file = new FormFile(stream, 0, content.Length, "file", fileName) { Headers = new HeaderDictionary(), - ContentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ContentType = ResolveGeneratedContentType(fileName) }; return await StorageFile(engine, file); } + + /// + /// 根据归档文件扩展名设置 OSS 元数据,避免 PDF、ZIP 被错误标记为 DOCX。 + /// + 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 方法 /// diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractDocumentController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractDocumentController.cs index a88c4ce..4e03210 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractDocumentController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractDocumentController.cs @@ -19,7 +19,7 @@ public class OnboardingContractDocumentController } /// - /// 创建入职任务前,按人事当前填写的参数下载 DOCX 合同预览。 + /// 创建入职任务前,按人事当前填写的参数下载指定 DOCX 合同预览。 /// [HttpPost("preview-before-create")] [DisplayName("创建入职任务前预览合同")] @@ -35,6 +35,21 @@ public class OnboardingContractDocumentController }; } + /// + /// 创建入职任务前,将全部 DOCX 合同预览打包下载。 + /// + [HttpPost("preview-all-before-create")] + [DisplayName("创建入职任务前预览全部合同")] + public async Task PreviewAllBeforeCreate( + [FromBody] OnboardingContractPreviewInput input) + { + var preview = await _service.PreviewAllBeforeCreate(input); + return new FileContentResult(preview.FileBytes, "application/zip") + { + FileDownloadName = preview.FileName + }; + } + /// /// 生成入职合同文件 /// diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignCallbackController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignCallbackController.cs new file mode 100644 index 0000000..3782648 --- /dev/null +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignCallbackController.cs @@ -0,0 +1,80 @@ +using System.Text; + +namespace SimpleAdmin.Web.Core; + +/// +/// 接收契约锁服务器状态通知;第一版按平台要求使用明文回调。 +/// +[ApiDescriptionSettings("Application", Tag = "入职电子签回调")] +[Route("open/onboarding/qiyuesuo")] +[AllowAnonymous] +public class OnboardingElectronicSignCallbackController +{ + private readonly IOnboardingElectronicSignService _service; + private readonly IHttpContextAccessor _httpContextAccessor; + + public OnboardingElectronicSignCallbackController( + IOnboardingElectronicSignService service, + IHttpContextAccessor httpContextAccessor) + { + _service = service; + _httpContextAccessor = httpContextAccessor; + } + + /// + /// 接收契约锁回调并返回平台可识别的非统一响应。 + /// + [HttpPost("callback")] + [NonUnify] + public async Task Callback() + { + try + { + var request = _httpContextAccessor.HttpContext?.Request + ?? throw new InvalidOperationException("当前请求上下文不存在"); + var payloadJson = await ReadPayload(request); + await _service.HandleCallback(payloadJson); + return new OkObjectResult(new + { + code = 0, + message = "success" + }); + } + catch (global::System.Exception exception) + { + // 契约锁按 HTTP 状态码判断回调是否成功;返回 500 后平台会继续重试。 + return new ObjectResult(new + { + code = -1, + message = exception.Message + }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + } + + /// + /// 契约锁明文回调使用表单格式;同时兼容测试工具直接发送 JSON。 + /// + private static async Task ReadPayload(HttpRequest request) + { + if (request.HasFormContentType) + { + var form = await request.ReadFormAsync(); + var values = form + .OrderBy(item => item.Key, StringComparer.Ordinal) + .ToDictionary( + item => item.Key, + item => item.Value.ToString()); + return global::System.Text.Json.JsonSerializer.Serialize(values); + } + + using var reader = new StreamReader( + request.Body, + Encoding.UTF8, + true, + leaveOpen: true); + return await reader.ReadToEndAsync(); + } +} diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignController.cs new file mode 100644 index 0000000..c1151f8 --- /dev/null +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignController.cs @@ -0,0 +1,97 @@ +using System.ComponentModel; + +namespace SimpleAdmin.Web.Core; + +/// +/// 提供入职任务电子签管理接口。 +/// +[ApiDescriptionSettings("Application", Tag = "入职电子签约")] +[Route("biz/onboarding/electronic-sign")] +[RolePermission] +public class OnboardingElectronicSignController +{ + private readonly IOnboardingElectronicSignService _service; + + public OnboardingElectronicSignController( + IOnboardingElectronicSignService service) + { + _service = service; + } + + /// + /// 查询入职任务的电子签状态和合同文件。 + /// + [HttpGet("detail")] + [DisplayName("查看入职电子签详情")] + public async Task Detail( + [FromQuery] BaseIdInput input) + { + return await _service.Detail(input); + } + + /// + /// 手动发起或继续上次失败的契约锁流程。 + /// + [HttpPost("initiate")] + [DisplayName("发起入职电子签")] + public async Task Initiate( + [FromBody] OnboardingElectronicSignTaskInput input) + { + return await _service.Initiate(input); + } + + /// + /// 保留原流程记录并创建一次全新的契约锁签约流程。 + /// + [HttpPost("reinitiate")] + [DisplayName("重新发起入职电子签")] + public async Task Reinitiate( + [FromBody] OnboardingElectronicSignReinitiateInput input) + { + return await _service.Reinitiate(input); + } + + /// + /// 主动同步契约锁状态,完成后自动归档。 + /// + [HttpPost("sync")] + [DisplayName("同步入职电子签状态")] + public async Task Sync( + [FromBody] OnboardingElectronicSignTaskInput input) + { + return await _service.Sync(input); + } + + /// + /// 获取仍然有效的员工签署地址,过期则重新生成。 + /// + [HttpPost("get-link")] + [DisplayName("获取员工电子签链接")] + public async Task GetSignLink( + [FromBody] OnboardingElectronicSignTaskInput input) + { + return await _service.GetSignLink(input); + } + + /// + /// 催办当前待签员工。 + /// + [HttpPost("notice")] + [DisplayName("催办员工电子签")] + public async Task Notice( + [FromBody] OnboardingElectronicSignTaskInput input) + { + await _service.Notice(input); + } + + /// + /// 作废尚未结束的电子签流程。 + /// + [HttpPost("invalid")] + [DisplayName("作废入职电子签")] + public async Task Invalid( + [FromBody] OnboardingElectronicSignInvalidInput input) + { + await _service.Invalid(input); + } +} diff --git a/database/migrations/20260903_qiyuesuo_electronic_sign.sql b/database/migrations/20260903_qiyuesuo_electronic_sign.sql new file mode 100644 index 0000000..06c1073 --- /dev/null +++ b/database/migrations/20260903_qiyuesuo_electronic_sign.sql @@ -0,0 +1,110 @@ +-- OnboardX 契约锁电子签流程表 +-- 适用数据库:MySQL 8.0+ +-- 说明: +-- 1. 一条电子签流程可以包含同一入职任务下的多份合同。 +-- 2. ProviderContractId、ProviderDocumentId 一律使用 VARCHAR,避免第三方 19 位 Id 在前端精度丢失。 +-- 3. 回调事件通过 PayloadHash 幂等处理,可安全接收契约锁重复通知。 + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `ob_electronic_sign_process` ( + `Id` BIGINT NOT NULL COMMENT '主键', + `TaskId` BIGINT NOT NULL COMMENT '入职任务Id', + `EmployeeId` BIGINT NOT NULL COMMENT '正式员工Id', + `Provider` VARCHAR(30) NOT NULL DEFAULT 'qiyuesuo' COMMENT '电子签服务商', + `BizId` VARCHAR(100) NOT NULL COMMENT '本系统幂等业务编号', + `IsCurrent` TINYINT(1) NULL DEFAULT 1 COMMENT '是否为任务当前电子签流程,历史流程为空', + `ProviderContractId` VARCHAR(100) NULL COMMENT '契约锁合同流程Id', + `CategoryId` VARCHAR(100) NOT NULL COMMENT '契约锁业务分类Id快照', + `SealId` VARCHAR(100) NOT NULL COMMENT '契约锁印章Id快照', + `Subject` VARCHAR(300) NOT NULL COMMENT '电子签流程标题', + `EmployeeName` VARCHAR(100) NOT NULL COMMENT '员工姓名快照', + `EmployeeMobile` VARCHAR(30) NOT NULL COMMENT '员工手机号快照', + `EmployeeIdCardNumber` VARCHAR(30) NULL COMMENT '员工身份证号码快照', + `CreatorName` VARCHAR(100) NOT NULL COMMENT '创建人姓名快照', + `CreatorMobile` VARCHAR(30) NOT NULL COMMENT '创建人手机号快照', + `SignStatus` VARCHAR(40) NOT NULL COMMENT '本系统电子签状态', + `ProviderStatus` VARCHAR(60) NULL COMMENT '契约锁原始合同状态', + `SignUrl` LONGTEXT NULL COMMENT '员工签署页面地址', + `SignUrlExpireAt` DATETIME(3) NULL COMMENT '签署页面地址到期时间', + `StartedAt` DATETIME(3) NOT NULL COMMENT '管理员发起时间', + `SentAt` DATETIME(3) NULL COMMENT '合同发送时间', + `CompletedAt` DATETIME(3) NULL COMMENT '全部签署完成时间', + `ArchivedAt` DATETIME(3) NULL COMMENT '归档完成时间', + `ArchiveFileId` BIGINT NULL COMMENT '完整归档ZIP文件Id', + `LastSyncedAt` DATETIME(3) NULL COMMENT '最后主动同步时间', + `LastCallbackAt` DATETIME(3) NULL COMMENT '最后回调时间', + `LastError` LONGTEXT NULL COMMENT '最近一次失败原因', + `RetryCount` INT NOT NULL DEFAULT 0 COMMENT '失败重试次数', + `ReissueReason` VARCHAR(500) NULL COMMENT '重新发起原因', + `ReissuedAt` DATETIME(3) NULL COMMENT '被新流程替代时间', + `ReissuedByProcessId` BIGINT NULL COMMENT '替代当前流程的新流程Id', + `ExtJson` LONGTEXT NULL COMMENT '扩展信息', + `Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态', + `CreateTime` DATETIME(3) NULL COMMENT '创建时间', + `UpdateTime` DATETIME(3) NULL COMMENT '更新时间', + `CreateUserId` BIGINT NULL COMMENT '创建者Id', + `UpdateUserId` BIGINT NULL COMMENT '修改者Id', + `CreateUser` VARCHAR(200) NULL COMMENT '创建人', + `UpdateUser` VARCHAR(200) NULL COMMENT '更新人', + `IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_esign_process_biz` (`BizId`), + UNIQUE KEY `uk_ob_esign_process_current` (`TaskId`, `IsCurrent`), + UNIQUE KEY `uk_ob_esign_process_provider_contract` (`ProviderContractId`), + KEY `idx_ob_esign_process_task` (`TaskId`, `StartedAt`), + KEY `idx_ob_esign_process_status` (`SignStatus`, `UpdateTime`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职电子签流程'; + +CREATE TABLE IF NOT EXISTS `ob_electronic_sign_document` ( + `Id` BIGINT NOT NULL COMMENT '主键', + `SignProcessId` BIGINT NOT NULL COMMENT '电子签流程Id', + `TaskContractId` BIGINT NOT NULL COMMENT '任务合同Id', + `ProviderDocumentId` VARCHAR(100) NOT NULL COMMENT '契约锁文档Id', + `DocumentName` VARCHAR(300) NOT NULL COMMENT '文档名称快照', + `DocumentSort` INT NOT NULL DEFAULT 1 COMMENT '文档顺序', + `OriginalDocxFileId` BIGINT NOT NULL COMMENT '原始DOCX文件Id', + `SignedPdfFileId` BIGINT NULL COMMENT '签署完成PDF文件Id', + `EmployeeSignKeyword` VARCHAR(200) NOT NULL COMMENT '员工签名关键字快照', + `CompanySealKeyword` VARCHAR(200) NOT NULL COMMENT '企业盖章关键字快照', + `EmployeeActionId` VARCHAR(100) NULL COMMENT '员工签署动作Id', + `CompanyActionId` VARCHAR(100) NULL COMMENT '企业盖章动作Id', + `ExtJson` LONGTEXT NULL COMMENT '扩展信息', + `Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态', + `CreateTime` DATETIME(3) NULL COMMENT '创建时间', + `UpdateTime` DATETIME(3) NULL COMMENT '更新时间', + `CreateUserId` BIGINT NULL COMMENT '创建者Id', + `UpdateUserId` BIGINT NULL COMMENT '修改者Id', + `CreateUser` VARCHAR(200) NULL COMMENT '创建人', + `UpdateUser` VARCHAR(200) NULL COMMENT '更新人', + `IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_esign_document_task_contract` (`SignProcessId`, `TaskContractId`), + UNIQUE KEY `uk_ob_esign_document_provider` (`ProviderDocumentId`), + KEY `idx_ob_esign_document_process_sort` (`SignProcessId`, `DocumentSort`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职电子签文档'; + +CREATE TABLE IF NOT EXISTS `ob_electronic_sign_event` ( + `Id` BIGINT NOT NULL COMMENT '主键', + `SignProcessId` BIGINT NULL COMMENT '电子签流程Id', + `PayloadHash` VARCHAR(64) NOT NULL COMMENT '回调内容SHA-256', + `EventSource` VARCHAR(30) NOT NULL COMMENT '事件来源', + `EventType` VARCHAR(100) NULL COMMENT '契约锁事件类型', + `ProviderStatus` VARCHAR(60) NULL COMMENT '契约锁状态', + `PayloadJson` LONGTEXT NULL COMMENT '事件原始内容', + `ProcessResult` VARCHAR(500) NULL COMMENT '处理结果', + `ReceivedAt` DATETIME(3) NOT NULL COMMENT '接收时间', + `ProcessedAt` DATETIME(3) NULL COMMENT '处理完成时间', + `ExtJson` LONGTEXT NULL COMMENT '扩展信息', + `Status` VARCHAR(20) NULL DEFAULT 'enable' COMMENT '启用状态', + `CreateTime` DATETIME(3) NULL COMMENT '创建时间', + `UpdateTime` DATETIME(3) NULL COMMENT '更新时间', + `CreateUserId` BIGINT NULL COMMENT '创建者Id', + `UpdateUserId` BIGINT NULL COMMENT '修改者Id', + `CreateUser` VARCHAR(200) NULL COMMENT '创建人', + `UpdateUser` VARCHAR(200) NULL COMMENT '更新人', + `IsDelete` TINYINT(1) NULL DEFAULT 0 COMMENT '软删除', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_ob_esign_event_payload_hash` (`PayloadHash`), + KEY `idx_ob_esign_event_process` (`SignProcessId`, `ReceivedAt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职电子签事件'; diff --git a/database/migrations/20260904_qiyuesuo_reinitiate.sql b/database/migrations/20260904_qiyuesuo_reinitiate.sql new file mode 100644 index 0000000..71a275e --- /dev/null +++ b/database/migrations/20260904_qiyuesuo_reinitiate.sql @@ -0,0 +1,33 @@ +-- 增加入职电子签“作废后重新发起”能力。 +-- 已执行 20260903_qiyuesuo_electronic_sign.sql 的数据库需要单独执行本文件。 + +SET NAMES utf8mb4; + +ALTER TABLE `ob_electronic_sign_process` + ADD COLUMN `IsCurrent` TINYINT(1) NULL DEFAULT NULL + COMMENT '是否为任务当前电子签流程,历史流程为空' + AFTER `BizId`; + +ALTER TABLE `ob_electronic_sign_process` + ADD COLUMN `ReissueReason` VARCHAR(500) NULL + COMMENT '重新发起原因' + AFTER `RetryCount`, + ADD COLUMN `ReissuedAt` DATETIME(3) NULL + COMMENT '被新流程替代时间' + AFTER `ReissueReason`, + ADD COLUMN `ReissuedByProcessId` BIGINT NULL + COMMENT '替代当前流程的新流程Id' + AFTER `ReissuedAt`; + +-- 雪花 Id 与创建时间同序,每个任务只把最新一条流程标记为当前流程。 +UPDATE `ob_electronic_sign_process` process +INNER JOIN ( + SELECT `TaskId`, MAX(`Id`) AS `LatestProcessId` + FROM `ob_electronic_sign_process` + GROUP BY `TaskId` +) latest ON latest.`LatestProcessId` = process.`Id` +SET process.`IsCurrent` = 1; + +ALTER TABLE `ob_electronic_sign_process` + ADD UNIQUE KEY `uk_ob_esign_process_current` (`TaskId`, `IsCurrent`); + diff --git a/database/migrations/20260904_qiyuesuo_signer_snapshot.sql b/database/migrations/20260904_qiyuesuo_signer_snapshot.sql new file mode 100644 index 0000000..b323deb --- /dev/null +++ b/database/migrations/20260904_qiyuesuo_signer_snapshot.sql @@ -0,0 +1,9 @@ +-- 补充电子签署人实名快照。 +-- 已执行 20260903_qiyuesuo_electronic_sign.sql 的数据库需要单独执行本文件。 + +SET NAMES utf8mb4; + +ALTER TABLE `ob_electronic_sign_process` + ADD COLUMN `EmployeeIdCardNumber` VARCHAR(30) NULL + COMMENT '员工身份证号码快照' + AFTER `EmployeeMobile`; diff --git a/database/scripts/20260907_seed_onboarding_demo_base_data.sql b/database/scripts/20260907_seed_onboarding_demo_base_data.sql new file mode 100644 index 0000000..02d10b7 --- /dev/null +++ b/database/scripts/20260907_seed_onboarding_demo_base_data.sql @@ -0,0 +1,398 @@ +-- ============================================================================ +-- OnboardX 基础业务假数据初始化脚本(MySQL 8.0+) +-- +-- 使用: +-- 1. 先执行 20260828_reset_onboarding_business_data_v2.sql; +-- 2. 再执行本脚本; +-- 3. 发起任务时选择: +-- 重庆示例科技有限公司 / 技术部 / 后端开发工程师 / +-- 技术岗位标准入职模板(测试)。 +-- +-- 特点: +-- - 不删除任何数据; +-- - 不覆盖保留的 ob_variable_definition 和 ob_material_type; +-- - 只生成基础配置,不生成任务、员工或电子签约流程; +-- - 固定业务 Id + ON DUPLICATE KEY UPDATE,可重复执行。 +-- +-- 合同源文件默认自动选择 sys_file 中最近的 DOCX,并优先排除 OB20 开头的 +-- 已生成任务合同。若自动选择不对,把 NULL 改为正确的源 DOCX 文件 Id。 +-- 例如:SET @ob_seed_source_docx_file_id := 842701790859333; +-- ============================================================================ + +USE `OnboardX`; +SET NAMES utf8mb4; +SET @ob_seed_source_docx_file_id := NULL; + +SET @ob_seed_source_docx_file_id := COALESCE( + @ob_seed_source_docx_file_id, + ( + SELECT file.`Id` + FROM `sys_file` file + WHERE LOWER(TRIM(LEADING '.' FROM COALESCE(file.`Suffix`, ''))) = 'docx' + AND COALESCE(file.`IsDelete`, 0) = 0 + ORDER BY + CASE WHEN UPPER(COALESCE(file.`Name`, '')) LIKE 'OB20%' THEN 1 ELSE 0 END, + file.`CreateTime` DESC, + file.`Id` DESC + LIMIT 1 + ) +); + +DROP PROCEDURE IF EXISTS `ob_assert_seed_prerequisites`; +DELIMITER $$ +CREATE PROCEDURE `ob_assert_seed_prerequisites`() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM `ob_variable_definition` + WHERE COALESCE(`IsDelete`, 0) = 0 + AND LOWER(COALESCE(`Status`, 'enable')) = 'enable' + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '初始化失败:ob_variable_definition 没有启用数据'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM `ob_material_type` + WHERE COALESCE(`IsDelete`, 0) = 0 + AND LOWER(COALESCE(`Status`, 'enable')) = 'enable' + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '初始化失败:ob_material_type 没有启用数据'; + END IF; + + IF @ob_seed_source_docx_file_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM `sys_file` + WHERE `Id` = @ob_seed_source_docx_file_id + AND LOWER(TRIM(LEADING '.' FROM COALESCE(`Suffix`, ''))) = 'docx' + AND COALESCE(`IsDelete`, 0) = 0 + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '初始化失败:没有可用的合同源 DOCX,请在脚本顶部指定文件 Id'; + END IF; +END$$ +DELIMITER ; + +CALL `ob_assert_seed_prerequisites`(); +DROP PROCEDURE IF EXISTS `ob_assert_seed_prerequisites`; + +START TRANSACTION; + +-- 1. 公司 +INSERT INTO `ob_company` + (`Id`, `CompanyCode`, `CompanyName`, `LegalName`, `UnifiedSocialCreditCode`, + `LegalRepresentative`, `RegisteredAddress`, `ContactPhone`, `Remark`, + `Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`) +VALUES + (8800000000000001, 'DEMO-COMPANY-001', '重庆示例科技有限公司', '重庆示例科技有限公司', + '91500000MA5UDEMO01', '张示例', '重庆市渝北区示例大道 100 号', '023-88886666', + '测试公司,可在后台修改', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0) +ON DUPLICATE KEY UPDATE + `CompanyCode` = VALUES(`CompanyCode`), + `CompanyName` = VALUES(`CompanyName`), + `LegalName` = VALUES(`LegalName`), + `UnifiedSocialCreditCode` = VALUES(`UnifiedSocialCreditCode`), + `LegalRepresentative` = VALUES(`LegalRepresentative`), + `RegisteredAddress` = VALUES(`RegisteredAddress`), + `ContactPhone` = VALUES(`ContactPhone`), + `Remark` = VALUES(`Remark`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +-- 2. 部门 +INSERT INTO `ob_department` + (`Id`, `CompanyId`, `DepartmentCode`, `DepartmentName`, `SortCode`, `Remark`, + `Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`) +VALUES + (8800000000000101, 8800000000000001, 'DEMO-DEPT-HR', '人事部', 10, + '负责招聘、入职和员工关系', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000102, 8800000000000001, 'DEMO-DEPT-TECH', '技术部', 20, + '负责产品研发和质量保障', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000103, 8800000000000001, 'DEMO-DEPT-OPERATION', '运营部', 30, + '负责产品运营和客户服务', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0) +ON DUPLICATE KEY UPDATE + `CompanyId` = VALUES(`CompanyId`), + `DepartmentCode` = VALUES(`DepartmentCode`), + `DepartmentName` = VALUES(`DepartmentName`), + `SortCode` = VALUES(`SortCode`), + `Remark` = VALUES(`Remark`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +-- 3. 岗位及默认人事合同参数 +INSERT INTO `ob_position` + (`Id`, `CompanyId`, `DepartmentId`, `PositionCode`, `PositionName`, + `DefaultContractYears`, `DefaultProbationMonths`, `DefaultParamsJson`, `Remark`, + `Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`) +VALUES + (8800000000000201, 8800000000000001, 8800000000000101, + 'DEMO-HR-SPECIALIST', '人事专员', 3, 3, + JSON_OBJECT('hr.BaseSalary', '4500', 'hr.PositionSalary', '1000', + 'hr.PerformanceAllowance', '500', 'hr.AttendanceBonus', '200', + 'hr.RegularSalary', '6200', 'hr.WorkCity', '重庆', + 'hr.PositionName', '人事专员', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'), + '测试岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000202, 8800000000000001, 8800000000000102, + 'DEMO-BACKEND-ENGINEER', '后端开发工程师', 3, 3, + JSON_OBJECT('hr.BaseSalary', '6500', 'hr.PositionSalary', '2500', + 'hr.PerformanceAllowance', '1000', 'hr.AttendanceBonus', '200', + 'hr.RegularSalary', '10200', 'hr.WorkCity', '重庆', + 'hr.PositionName', '后端开发工程师', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'), + '完整流程推荐使用此岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000203, 8800000000000001, 8800000000000102, + 'DEMO-FRONTEND-ENGINEER', '前端开发工程师', 3, 3, + JSON_OBJECT('hr.BaseSalary', '6200', 'hr.PositionSalary', '2300', + 'hr.PerformanceAllowance', '1000', 'hr.AttendanceBonus', '200', + 'hr.RegularSalary', '9700', 'hr.WorkCity', '重庆', + 'hr.PositionName', '前端开发工程师', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'), + '测试岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000204, 8800000000000001, 8800000000000102, + 'DEMO-QA-ENGINEER', '测试工程师', 3, 3, + JSON_OBJECT('hr.BaseSalary', '5500', 'hr.PositionSalary', '1800', + 'hr.PerformanceAllowance', '800', 'hr.AttendanceBonus', '200', + 'hr.RegularSalary', '8300', 'hr.WorkCity', '重庆', + 'hr.PositionName', '测试工程师', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'), + '测试岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000205, 8800000000000001, 8800000000000103, + 'DEMO-OPERATION-SPECIALIST', '运营专员', 3, 3, + JSON_OBJECT('hr.BaseSalary', '4500', 'hr.PositionSalary', '1200', + 'hr.PerformanceAllowance', '800', 'hr.AttendanceBonus', '200', + 'hr.RegularSalary', '6700', 'hr.WorkCity', '重庆', + 'hr.PositionName', '运营专员', 'hr.ContractTerm', '3', 'hr.ProbationTerm', '3'), + '测试岗位', 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0) +ON DUPLICATE KEY UPDATE + `CompanyId` = VALUES(`CompanyId`), + `DepartmentId` = VALUES(`DepartmentId`), + `PositionCode` = VALUES(`PositionCode`), + `PositionName` = VALUES(`PositionName`), + `DefaultContractYears` = VALUES(`DefaultContractYears`), + `DefaultProbationMonths` = VALUES(`DefaultProbationMonths`), + `DefaultParamsJson` = VALUES(`DefaultParamsJson`), + `Remark` = VALUES(`Remark`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +-- 4. 技术部合同模板及适用岗位 +INSERT INTO `ob_contract_template` + (`Id`, `CompanyId`, `DepartmentId`, `TemplateCode`, `TemplateName`, `ContractType`, + `Description`, `Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`) +VALUES + (8800000000000301, 8800000000000001, 8800000000000102, + 'DEMO-LABOR-CONTRACT-001', '技术岗位劳动合同(测试)', '劳动合同', + '测试合同模板,源 DOCX 由脚本顶部变量决定', + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0) +ON DUPLICATE KEY UPDATE + `CompanyId` = VALUES(`CompanyId`), + `DepartmentId` = VALUES(`DepartmentId`), + `TemplateCode` = VALUES(`TemplateCode`), + `TemplateName` = VALUES(`TemplateName`), + `ContractType` = VALUES(`ContractType`), + `Description` = VALUES(`Description`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +INSERT INTO `ob_contract_template_position` + (`Id`, `ContractTemplateId`, `PositionId`, `Status`, `CreateTime`, `UpdateTime`, + `CreateUser`, `UpdateUser`, `IsDelete`) +VALUES + (8800000000000401, 8800000000000301, 8800000000000202, + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000402, 8800000000000301, 8800000000000203, + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000403, 8800000000000301, 8800000000000204, + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0) +ON DUPLICATE KEY UPDATE + `ContractTemplateId` = VALUES(`ContractTemplateId`), + `PositionId` = VALUES(`PositionId`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +-- 5. 已启用合同版本 +INSERT INTO `ob_contract_template_version` + (`Id`, `TemplateId`, `VersionNo`, `SourceDocxFileId`, `FileHash`, + `EffectiveDate`, `ExpiryDate`, `VersionStatus`, `Remark`, + `Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`) +VALUES + (8800000000000311, 8800000000000301, '1.0', @ob_seed_source_docx_file_id, + CONCAT('seed-file-', @ob_seed_source_docx_file_id), + CURDATE(), DATE_ADD(CURDATE(), INTERVAL 10 YEAR), 'enabled', '测试合同版本', + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0) +ON DUPLICATE KEY UPDATE + `TemplateId` = VALUES(`TemplateId`), + `VersionNo` = VALUES(`VersionNo`), + `SourceDocxFileId` = VALUES(`SourceDocxFileId`), + `FileHash` = VALUES(`FileHash`), + `EffectiveDate` = VALUES(`EffectiveDate`), + `ExpiryDate` = VALUES(`ExpiryDate`), + `VersionStatus` = 'enabled', + `Remark` = VALUES(`Remark`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +-- SQL 不能解压 DOCX 识别占位符,因此复制当前启用的四类变量字典。 +-- DOCX 未使用的变量不会改变文件;这样能避免实际占位符初始化时遗漏。 +INSERT INTO `ob_template_variable` + (`Id`, `TemplateVersionId`, `VariableCode`, `VariableName`, `VariableType`, `DataType`, + `EmployeeField`, `DefaultValue`, `Required`, `HrEditable`, `SortCode`, `Remark`, + `Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`) +SELECT + 8000000000000000 + definition.`Id`, + 8800000000000311, + definition.`VariableCode`, + definition.`VariableName`, + CASE LOWER(definition.`VariableCategory`) + WHEN 'employee' THEN 'employee' + WHEN 'hr' THEN 'contract' + WHEN 'company' THEN 'company' + WHEN 'system' THEN 'system' + END, + COALESCE(NULLIF(definition.`DataType`, ''), 'string'), + CASE + WHEN LOWER(definition.`VariableCategory`) IN ('employee', 'company') + THEN SUBSTRING_INDEX(definition.`VariableCode`, '.', -1) + ELSE NULL + END, + definition.`DefaultValue`, + COALESCE(definition.`Required`, 0), + CASE WHEN LOWER(definition.`VariableCategory`) = 'hr' THEN 1 ELSE 0 END, + COALESCE(definition.`SortCode`, 100), + definition.`Remark`, + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0 + FROM `ob_variable_definition` definition + WHERE COALESCE(definition.`IsDelete`, 0) = 0 + AND LOWER(COALESCE(definition.`Status`, 'enable')) = 'enable' + AND LOWER(definition.`VariableCategory`) IN ('employee', 'hr', 'company', 'system') + AND definition.`Id` BETWEEN 1 AND 1007199254740991 +ON DUPLICATE KEY UPDATE + `VariableName` = VALUES(`VariableName`), + `VariableType` = VALUES(`VariableType`), + `DataType` = VALUES(`DataType`), + `EmployeeField` = VALUES(`EmployeeField`), + `DefaultValue` = VALUES(`DefaultValue`), + `Required` = VALUES(`Required`), + `HrEditable` = VALUES(`HrEditable`), + `SortCode` = VALUES(`SortCode`), + `Remark` = VALUES(`Remark`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +-- 6. 可直接发起任务的入职模板 +INSERT INTO `ob_onboarding_template` + (`Id`, `TemplateCode`, `TemplateName`, `CompanyId`, `DepartmentId`, `Description`, + `Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`) +VALUES + (8800000000000501, 'DEMO-ONBOARDING-TECH', '技术岗位标准入职模板(测试)', + 8800000000000001, 8800000000000102, + '适用于后端、前端和测试工程师,可用于完整入职流程测试', + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0) +ON DUPLICATE KEY UPDATE + `TemplateCode` = VALUES(`TemplateCode`), + `TemplateName` = VALUES(`TemplateName`), + `CompanyId` = VALUES(`CompanyId`), + `DepartmentId` = VALUES(`DepartmentId`), + `Description` = VALUES(`Description`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +INSERT INTO `ob_onboarding_template_position` + (`Id`, `OnboardingTemplateId`, `PositionId`, `Status`, `CreateTime`, `UpdateTime`, + `CreateUser`, `UpdateUser`, `IsDelete`) +VALUES + (8800000000000601, 8800000000000501, 8800000000000202, + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000602, 8800000000000501, 8800000000000203, + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0), + (8800000000000603, 8800000000000501, 8800000000000204, + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0) +ON DUPLICATE KEY UPDATE + `OnboardingTemplateId` = VALUES(`OnboardingTemplateId`), + `PositionId` = VALUES(`PositionId`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +INSERT INTO `ob_onboarding_template_contract` + (`Id`, `OnboardingTemplateId`, `ContractTemplateId`, `ContractTemplateVersionId`, + `Required`, `PrintCopies`, `SortCode`, `Status`, `CreateTime`, `UpdateTime`, + `CreateUser`, `UpdateUser`, `IsDelete`) +VALUES + (8800000000000701, 8800000000000501, 8800000000000301, 8800000000000311, + 1, 1, 10, 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0) +ON DUPLICATE KEY UPDATE + `OnboardingTemplateId` = VALUES(`OnboardingTemplateId`), + `ContractTemplateId` = VALUES(`ContractTemplateId`), + `ContractTemplateVersionId` = VALUES(`ContractTemplateVersionId`), + `Required` = 1, + `PrintCopies` = 1, + `SortCode` = 10, + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +-- 所有启用资料类型都会显示;一寸白底证件照必传,其余选传。 +INSERT INTO `ob_onboarding_template_material` + (`Id`, `OnboardingTemplateId`, `MaterialTypeId`, `Required`, `SortCode`, + `Status`, `CreateTime`, `UpdateTime`, `CreateUser`, `UpdateUser`, `IsDelete`) +SELECT + 7000000000000000 + material.`Id`, + 8800000000000501, + material.`Id`, + CASE + WHEN material.`MaterialName` LIKE '%一寸白底%' + OR LOWER(material.`MaterialCode`) IN ('photo', 'id_photo', 'head_photo') THEN 1 + ELSE 0 + END, + ROW_NUMBER() OVER (ORDER BY material.`Id`) * 10, + 'ENABLE', NOW(3), NOW(3), 'seed-script', 'seed-script', 0 + FROM `ob_material_type` material + WHERE COALESCE(material.`IsDelete`, 0) = 0 + AND LOWER(COALESCE(material.`Status`, 'enable')) = 'enable' + AND material.`Id` BETWEEN 1 AND 2007199254740991 +ON DUPLICATE KEY UPDATE + `Required` = VALUES(`Required`), + `SortCode` = VALUES(`SortCode`), + `Status` = 'ENABLE', + `UpdateTime` = NOW(3), + `UpdateUser` = 'seed-script', + `IsDelete` = 0; + +COMMIT; + +-- 执行结果:先核对自动选中的源 DOCX,再核对初始化数量。 +SELECT + 'SUCCESS' AS `Result`, + @ob_seed_source_docx_file_id AS `SourceDocxFileId`, + (SELECT `Name` FROM `sys_file` WHERE `Id` = @ob_seed_source_docx_file_id LIMIT 1) AS `SourceDocxFileName`, + 8800000000000001 AS `CompanyId`, + 8800000000000102 AS `DepartmentId`, + 8800000000000202 AS `RecommendedPositionId`, + 8800000000000501 AS `OnboardingTemplateId`; + +SELECT + (SELECT COUNT(*) FROM `ob_company` + WHERE `Id` = 8800000000000001 AND COALESCE(`IsDelete`, 0) = 0) AS `CompanyCount`, + (SELECT COUNT(*) FROM `ob_department` + WHERE `CompanyId` = 8800000000000001 AND COALESCE(`IsDelete`, 0) = 0) AS `DepartmentCount`, + (SELECT COUNT(*) FROM `ob_position` + WHERE `CompanyId` = 8800000000000001 AND COALESCE(`IsDelete`, 0) = 0) AS `PositionCount`, + (SELECT COUNT(*) FROM `ob_template_variable` + WHERE `TemplateVersionId` = 8800000000000311 AND COALESCE(`IsDelete`, 0) = 0) AS `ContractVariableCount`, + (SELECT COUNT(*) FROM `ob_onboarding_template_material` + WHERE `OnboardingTemplateId` = 8800000000000501 AND COALESCE(`IsDelete`, 0) = 0) AS `MaterialCount`;