diff --git a/SimpleAdmin.Application/Application.Development.json b/SimpleAdmin.Application/Application.Development.json index 0b95cce..addabbc 100644 --- a/SimpleAdmin.Application/Application.Development.json +++ b/SimpleAdmin.Application/Application.Development.json @@ -17,17 +17,19 @@ "AppToken": "rMKU99gkts", "AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O", "SignatureVersion": "", - "CategoryId": "3533041076673835142", - "SealId": "3533379903657587306", "CreatorName": "袁林", "CreatorMobile": "15123135384", - "CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev", + "CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev/api/open/onboarding/qiyuesuo/callback", "SignPageCallbackUrl": "", "SignUrlExpireMinutes": 10, "EmployeeSignKeyword": "乙方(劳动者)签字:", "EmployeeSignKeywordIndex": 1, "CompanySealKeyword": "甲方(盖章):", "CompanySealKeywordIndex": 1, + "CompanySealOffsetX": 0, + "CompanySealOffsetY": -0.05, + "EnableAcrossPageSeal": true, + "AcrossPageSealType": "ALL_DOCUMENT", "TimeoutSeconds": 90 } } diff --git a/SimpleAdmin.Application/Application.Production.json b/SimpleAdmin.Application/Application.Production.json index 92185cf..5e4e153 100644 --- a/SimpleAdmin.Application/Application.Production.json +++ b/SimpleAdmin.Application/Application.Production.json @@ -17,8 +17,6 @@ "AppToken": "rMKU99gkts", "AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O", "SignatureVersion": "", - "CategoryId": "3533041076673835142", - "SealId": "3533042752893948009", "CreatorName": "袁林", "CreatorMobile": "15123135384", "CallbackUrl": "https://onboardx-api.cnzhyx.cn/api/open/onboarding/qiyuesuo/callback", @@ -28,6 +26,10 @@ "EmployeeSignKeywordIndex": 1, "CompanySealKeyword": "甲方(盖章):", "CompanySealKeywordIndex": 1, + "CompanySealOffsetX": 0, + "CompanySealOffsetY": -0.12, + "EnableAcrossPageSeal": true, + "AcrossPageSealType": "ALL_DOCUMENT", "TimeoutSeconds": 90 } } diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs b/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs index f187462..008e992 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObCompany.cs @@ -52,6 +52,16 @@ public class ObCompany : BaseEntity /// public string ContactPhone { get; set; } + /// + /// 契约锁默认业务分类 Id;每个签约公司独立配置。 + /// + public string QiyuesuoCategoryId { get; set; } + + /// + /// 契约锁企业印章 Id;每个签约公司独立配置。 + /// + public string QiyuesuoSealId { get; set; } + /// /// 业务备注。 /// diff --git a/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs b/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs index 57e7188..e773c6f 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/OnboardingStatus.cs @@ -176,7 +176,7 @@ public static class ElectronicSignStatus public const string PendingEmployeeSign = "pending_employee_sign"; /// - /// 员工已签,等待企业自动盖章或平台完成流程。 + /// 员工已签,等待后台管理员核对文件并确认企业盖章。 /// public const string PendingCompanySeal = "pending_company_seal"; diff --git a/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs b/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs index 28090d7..b9c9a9d 100644 --- a/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs +++ b/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs @@ -30,16 +30,6 @@ public class QiyuesuoSettingOptions /// public string SignatureVersion { get; set; } - /// - /// 默认业务分类 Id。 - /// - public string CategoryId { get; set; } - - /// - /// 企业自动盖章使用的印章 Id。 - /// - public string SealId { get; set; } - /// /// 契约锁企业成员中的合同创建人姓名。 /// @@ -85,6 +75,26 @@ public class QiyuesuoSettingOptions /// public int CompanySealKeywordIndex { get; set; } = 1; + /// + /// 企业盖章相对关键字的横向偏移量,范围 (-1, 1),正数向右、负数向左。 + /// + public decimal CompanySealOffsetX { get; set; } + + /// + /// 企业盖章相对关键字的纵向偏移量,范围 (-1, 1),正数向上、负数向下。 + /// + public decimal CompanySealOffsetY { get; set; } + + /// + /// 是否在后台确认企业盖章时同时加盖骑缝章。 + /// + public bool EnableAcrossPageSeal { get; set; } = true; + + /// + /// 骑缝章文档范围:ALL_DOCUMENT(全部文档合并)或 SINGLE_DOCUMENT(每份文档单独)。 + /// + public string AcrossPageSealType { get; set; } = "ALL_DOCUMENT"; + /// /// 单次 HTTP 请求超时秒数。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs index 9c41a7c..e802d76 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Company/OnboardingCompanyService.cs @@ -43,6 +43,7 @@ public class OnboardingCompanyService : DbRepository, IOnboardingComp /// public async Task Add(OnboardingCompanyAddInput input) { + NormalizeAndValidateQiyuesuoConfig(input); await EnsureCodeUnique(input.CompanyCode, 0); await InsertAsync(input.Adapt()); } @@ -52,6 +53,7 @@ public class OnboardingCompanyService : DbRepository, IOnboardingComp /// public async Task Edit(OnboardingCompanyEditInput input) { + NormalizeAndValidateQiyuesuoConfig(input); await Detail(new BaseIdInput { Id = input.Id }); await EnsureCodeUnique(input.CompanyCode, input.Id); await UpdateAsync(input.Adapt()); @@ -98,6 +100,24 @@ public class OnboardingCompanyService : DbRepository, IOnboardingComp await DeleteByIdsAsync(input.Ids.Cast().ToArray()); } + private static void NormalizeAndValidateQiyuesuoConfig(ObCompany company) + { + company.QiyuesuoCategoryId = company.QiyuesuoCategoryId?.Trim(); + company.QiyuesuoSealId = company.QiyuesuoSealId?.Trim(); + + var hasCategoryId = !string.IsNullOrWhiteSpace(company.QiyuesuoCategoryId); + var hasSealId = !string.IsNullOrWhiteSpace(company.QiyuesuoSealId); + if (hasCategoryId != hasSealId) + throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 必须同时填写"); + if (!hasCategoryId) + return; + if (!long.TryParse(company.QiyuesuoCategoryId, out _) + || !long.TryParse(company.QiyuesuoSealId, out _)) + { + throw Oops.Bah("契约锁默认业务分类 Id 和企业印章 Id 只能填写数字"); + } + } + private async Task EnsureCodeUnique(string code, long id) { var exists = await Context.Queryable() diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/DocxTemplateProcessor.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/DocxTemplateProcessor.cs index 95bedae..0927f21 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/DocxTemplateProcessor.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/DocxTemplateProcessor.cs @@ -17,25 +17,39 @@ public static class DocxTemplateProcessor /// public static List ExtractVariableCodes(byte[] docx) { - using var input = new MemoryStream(docx); - using var archive = new global::System.IO.Compression.ZipArchive( - input, - global::System.IO.Compression.ZipArchiveMode.Read); - var codes = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName))) + ValidateDocxHeader(docx); + try { - using var stream = entry.Open(); - var document = global::System.Xml.Linq.XDocument.Load(stream); - foreach (var paragraph in document.Descendants(WordNamespace + "p")) - { - var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value)); - foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text)) - codes.Add(match.Groups["code"].Value.Trim()); - } - } + using var input = new MemoryStream(docx); + using var archive = new global::System.IO.Compression.ZipArchive( + input, + global::System.IO.Compression.ZipArchiveMode.Read); + if (archive.GetEntry("word/document.xml") == null) + throw Oops.Bah("上传文件不是有效的 DOCX 文档,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传"); - return codes.OrderBy(code => code).ToList(); + var codes = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName))) + { + using var stream = entry.Open(); + var document = global::System.Xml.Linq.XDocument.Load(stream); + foreach (var paragraph in document.Descendants(WordNamespace + "p")) + { + var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value)); + foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text)) + codes.Add(match.Groups["code"].Value.Trim()); + } + } + + return codes.OrderBy(code => code).ToList(); + } + catch (global::System.IO.InvalidDataException) + { + throw Oops.Bah("DOCX 文件结构已经损坏或上传不完整,请重新另存为 DOCX 后上传"); + } + catch (global::System.Xml.XmlException) + { + throw Oops.Bah("DOCX 文件内部内容损坏,Word 可以尝试修复该文件;请修复并重新另存为 DOCX 后上传"); + } } /// @@ -43,6 +57,7 @@ public static class DocxTemplateProcessor /// public static byte[] Render(byte[] docx, IReadOnlyDictionary values) { + ValidateDocxHeader(docx); using var input = new MemoryStream(docx); using var archive = new global::System.IO.Compression.ZipArchive( input, @@ -157,4 +172,26 @@ public static class DocxTemplateProcessor || entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase)) && entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)); } -} \ No newline at end of file + + /// + /// 根据文件头识别真实格式,避免只修改扩展名的旧版 DOC 在解压时变成系统异常。 + /// + private static void ValidateDocxHeader(byte[] docx) + { + if (docx == null || docx.Length < 4) + throw Oops.Bah("上传的 DOCX 文件为空或上传不完整,请重新选择文件上传"); + + // 旧版 Word 97-2003 二进制 DOC 使用 OLE Compound File 文件头:D0 CF 11 E0。 + if (docx[0] == 0xD0 + && docx[1] == 0xCF + && docx[2] == 0x11 + && docx[3] == 0xE0) + { + throw Oops.Bah("上传文件实际是旧版 DOC 格式,不能直接修改后缀名;请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传"); + } + + // DOCX 是 Open XML ZIP 包,正常文件以 PK 开头。 + if (docx[0] != 0x50 || docx[1] != 0x4B) + throw Oops.Bah("上传文件的真实格式不是 DOCX,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传"); + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs index a13599b..92babaf 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs @@ -37,6 +37,7 @@ public class OnboardingContractTemplateService : DbRepository it.CreateTime) .OrderBy(it => it.TemplateName) .ToPagedListAsync(input.PageNum, input.PageSize); await PopulateScopeDisplay(page.List.ToList()); diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignInput.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignInput.cs index bea5d2b..37855fe 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignInput.cs @@ -14,6 +14,18 @@ public class OnboardingElectronicSignTaskInput public long TaskId { get; set; } } +/// +/// 管理员查看一份员工已签合同的参数。 +/// +public class OnboardingElectronicSignPreviewInput : OnboardingElectronicSignTaskInput +{ + /// + /// 本地电子签文档 Id。 + /// + [IdNotNull(ErrorMessage = "电子签文档Id不能为空")] + public long SignDocumentId { get; set; } +} + /// /// 作废入职电子签流程的参数。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs index bb09b24..3e7bc54 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs @@ -85,6 +85,18 @@ public class OnboardingElectronicSignDetailOutput public List HistoryProcesses { get; set; } = new(); } +/// +/// 契约锁员工已签文件只读预览结果。 +/// +public class OnboardingElectronicSignPreviewOutput +{ + /// 本地电子签文档 Id。 + public long SignDocumentId { get; set; } + + /// 契约锁生成的只读浏览页面地址。 + public string PreviewUrl { get; set; } +} + /// /// 已被新流程替代的历史电子签流程摘要。 @@ -149,4 +161,7 @@ public class OnboardingElectronicSignDocumentOutput /// 签署完成后的 PDF 文件 Id。 public long? SignedPdfFileId { get; set; } + + /// 当前管理员流程中是否已生成过员工签后预览。 + public bool EmployeeSignedPreviewed { get; set; } } diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/IOnboardingElectronicSignService.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/IOnboardingElectronicSignService.cs index b987d4f..c7654fc 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/IOnboardingElectronicSignService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/IOnboardingElectronicSignService.cs @@ -24,6 +24,14 @@ public interface IOnboardingElectronicSignService : ITransient Task GetSignLink( OnboardingElectronicSignTaskInput input); + /// 获取一份员工已签合同的只读浏览页面。 + Task PreviewEmployeeSignedDocument( + OnboardingElectronicSignPreviewInput input); + + /// 确认全部文件已核对后,由后台执行企业盖章。 + Task CompanySeal( + OnboardingElectronicSignTaskInput input); + /// 催办当前待签员工。 Task Notice(OnboardingElectronicSignTaskInput input); diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs index 4c7fb91..3104574 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs @@ -5,10 +5,14 @@ using System.Text.Json; namespace SimpleAdmin.Application; /// -/// 实现入职合同“员工先签、企业自动盖章”的契约锁电子签流程。 +/// 实现入职合同“员工先签、后台核对、企业人工盖章”的契约锁电子签流程。 /// public class OnboardingElectronicSignService : IOnboardingElectronicSignService { + private const string AdminPreviewEventSource = "admin_preview"; + private const string AdminCompanySealEventSource = "admin_company_seal"; + private const string PreviewEventTypePrefix = "employee_signed_preview:"; + private readonly SqlSugarScope _db; private readonly IQiyuesuoClient _qiyuesuoClient; private readonly IOnboardingContractDocumentService _contractDocumentService; @@ -68,11 +72,22 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService var context = await GetInitiationContext(task); if (process == null) { - var created = await CreateProcess(task, context.Employee); + var created = await CreateProcess( + task, + context.Employee, + context.CategoryId, + context.SealId); process = created.Process; isNewProcess = created.Created; } + if (string.IsNullOrWhiteSpace(process.ProviderContractId)) + { + // 第三方合同尚未创建时允许采用公司资料中的最新配置,便于修正配置后直接重试。 + process.CategoryId = context.CategoryId; + process.SealId = context.SealId; + } + return await StartProcess( task, context.CompanyName, @@ -97,6 +112,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService var process = await CreateReplacementProcess( task, context.Employee, + context.CategoryId, + context.SealId, previousProcess, input.Reason.Trim()); @@ -114,6 +131,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService private async Task<( ObEmployee Employee, string CompanyName, + string CategoryId, + string SealId, List TaskContracts)> GetInitiationContext( ObOnboardingTask task) { @@ -140,6 +159,15 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService if (string.IsNullOrWhiteSpace(companyName)) throw Oops.Bah("任务所属公司法定名称为空,不能发起电子签"); + var categoryId = company.QiyuesuoCategoryId?.Trim(); + var sealId = company.QiyuesuoSealId?.Trim(); + if (string.IsNullOrWhiteSpace(categoryId)) + throw Oops.Bah($"公司“{companyName}”未配置契约锁默认业务分类 Id,请先到公司与岗位中编辑公司资料"); + if (string.IsNullOrWhiteSpace(sealId)) + throw Oops.Bah($"公司“{companyName}”未配置契约锁企业印章 Id,请先到公司与岗位中编辑公司资料"); + if (!long.TryParse(categoryId, out _) || !long.TryParse(sealId, out _)) + throw Oops.Bah($"公司“{companyName}”的契约锁业务分类或印章 Id 格式不正确"); + var taskContracts = await _db.Queryable() .Where(item => item.TaskId == task.Id) .OrderBy(item => item.Id) @@ -152,7 +180,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService if (unconfirmed != null) throw Oops.Bah($"合同“{unconfirmed.ContractName}”尚未由员工确认"); - return (employee, companyName, taskContracts); + return (employee, companyName, categoryId, sealId, taskContracts); } /// @@ -258,6 +286,159 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService return await BuildDetail(task, process); } + /// + public async Task PreviewEmployeeSignedDocument( + OnboardingElectronicSignPreviewInput input) + { + var task = await GetAuthorizedTask(input.TaskId); + var process = await GetRequiredProcess(task.Id); + + // 不信任前端缓存状态。每次预览前重新查询契约锁,确保员工确实已完成签署。 + await SyncProcess(process, "preview_employee_signed"); + if (process.SignStatus != ElectronicSignStatus.PendingCompanySeal) + throw Oops.Bah("只有员工已签、企业待盖章的合同才能在此处核对"); + + var document = await _db.Queryable() + .FirstAsync(item => + item.Id == input.SignDocumentId + && item.SignProcessId == process.Id) + ?? throw Oops.Bah("电子签文档不存在或不属于当前签约流程"); + if (string.IsNullOrWhiteSpace(document.ProviderDocumentId)) + throw Oops.Bah("契约锁文档尚未创建完成,不能预览"); + + var companyName = await GetCompanyLegalName(task); + var previewUrl = await _qiyuesuoClient.GetViewPageUrl( + new QiyuesuoViewPageInput + { + ContractId = process.ProviderContractId, + DocumentId = document.ProviderDocumentId, + CompanyName = companyName + }); + if (string.IsNullOrWhiteSpace(previewUrl) + || !Uri.TryCreate(previewUrl, UriKind.Absolute, out var previewUri) + || (previewUri.Scheme != Uri.UriSchemeHttps + && previewUri.Scheme != Uri.UriSchemeHttp)) + { + throw Oops.Bah("契约锁返回的合同浏览地址无效"); + } + + await RecordAdminOperation( + process, + AdminPreviewEventSource, + BuildPreviewEventType(document.Id), + JsonSerializer.Serialize(new + { + signDocumentId = document.Id, + providerDocumentId = document.ProviderDocumentId, + document.DocumentName, + operatorUserId = UserManager.UserId, + operatorName = UserManager.Name + }), + "已生成预览地址"); + + return new OnboardingElectronicSignPreviewOutput + { + SignDocumentId = document.Id, + PreviewUrl = previewUrl + }; + } + + /// + public async Task CompanySeal( + OnboardingElectronicSignTaskInput input) + { + var task = await GetAuthorizedTask(input.TaskId); + var process = await GetRequiredProcess(task.Id); + + // 先查第三方真实状态。重复点击时,如果契约锁已经完成,则直接返回最新详情。 + await SyncProcess(process, "company_seal_precheck"); + if (process.SignStatus is ElectronicSignStatus.Completed + or ElectronicSignStatus.Archived) + { + return await BuildDetail(task, process); + } + if (process.SignStatus != ElectronicSignStatus.PendingCompanySeal) + throw Oops.Bah("员工尚未完成签署,当前不能执行企业盖章"); + + var documents = await _db.Queryable() + .Where(item => item.SignProcessId == process.Id) + .OrderBy(item => item.DocumentSort) + .ToListAsync(); + if (documents.Count == 0) + throw Oops.Bah("当前电子签流程没有可盖章的合同文件"); + + var previewedDocumentIds = await GetPreviewedDocumentIds(process.Id); + var notPreviewed = documents + .Where(item => !previewedDocumentIds.Contains(item.Id)) + .Select(item => item.DocumentName) + .ToList(); + if (notPreviewed.Count > 0) + { + throw Oops.Bah( + $"请先逐份查看员工已签文件:{string.Join("、", notPreviewed)}"); + } + + var companyName = await GetCompanyLegalName(task); + try + { + await _qiyuesuoClient.CompanySign( + new QiyuesuoCompanySignInput + { + ContractId = process.ProviderContractId, + CompanyName = companyName, + SealId = process.SealId + }); + } + catch (Exception signException) + { + // POST 响应中断时结果可能不明确,立即反查一次,避免实际已盖章却提示用户反复重试。 + try + { + await SyncProcess(process, "company_seal_verify"); + } + catch + { + // SyncProcess 已保存同步或归档错误,下面仍按内存中的真实状态判断是否已经盖章。 + } + if (process.SignStatus is ElectronicSignStatus.Completed + or ElectronicSignStatus.Archived) + { + return await BuildDetail(task, process); + } + + process.LastError = $"企业盖章失败:{LimitError(signException.Message)}"; + process.UpdateTime = DateTime.Now; + await _db.Updateable(process).ExecuteCommandAsync(); + throw; + } + + await RecordAdminOperation( + process, + AdminCompanySealEventSource, + "company_seal_submitted", + JsonSerializer.Serialize(new + { + process.ProviderContractId, + companyName, + process.SealId, + operatorUserId = UserManager.UserId, + operatorName = UserManager.Name + }), + "企业盖章请求已提交"); + + // 盖章接口成功即代表请求已受理;后续查询短暂失败不能误导管理员再次盖章。 + try + { + await SyncProcess(process, "company_seal_sync"); + } + catch + { + // LastError 已由 SyncProcess 保存,页面自动刷新或回调会继续完成归档。 + } + + return await BuildDetail(task, process); + } + /// public async Task Notice(OnboardingElectronicSignTaskInput input) { @@ -284,6 +465,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService await _qiyuesuoClient.Invalid( process.ProviderContractId, + process.SealId, input.Reason.Trim()); process.SignStatus = ElectronicSignStatus.Invalid; process.ProviderStatus = "INVALIDED"; @@ -390,9 +572,11 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService /// private async Task<(ObElectronicSignProcess Process, bool Created)> CreateProcess( ObOnboardingTask task, - ObEmployee employee) + ObEmployee employee, + string categoryId, + string sealId) { - var process = BuildProcess(task, employee); + var process = BuildProcess(task, employee, categoryId, sealId); try { await _db.Insertable(process).ExecuteCommandAsync(); @@ -415,10 +599,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService private async Task CreateReplacementProcess( ObOnboardingTask task, ObEmployee employee, + string categoryId, + string sealId, ObElectronicSignProcess previousProcess, string reason) { - var process = BuildProcess(task, employee); + var process = BuildProcess(task, employee, categoryId, sealId); var now = DateTime.Now; await _db.Ado.BeginTranAsync(); @@ -458,7 +644,9 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService /// private ObElectronicSignProcess BuildProcess( ObOnboardingTask task, - ObEmployee employee) + ObEmployee employee, + string categoryId, + string sealId) { var now = DateTime.Now; var processId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); @@ -470,8 +658,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService Provider = ElectronicSignProvider.Qiyuesuo, BizId = $"{task.TaskNo}-SIGN-{processId}", IsCurrent = true, - CategoryId = _setting.CategoryId.Trim(), - SealId = _setting.SealId.Trim(), + CategoryId = categoryId, + SealId = sealId, Subject = $"{employee.EmployeeName}-{task.TaskNo}-入职合同", EmployeeName = employee.EmployeeName.Trim(), EmployeeMobile = employee.Phone.Trim(), @@ -612,11 +800,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService ?? throw Oops.Bah("契约锁草稿未返回企业签署方"); var companyAction = companySignatory.Actions.FirstOrDefault(item => string.Equals(item.Type, "COMPANY", StringComparison.OrdinalIgnoreCase)) - ?? throw Oops.Bah("契约锁草稿未返回企业自动盖章动作"); + ?? throw Oops.Bah("契约锁草稿未返回企业盖章动作"); var personalSignatoryId = GetSignatoryId(personalSignatory); var companyActionId = GetActionId(companyAction); var stampers = new List(); - foreach (var document in documents.OrderBy(item => item.DocumentSort)) + var orderedDocuments = documents.OrderBy(item => item.DocumentSort).ToList(); + foreach (var document in orderedDocuments) { document.EmployeeActionId = personalSignatoryId; document.CompanyActionId = companyActionId; @@ -637,10 +826,50 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService DocumentId = document.ProviderDocumentId, SealId = process.SealId, Keyword = document.CompanySealKeyword, - KeywordIndex = Math.Max(1, _setting.CompanySealKeywordIndex) + KeywordIndex = Math.Max(1, _setting.CompanySealKeywordIndex), + OffsetX = _setting.CompanySealOffsetX, + OffsetY = _setting.CompanySealOffsetY }); } + if (_setting.EnableAcrossPageSeal) + { + var providerDocumentIds = orderedDocuments + .Select(item => item.ProviderDocumentId) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .ToList(); + if (providerDocumentIds.Count == 0) + throw Oops.Bah("契约锁未返回可用于骑缝章的合同文档 Id"); + + var acrossType = _setting.AcrossPageSealType.Trim().ToUpperInvariant(); + if (acrossType == "ALL_DOCUMENT") + { + stampers.Add(new QiyuesuoStamperInput + { + ActionId = companyActionId, + Type = "ACROSS_PAGE", + DocumentId = providerDocumentIds[0], + DocumentIds = string.Join(",", providerDocumentIds), + SealId = process.SealId, + AcrossType = acrossType + }); + } + else + { + foreach (var providerDocumentId in providerDocumentIds) + { + stampers.Add(new QiyuesuoStamperInput + { + ActionId = companyActionId, + Type = "ACROSS_PAGE", + DocumentId = providerDocumentId, + SealId = process.SealId, + AcrossType = acrossType + }); + } + } + } + var sent = await _qiyuesuoClient.Send(new QiyuesuoSendContractInput { ContractId = process.ProviderContractId, @@ -861,6 +1090,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService .Where(item => item.SignProcessId == process.Id) .OrderBy(item => item.DocumentSort) .ToListAsync(); + var previewedDocumentIds = await GetPreviewedDocumentIds(process.Id); result.ProcessId = process.Id; result.ProviderContractId = process.ProviderContractId; result.BizId = process.BizId; @@ -890,7 +1120,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService DocumentName = item.DocumentName, DocumentSort = item.DocumentSort, OriginalDocxFileId = item.OriginalDocxFileId, - SignedPdfFileId = item.SignedPdfFileId + SignedPdfFileId = item.SignedPdfFileId, + EmployeeSignedPreviewed = previewedDocumentIds.Contains(item.Id) }).ToList(); } else @@ -913,6 +1144,108 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService return result; } + /// + /// 读取任务当前所属公司的法定名称。集团共用令牌时,该名称用于明确子公司主体。 + /// + private async Task GetCompanyLegalName(ObOnboardingTask task) + { + var company = await _db.Queryable() + .InSingleAsync(task.CompanyId) + ?? throw Oops.Bah("任务所属公司不存在"); + var companyName = string.IsNullOrWhiteSpace(company.LegalName) + ? company.CompanyName + : company.LegalName; + if (string.IsNullOrWhiteSpace(companyName)) + throw Oops.Bah("任务所属公司法定名称为空"); + return companyName.Trim(); + } + + /// + /// 返回当前流程中已由管理员生成过员工签后预览的文档 Id。 + /// + private async Task> GetPreviewedDocumentIds(long processId) + { + var eventTypes = await _db.Queryable() + .Where(item => + item.SignProcessId == processId + && item.EventSource == AdminPreviewEventSource + && item.ProcessResult == "已生成预览地址") + .Select(item => item.EventType) + .ToListAsync(); + var result = new HashSet(); + foreach (var eventType in eventTypes) + { + if (!string.IsNullOrWhiteSpace(eventType) + && eventType.StartsWith( + PreviewEventTypePrefix, + StringComparison.Ordinal) + && long.TryParse( + eventType[PreviewEventTypePrefix.Length..], + out var documentId)) + { + result.Add(documentId); + } + } + return result; + } + + /// + /// 将管理员预览、确认盖章等关键操作写入现有电子签事件表。 + /// 固定操作键生成固定哈希,可防止双击或重试留下重复审计记录。 + /// + private async Task RecordAdminOperation( + ObElectronicSignProcess process, + string eventSource, + string eventType, + string payloadJson, + string processResult) + { + var eventKey = $"{eventSource}:{process.Id}:{eventType}"; + var payloadHash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(eventKey))) + .ToLowerInvariant(); + var existing = await _db.Queryable() + .FirstAsync(item => item.PayloadHash == payloadHash); + if (existing != null) + return; + + var now = DateTime.Now; + var operationEvent = new ObElectronicSignEvent + { + Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), + SignProcessId = process.Id, + PayloadHash = payloadHash, + EventSource = eventSource, + EventType = eventType, + ProviderStatus = process.ProviderStatus, + PayloadJson = payloadJson, + ProcessResult = processResult, + ReceivedAt = now, + ProcessedAt = now, + CreateTime = now, + CreateUserId = UserManager.UserId, + CreateUser = UserManager.Name, + IsDelete = false + }; + try + { + await _db.Insertable(operationEvent).ExecuteCommandAsync(); + } + catch + { + // 并发重复操作由 PayloadHash 唯一索引兜底;其他插入错误继续抛出。 + existing = await _db.Queryable() + .FirstAsync(item => item.PayloadHash == payloadHash); + if (existing == null) + throw; + } + } + + private static string BuildPreviewEventType(long documentId) + { + return $"{PreviewEventTypePrefix}{documentId}"; + } + /// /// 电子签管理沿用入职审核人权限,不新增另一套权限来源。 /// @@ -1151,10 +1484,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService 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)) { @@ -1165,6 +1495,17 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService { throw Oops.Bah("请配置员工签名和企业盖章定位关键字"); } + if (_setting.CompanySealOffsetX <= -1m || _setting.CompanySealOffsetX >= 1m + || _setting.CompanySealOffsetY <= -1m || _setting.CompanySealOffsetY >= 1m) + { + throw Oops.Bah("企业盖章 X/Y 偏移量必须在 -1 与 1 之间"); + } + if (_setting.EnableAcrossPageSeal + && !string.Equals(_setting.AcrossPageSealType, "ALL_DOCUMENT", StringComparison.OrdinalIgnoreCase) + && !string.Equals(_setting.AcrossPageSealType, "SINGLE_DOCUMENT", StringComparison.OrdinalIgnoreCase)) + { + throw Oops.Bah("契约锁骑缝章模式只能配置为 ALL_DOCUMENT 或 SINGLE_DOCUMENT"); + } } private static void EnsureCanOpenEmployeeSignPage( diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/IQiyuesuoClient.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/IQiyuesuoClient.cs index 8d475e1..e2795b2 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/IQiyuesuoClient.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/IQiyuesuoClient.cs @@ -6,7 +6,7 @@ namespace SimpleAdmin.Application; public interface IQiyuesuoClient : ITransient { /// - /// 创建包含个人签署方和企业自动盖章动作的合同草稿。 + /// 创建包含个人签署方和企业人工盖章动作的合同草稿。 /// Task CreateDraft( QiyuesuoCreateDraftInput input); @@ -29,6 +29,12 @@ public interface IQiyuesuoClient : ITransient Task GetSignPageUrl( QiyuesuoSignPageInput input); + /// + /// 获取当前合同中指定文档的只读浏览页面地址。 + /// + Task GetViewPageUrl( + QiyuesuoViewPageInput input); + /// /// 查询契约锁合同详情。 /// @@ -39,10 +45,15 @@ public interface IQiyuesuoClient : ITransient /// Task Notice(string contractId); + /// + /// 由后台管理员确认后执行企业盖章节点。 + /// + Task CompanySign(QiyuesuoCompanySignInput input); + /// /// 作废尚未完成的合同流程。 /// - Task Invalid(string contractId, string reason); + Task Invalid(string contractId, string sealId, string reason); /// /// 下载单份签署完成的 PDF 文档。 diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs index 1839e30..3881422 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs @@ -103,9 +103,9 @@ public class QiyuesuoClient : IQiyuesuoClient new { type = "COMPANY", - name = "企业自动盖章", + name = "企业人工盖章", serialNo = 0, - autoSign = true, + autoSign = false, sealId = input.SealId, corpSealIds = new[] { sealId }, actionNo = "company_seal" @@ -185,6 +185,33 @@ public class QiyuesuoClient : IQiyuesuoClient throw Oops.Bah("契约锁未返回员工签署页面地址"); } + /// + public async Task GetViewPageUrl( + QiyuesuoViewPageInput input) + { + var result = await GetJsonElement( + "/v2/contract/viewurl", + new Dictionary + { + ["contractId"] = input.ContractId, + ["documentId"] = input.DocumentId, + ["tenantName"] = input.CompanyName, + ["downloadButton"] = "false", + ["printButton"] = "false" + }); + + if (result.ValueKind == JsonValueKind.String) + return result.GetString(); + if (TryGetString(result, "url", out var url) + || TryGetString(result, "viewUrl", out url) + || TryGetString(result, "pageUrl", out url)) + { + return url; + } + + throw Oops.Bah("契约锁未返回合同浏览页面地址"); + } + /// public Task GetContractDetail(string contractId) { @@ -212,14 +239,27 @@ public class QiyuesuoClient : IQiyuesuoClient } /// - public async Task Invalid(string contractId, string reason) + public async Task CompanySign(QiyuesuoCompanySignInput input) + { + await PostJsonElement( + "/v2/contract/companysign", + new + { + contractId = input.ContractId, + tenantName = input.CompanyName, + sealId = input.SealId + }); + } + + /// + public async Task Invalid(string contractId, string sealId, string reason) { await PostJsonElement( "/v2/contract/invalid", new { contractId, - sealId = _setting.SealId, + sealId, reason, deleteDoc = false, autoSign = true @@ -318,6 +358,15 @@ public class QiyuesuoClient : IQiyuesuoClient private async Task GetJson( string path, Dictionary query) + { + var result = await GetJsonElement(path, query); + return result.Deserialize(JsonOptions) + ?? throw Oops.Bah("契约锁返回数据为空"); + } + + private async Task GetJsonElement( + string path, + Dictionary query) { ValidateSettings(); using var request = new HttpRequestMessage( @@ -328,9 +377,7 @@ public class QiyuesuoClient : IQiyuesuoClient path, HttpMethod.Get, query); - var result = await SendForJsonResult(request); - return result.Deserialize(JsonOptions) - ?? throw Oops.Bah("契约锁返回数据为空"); + return await SendForJsonResult(request); } private async Task Download( diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs index 9a28302..ffade02 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs @@ -77,6 +77,12 @@ public class QiyuesuoStamperInput /// 契约锁文档 Id。 public string DocumentId { get; set; } + /// 骑缝章覆盖的契约锁文档 Id,多个 Id 使用英文逗号分隔。 + public string DocumentIds { get; set; } + + /// 骑缝章位置指定方式。 + public string AcrossType { get; set; } + /// 企业印章 Id。 public string SealId { get; set; } @@ -84,7 +90,13 @@ public class QiyuesuoStamperInput public string Keyword { get; set; } /// 同名关键字的序号,从 1 开始。 - public int KeywordIndex { get; set; } = 1; + public int? KeywordIndex { get; set; } + + /// 相对关键字的横向偏移量;契约锁要求必传,不偏移时为 0。 + public decimal OffsetX { get; set; } + + /// 相对关键字的纵向偏移量;契约锁要求必传,不偏移时为 0。 + public decimal OffsetY { get; set; } } /// @@ -120,6 +132,36 @@ public class QiyuesuoSignPageInput public string CallbackPage { get; set; } } +/// +/// 获取契约锁合同文档只读浏览页所需参数。 +/// +public class QiyuesuoViewPageInput +{ + /// 契约锁合同流程 Id。 + public string ContractId { get; set; } + + /// 契约锁合同文档 Id。 + public string DocumentId { get; set; } + + /// 签署企业法定名称;集团模式下用于定位子公司。 + public string CompanyName { get; set; } +} + +/// +/// 契约锁企业人工盖章所需参数。 +/// +public class QiyuesuoCompanySignInput +{ + /// 契约锁合同流程 Id。 + public string ContractId { get; set; } + + /// 签署企业法定名称;集团模式下用于定位子公司。 + public string CompanyName { get; set; } + + /// 当前流程使用的企业印章 Id 快照。 + public string SealId { get; set; } +} + /// /// 契约锁合同返回对象。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs b/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs index f18b7c4..1175cb3 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Position/OnboardingPositionService.cs @@ -1,4 +1,4 @@ -namespace SimpleAdmin.Application; +namespace SimpleAdmin.Application; /// /// 实现入职业务岗位业务能力。 @@ -17,6 +17,7 @@ public class OnboardingPositionService : DbRepository, IOnboardingPo .WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName)) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}") + .OrderByDescending(it => it.CreateTime) .OrderBy(it => it.PositionName) .ToPagedListAsync(input.PageNum, input.PageSize); diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs index 9f9cc83..d511b2e 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs @@ -64,6 +64,7 @@ public class OnboardingContractTemplateController /// 查询入职模板当前公司、部门及岗位可选择的合同模板。 /// [HttpPost("list-available")] + [DisplayName("查询可选合同模板")] public async Task ListAvailable([FromBody] OnboardingContractTemplateAvailableInput input) { return await _service.ListAvailable(input); @@ -113,6 +114,7 @@ public class OnboardingContractTemplateController /// 新增合同模板版本。 /// [HttpPost("version/add")] + [DisplayName("新增合同模板版本")] public async Task AddVersion([FromBody] OnboardingContractVersionAddInput input) { await _service.AddVersion(input); @@ -122,6 +124,7 @@ public class OnboardingContractTemplateController /// 设置合同模板版本状态。 /// [HttpPost("version/set-status")] + [DisplayName("设置合同模板版本状态")] public async Task SetVersionStatus([FromBody] OnboardingContractVersionStatusInput input) { await _service.SetVersionStatus(input); @@ -131,6 +134,7 @@ public class OnboardingContractTemplateController /// 删除未被入职模板或任务使用的合同模板版本。 /// [HttpPost("version/delete")] + [DisplayName("删除合同模板版本")] public async Task DeleteVersion([FromBody] BaseIdInput input) { await _service.DeleteVersion(input); @@ -150,6 +154,7 @@ public class OnboardingContractTemplateController /// 编辑模板变量。 /// [HttpPost("variable/edit")] + [DisplayName("编辑合同模板变量")] public async Task EditVariable([FromBody] OnboardingTemplateVariableEditInput input) { await _service.EditVariable(input); diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignController.cs index c1151f8..91ad989 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingElectronicSignController.cs @@ -73,6 +73,28 @@ public class OnboardingElectronicSignController return await _service.GetSignLink(input); } + /// + /// 获取员工已签、企业未盖章文件的只读浏览页面。 + /// + [HttpPost("preview-employee-signed")] + [DisplayName("预览员工已签合同")] + public async Task PreviewEmployeeSignedDocument( + [FromBody] OnboardingElectronicSignPreviewInput input) + { + return await _service.PreviewEmployeeSignedDocument(input); + } + + /// + /// 全部文件核对完成后,由后台管理员确认企业盖章。 + /// + [HttpPost("company-seal")] + [DisplayName("确认入职合同企业盖章")] + public async Task CompanySeal( + [FromBody] OnboardingElectronicSignTaskInput input) + { + return await _service.CompanySeal(input); + } + /// /// 催办当前待签员工。 /// diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Organization/BizUserController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Organization/BizUserController.cs index cfbe283..5e30537 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Organization/BizUserController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Organization/BizUserController.cs @@ -51,6 +51,7 @@ public class BizUserController : IDynamicApiController /// [HttpGet(template: "template")] [SuppressMonitor] + [DisplayName("下载人员导入模板")] public async Task Template() { return await _userService.Template(); diff --git a/database/migrations/20260908_qiyuesuo_company_config.sql b/database/migrations/20260908_qiyuesuo_company_config.sql new file mode 100644 index 0000000..36a2147 --- /dev/null +++ b/database/migrations/20260908_qiyuesuo_company_config.sql @@ -0,0 +1,57 @@ +-- OnboardX 契约锁公司级业务分类与印章配置 +-- 适用数据库:MySQL 8.0+ +-- 可重复执行;仅增加字段,不覆盖任何公司已有配置。 + +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS `ob_qys_company_add_column_if_missing`; +DELIMITER $$ +CREATE PROCEDURE `ob_qys_company_add_column_if_missing`( + IN p_column_name VARCHAR(128), + IN p_column_definition VARCHAR(1000) +) +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ob_company' + AND COLUMN_NAME = p_column_name + ) THEN + SET @ddl = CONCAT( + 'ALTER TABLE `ob_company` ADD COLUMN `', + p_column_name, '` ', p_column_definition + ); + PREPARE stmt FROM @ddl; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + END IF; +END$$ +DELIMITER ; + +CALL `ob_qys_company_add_column_if_missing`( + 'QiyuesuoCategoryId', + 'VARCHAR(100) NULL COMMENT ''契约锁默认业务分类Id(按公司独立配置)'' AFTER `ContactPhone`' +); +CALL `ob_qys_company_add_column_if_missing`( + 'QiyuesuoSealId', + 'VARCHAR(100) NULL COMMENT ''契约锁企业印章Id(按公司独立配置)'' AFTER `QiyuesuoCategoryId`' +); + +DROP PROCEDURE IF EXISTS `ob_qys_company_add_column_if_missing`; + +SELECT + Id, + CompanyCode, + CompanyName, + LegalName, + QiyuesuoCategoryId, + QiyuesuoSealId, + CASE + WHEN NULLIF(TRIM(QiyuesuoCategoryId), '') IS NOT NULL + AND NULLIF(TRIM(QiyuesuoSealId), '') IS NOT NULL THEN '已配置' + ELSE '待配置' + END AS QiyuesuoConfigStatus +FROM ob_company +WHERE IsDelete = 0 OR IsDelete IS NULL +ORDER BY CompanyName; \ No newline at end of file diff --git a/database/scripts/20260908_reset_onboarding_personnel_and_sign_data.sql b/database/scripts/20260908_reset_onboarding_personnel_and_sign_data.sql new file mode 100644 index 0000000..74eb1d1 --- /dev/null +++ b/database/scripts/20260908_reset_onboarding_personnel_and_sign_data.sql @@ -0,0 +1,215 @@ +-- OnboardX 入职人员与电子签约业务数据清理脚本 +-- 适用数据库:MySQL 8.0+ +-- +-- 会物理删除: +-- 1. 正式员工档案; +-- 2. 入职任务及候选人填写、材料、审核、合同快照等任务数据; +-- 3. 契约锁流程、文档映射、回调及后台预览/盖章事件。 +-- +-- 会保留: +-- 公司、部门、岗位、入职模板、合同模板及版本、模板适用范围、 +-- 合同变量字典、模板变量、上传资料类型及其他系统基础配置。 +-- +-- 特别注意: +-- 1. 执行前请先备份数据库;本脚本提交后不可恢复。 +-- 2. 请先在契约锁作废仍在签署中的测试流程,或临时关闭回调; +-- 本脚本只清理本地数据库,不能删除或作废契约锁平台上的合同。 +-- 3. 本脚本不删除 sys_file/dev_file 等系统文件记录,也不删除 OSS 对象, +-- 避免误删合同模板源文件或其他模块共用文件。 +-- 4. DELETE 会同时清理正常数据和已经软删除的数据。 + +USE `OnboardX`; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS `ob_reset_onboarding_personnel_and_sign_data`; + +DELIMITER $$ + +CREATE PROCEDURE `ob_reset_onboarding_personnel_and_sign_data`() +BEGIN + DECLARE old_foreign_key_checks INT DEFAULT 1; + DECLARE finished INT DEFAULT 0; + DECLARE target_table_name VARCHAR(64); + DECLARE before_rows BIGINT DEFAULT 0; + DECLARE after_rows BIGINT DEFAULT 0; + + -- 只允许删除明确列出的流程数据表。新版本即使增加 ob_ 基础表,也不会被误删。 + DECLARE target_table_cursor CURSOR FOR + SELECT `TABLE_NAME` + FROM `information_schema`.`TABLES` + WHERE `TABLE_SCHEMA` = DATABASE() + AND `TABLE_TYPE` = 'BASE TABLE' + AND `TABLE_NAME` IN ( + 'ob_electronic_sign_event', + 'ob_electronic_sign_document', + 'ob_electronic_sign_process', + 'ob_task_operation_log', + 'ob_task_audit', + 'ob_task_material', + 'ob_task_form_value', + 'ob_task_employee_info', + 'ob_task_contract_variable', + 'ob_task_contract', + 'ob_task_link', + 'ob_task_material_requirement', + 'ob_task_form_requirement', + 'ob_onboarding_task', + 'ob_employee' + ) + ORDER BY FIELD( + `TABLE_NAME`, + 'ob_electronic_sign_event', + 'ob_electronic_sign_document', + 'ob_electronic_sign_process', + 'ob_task_operation_log', + 'ob_task_audit', + 'ob_task_material', + 'ob_task_form_value', + 'ob_task_employee_info', + 'ob_task_contract_variable', + 'ob_task_contract', + 'ob_task_link', + 'ob_task_material_requirement', + 'ob_task_form_requirement', + 'ob_onboarding_task', + 'ob_employee' + ); + + DECLARE CONTINUE HANDLER FOR NOT FOUND SET finished = 1; + + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + ROLLBACK; + SET FOREIGN_KEY_CHECKS = old_foreign_key_checks; + RESIGNAL; + END; + + IF LOWER(DATABASE()) <> 'onboardx' THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '当前数据库不是 OnboardX,已拒绝执行人员与签约数据清理'; + END IF; + + DROP TEMPORARY TABLE IF EXISTS `tmp_ob_reset_result`; + CREATE TEMPORARY TABLE `tmp_ob_reset_result` ( + `TableName` VARCHAR(64) NOT NULL, + `BeforeRows` BIGINT NOT NULL, + `DeletedRows` BIGINT NOT NULL, + `AfterRows` BIGINT NOT NULL, + PRIMARY KEY (`TableName`) + ); + + SET old_foreign_key_checks = @@FOREIGN_KEY_CHECKS; + SET FOREIGN_KEY_CHECKS = 0; + + START TRANSACTION; + + OPEN target_table_cursor; + + delete_loop: LOOP + FETCH target_table_cursor INTO target_table_name; + IF finished = 1 THEN + LEAVE delete_loop; + END IF; + + SET @ob_reset_row_count = 0; + SET @ob_reset_count_sql = CONCAT( + 'SELECT COUNT(*) INTO @ob_reset_row_count FROM `', + REPLACE(target_table_name, '`', '``'), + '`' + ); + PREPARE ob_reset_count_statement FROM @ob_reset_count_sql; + EXECUTE ob_reset_count_statement; + DEALLOCATE PREPARE ob_reset_count_statement; + SET before_rows = COALESCE(@ob_reset_row_count, 0); + + SET @ob_reset_delete_sql = CONCAT( + 'DELETE FROM `', + REPLACE(target_table_name, '`', '``'), + '`' + ); + PREPARE ob_reset_delete_statement FROM @ob_reset_delete_sql; + EXECUTE ob_reset_delete_statement; + DEALLOCATE PREPARE ob_reset_delete_statement; + + SET @ob_reset_row_count = 0; + PREPARE ob_reset_count_statement FROM @ob_reset_count_sql; + EXECUTE ob_reset_count_statement; + DEALLOCATE PREPARE ob_reset_count_statement; + SET after_rows = COALESCE(@ob_reset_row_count, 0); + + INSERT INTO `tmp_ob_reset_result` ( + `TableName`, + `BeforeRows`, + `DeletedRows`, + `AfterRows` + ) VALUES ( + target_table_name, + before_rows, + before_rows - after_rows, + after_rows + ); + END LOOP; + + CLOSE target_table_cursor; + + COMMIT; + SET FOREIGN_KEY_CHECKS = old_foreign_key_checks; + + SELECT + `TableName` AS `已清理表`, + `BeforeRows` AS `清理前`, + `DeletedRows` AS `删除数量`, + `AfterRows` AS `清理后` + FROM `tmp_ob_reset_result` + ORDER BY FIELD( + `TableName`, + 'ob_electronic_sign_event', + 'ob_electronic_sign_document', + 'ob_electronic_sign_process', + 'ob_task_operation_log', + 'ob_task_audit', + 'ob_task_material', + 'ob_task_form_value', + 'ob_task_employee_info', + 'ob_task_contract_variable', + 'ob_task_contract', + 'ob_task_link', + 'ob_task_material_requirement', + 'ob_task_form_requirement', + 'ob_onboarding_task', + 'ob_employee' + ); + + DROP TEMPORARY TABLE IF EXISTS `tmp_ob_reset_result`; +END$$ + +DELIMITER ; + +CALL `ob_reset_onboarding_personnel_and_sign_data`(); +DROP PROCEDURE IF EXISTS `ob_reset_onboarding_personnel_and_sign_data`; + +-- 最终校验:下面存在的流程表都应为 0 行。 +SELECT + `TABLE_NAME` AS `流程数据表`, + `TABLE_ROWS` AS `估算剩余行数` +FROM `information_schema`.`TABLES` +WHERE `TABLE_SCHEMA` = DATABASE() + AND `TABLE_TYPE` = 'BASE TABLE' + AND `TABLE_NAME` IN ( + 'ob_electronic_sign_event', + 'ob_electronic_sign_document', + 'ob_electronic_sign_process', + 'ob_task_operation_log', + 'ob_task_audit', + 'ob_task_material', + 'ob_task_form_value', + 'ob_task_employee_info', + 'ob_task_contract_variable', + 'ob_task_contract', + 'ob_task_link', + 'ob_task_material_requirement', + 'ob_task_form_requirement', + 'ob_onboarding_task', + 'ob_employee' + ) +ORDER BY `TABLE_NAME`; diff --git a/database/scripts/20260908_seed_qiyuesuo_test_company_config.sql b/database/scripts/20260908_seed_qiyuesuo_test_company_config.sql new file mode 100644 index 0000000..56318eb --- /dev/null +++ b/database/scripts/20260908_seed_qiyuesuo_test_company_config.sql @@ -0,0 +1,40 @@ +-- 契约锁测试环境公司配置初始化 +-- 先执行 migrations/20260908_qiyuesuo_company_config.sql。 +-- 本脚本包含测试环境 Id;不要直接用于契约锁正式环境。 + +SET NAMES utf8mb4; + +UPDATE ob_company +SET QiyuesuoCategoryId = '3533041076673835142', + QiyuesuoSealId = '3533379903657587306', + UpdateTime = NOW(3) +WHERE (IsDelete = 0 OR IsDelete IS NULL) + AND ( + TRIM(CompanyName) = '重庆远轩教育科技公司' + OR TRIM(LegalName) = '重庆远轩教育科技公司' + ); + +UPDATE ob_company +SET QiyuesuoCategoryId = '3534511390926574114', + QiyuesuoSealId = '3534750711407686595', + UpdateTime = NOW(3) +WHERE (IsDelete = 0 OR IsDelete IS NULL) + AND ( + TRIM(CompanyName) = '重庆海豚科技公司' + OR TRIM(LegalName) = '重庆海豚科技公司' + ); + +SELECT + Id, + CompanyCode, + CompanyName, + LegalName AS QiyuesuoTenantName, + QiyuesuoCategoryId, + QiyuesuoSealId +FROM ob_company +WHERE (IsDelete = 0 OR IsDelete IS NULL) + AND ( + TRIM(CompanyName) IN ('重庆远轩教育科技公司', '重庆海豚科技公司') + OR TRIM(LegalName) IN ('重庆远轩教育科技公司', '重庆海豚科技公司') + ) +ORDER BY CompanyName; \ No newline at end of file