diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplateVersion.cs b/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplateVersion.cs index c2f750c..3c9222b 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplateVersion.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObContractTemplateVersion.cs @@ -52,6 +52,12 @@ public class ObContractTemplateVersion : BaseEntity /// public string VersionStatus { get; set; } = ContractTemplateVersionStatus.Draft; + /// 本版本是否需要公司盖章;历史空值沿用需要盖章。 + public bool? CompanySealEnabled { get; set; } + + /// 本版本是否需要骑缝章;历史空值沿用系统骑缝章配置。 + public bool? AcrossPageSealEnabled { get; set; } + /// /// 业务备注。 /// diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignDocument.cs b/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignDocument.cs index 0c3e104..6e51b19 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignDocument.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObElectronicSignDocument.cs @@ -52,6 +52,12 @@ public class ObElectronicSignDocument : BaseEntity /// public string CompanySealKeyword { get; set; } + /// 发起本次签约时冻结的公司盖章要求。 + public bool? CompanySealEnabled { get; set; } + + /// 发起本次签约时冻结的骑缝章要求。 + public bool? AcrossPageSealEnabled { get; set; } + /// /// 契约锁员工签署动作 Id。 /// diff --git a/SimpleAdmin.Application/Entity/Onboarding/ObTaskContract.cs b/SimpleAdmin.Application/Entity/Onboarding/ObTaskContract.cs index 0d1d126..24aff58 100644 --- a/SimpleAdmin.Application/Entity/Onboarding/ObTaskContract.cs +++ b/SimpleAdmin.Application/Entity/Onboarding/ObTaskContract.cs @@ -52,6 +52,12 @@ public class ObTaskContract : BaseEntity /// public int PrintCopies { get; set; } = 1; + /// 任务创建时冻结的公司盖章要求;历史空值沿用需要盖章。 + public bool? CompanySealEnabled { get; set; } + + /// 任务创建时冻结的骑缝章要求;历史空值沿用系统配置。 + public bool? AcrossPageSealEnabled { get; set; } + /// /// 任务合同当前状态。 /// diff --git a/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs b/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs index b9c9a9d..030974d 100644 --- a/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs +++ b/SimpleAdmin.Application/Options/QiyuesuoSettingOptions.cs @@ -86,12 +86,12 @@ public class QiyuesuoSettingOptions public decimal CompanySealOffsetY { get; set; } /// - /// 是否在后台确认企业盖章时同时加盖骑缝章。 + /// 历史版本未单独设置开关时,是否在企业盖章时加盖骑缝章;新版本显式开关优先。 /// public bool EnableAcrossPageSeal { get; set; } = true; /// - /// 骑缝章文档范围:ALL_DOCUMENT(全部文档合并)或 SINGLE_DOCUMENT(每份文档单独)。 + /// 骑缝章方式:ALL_DOCUMENT(所选文档合并)或 SINGLE_DOCUMENT(所选文档各自独立)。 /// public string AcrossPageSealType { get; set; } = "ALL_DOCUMENT"; diff --git a/SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionSealConfiguration.cs b/SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionSealConfiguration.cs new file mode 100644 index 0000000..297ad39 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/Common/ContractTemplateVersionSealConfiguration.cs @@ -0,0 +1,25 @@ +using SqlSugar; + +namespace SimpleAdmin.Application; + +/// 版本盖章配置局部更新,避免覆盖另一个开关、版本状态或历史任务快照。 +internal static class ContractTemplateVersionSealConfiguration +{ + internal static IUpdateable BuildUpdate( + ISqlSugarClient db, + OnboardingContractVersionSealInput input) + { + if (input.Id <= 0) + throw Oops.Bah("版本Id不能为空"); + if (!input.CompanySealEnabled.HasValue && !input.AcrossPageSealEnabled.HasValue) + throw Oops.Bah("请指定需要修改的盖章开关"); + + var update = db.Updateable() + .Where(item => item.Id == input.Id); + if (input.CompanySealEnabled.HasValue) + update = update.SetColumns(item => item.CompanySealEnabled == input.CompanySealEnabled); + if (input.AcrossPageSealEnabled.HasValue) + update = update.SetColumns(item => item.AcrossPageSealEnabled == input.AcrossPageSealEnabled); + return update; + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs index d768607..c67df45 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/Dto/OnboardingContractVersionInput.cs @@ -55,6 +55,12 @@ public class OnboardingContractVersionAddInput /// public DateTime? ExpiryDate { get; set; } + /// 本版本是否需要公司盖章;旧客户端未传时沿用原规则。 + public bool? CompanySealEnabled { get; set; } + + /// 本版本是否需要骑缝章;旧客户端未传时沿用系统配置。 + public bool? AcrossPageSealEnabled { get; set; } + /// /// 业务备注。 /// @@ -78,3 +84,19 @@ public class OnboardingContractVersionStatusInput [global::System.ComponentModel.DataAnnotations.Required(ErrorMessage = "版本状态不能为空")] public string VersionStatus { get; set; } } + +/// +/// 修改合同模板版本的盖章配置;未传的开关保持不变。 +/// +public class OnboardingContractVersionSealInput +{ + /// 合同版本 Id。 + [IdNotNull(ErrorMessage = "版本Id不能为空")] + public long Id { get; set; } + + /// 是否需要公司盖章。 + public bool? CompanySealEnabled { get; set; } + + /// 是否需要骑缝章。 + public bool? AcrossPageSealEnabled { get; set; } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs index 87a2f76..ccb7ab7 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/IOnboardingContractTemplateService.cs @@ -50,6 +50,11 @@ public interface IOnboardingContractTemplateService : ITransient /// Task SetVersionStatus(OnboardingContractVersionStatusInput input); + /// + /// 修改合同模板版本的盖章配置,仅影响后续新建任务。 + /// + Task SetVersionSeals(OnboardingContractVersionSealInput input); + /// /// 删除未被入职模板或任务使用的合同版本。 /// diff --git a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs index 1ff7981..4f36bcc 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ContractTemplate/OnboardingContractTemplateService.cs @@ -272,11 +272,21 @@ public class OnboardingContractTemplateService : DbRepository public async Task> VersionPage(OnboardingContractVersionPageInput input) { - return await Context.Queryable() + var page = await Context.Queryable() .Where(it => it.TemplateId == input.TemplateId) .WhereIF(!string.IsNullOrWhiteSpace(input.VersionStatus), it => it.VersionStatus == input.VersionStatus) .OrderByDescending(it => it.CreateTime) .ToPagedListAsync(input.PageNum, input.PageSize); + + // 仅解析返回值,不回写历史空值,使列表开关显示真实生效的规则。 + var setting = App.GetConfig("QiyuesuoSetting", true) + ?? new QiyuesuoSettingOptions(); + foreach (var version in page.List) + { + version.CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(version.CompanySealEnabled); + version.AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(version.AcrossPageSealEnabled, setting); + } + return page; } /// @@ -395,7 +405,21 @@ public class OnboardingContractTemplateService : DbRepository new { item.VersionStatus, item.UpdateTime, item.UpdateUserId, item.UpdateUser }) + .ExecuteCommandAsync(); + } + + /// + /// 修改版本盖章开关;任务和电子签约已经保存独立快照,不随版本变更。 + /// + public async Task SetVersionSeals(OnboardingContractVersionSealInput input) + { + var version = await Context.Queryable().InSingleAsync(input.Id) + ?? throw Oops.Bah("模板版本不存在"); + await EnsureTemplateCanManage(version.TemplateId); + + await ContractTemplateVersionSealConfiguration.BuildUpdate(Context, input).ExecuteCommandAsync(); } /// diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs index 3e7bc54..9233331 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Dto/OnboardingElectronicSignOutput.cs @@ -164,4 +164,10 @@ public class OnboardingElectronicSignDocumentOutput /// 当前管理员流程中是否已生成过员工签后预览。 public bool EmployeeSignedPreviewed { get; set; } + + /// 本份文件是否需要公司盖章。 + public bool CompanySealEnabled { get; set; } + + /// 本份文件是否需要骑缝章。 + public bool AcrossPageSealEnabled { get; set; } } diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs index 2fa8c3f..8d8d6e1 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/OnboardingElectronicSignService.cs @@ -805,7 +805,10 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService SealId = process.SealId, CreatorName = process.CreatorName, CreatorMobile = process.CreatorMobile, - CallbackUrl = _setting.CallbackUrl + CallbackUrl = _setting.CallbackUrl, + RequiresCompanySign = taskContracts.Any(item => + QiyuesuoContractRequestBuilder.RequiresCompanySign( + item.CompanySealEnabled, item.AcrossPageSealEnabled, _setting)) }); process.ProviderContractId = GetContractId(providerContract); process.ProviderStatus = providerContract.Status ?? "DRAFT"; @@ -860,7 +863,9 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService DocumentSort = index + 1, OriginalDocxFileId = originalFileId, EmployeeSignKeyword = _setting.EmployeeSignKeyword.Trim(), - CompanySealKeyword = _setting.CompanySealKeyword.Trim(), + CompanySealKeyword = _setting.CompanySealKeyword?.Trim(), + CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(taskContract.CompanySealEnabled), + AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(taskContract.AcrossPageSealEnabled, _setting), Status = CommonStatusConst.ENABLE, CreateTime = DateTime.Now, CreateUserId = process.CreateUserId, @@ -879,7 +884,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService } /// - /// 为每份文档分别配置员工签名与企业盖章位置,然后一次性发送整个流程。 + /// 全部文档配置员工签名,按每份文件的快照配置企业盖章及骑缝章位置。 /// private async Task SendProcess( ObElectronicSignProcess process, @@ -891,79 +896,40 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService 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(); var orderedDocuments = documents.OrderBy(item => item.DocumentSort).ToList(); + var requiresCompanySign = orderedDocuments.Any(item => + QiyuesuoContractRequestBuilder.RequiresCompanySign( + item.CompanySealEnabled, item.AcrossPageSealEnabled, _setting)); + var companyAction = providerContract.Signatories + .Where(item => string.Equals(item.TenantType, "COMPANY", StringComparison.OrdinalIgnoreCase)) + .SelectMany(item => item.Actions) + .FirstOrDefault(item => string.Equals(item.Type, "COMPANY", StringComparison.OrdinalIgnoreCase)); + if (requiresCompanySign && companyAction == null) + throw Oops.Bah("契约锁草稿未返回企业盖章动作"); + if (!requiresCompanySign && companyAction != null) + throw Oops.Bah("本次材料仅需员工签字,但契约锁业务分类仍预设企业盖章节点,请先调整该业务分类的签署流程"); + + var personalSignatoryId = GetSignatoryId(personalSignatory); + var companyActionId = companyAction == null ? null : GetActionId(companyAction); + var signingDocuments = orderedDocuments.Select(item => + new OnboardingSigningDocument( + item.ProviderDocumentId, + item.EmployeeSignKeyword, + item.CompanySealKeyword, + item.CompanySealEnabled, + item.AcrossPageSealEnabled)).ToList(); + var stampers = QiyuesuoContractRequestBuilder.BuildStampers( + signingDocuments, personalSignatoryId, companyActionId, process.SealId, _setting); + foreach (var document in orderedDocuments) { document.EmployeeActionId = personalSignatoryId; - document.CompanyActionId = companyActionId; + document.CompanyActionId = QiyuesuoContractRequestBuilder.RequiresCompanySign( + document.CompanySealEnabled, document.AcrossPageSealEnabled, _setting) + ? companyActionId + : null; 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), - 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 @@ -1179,7 +1145,9 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService DocumentName = item.ContractName, DocumentSort = index + 1, OriginalDocxFileId = item.GeneratedDocxFileId ?? 0, - SignedPdfFileId = item.GeneratedPdfFileId + SignedPdfFileId = item.GeneratedPdfFileId, + CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(item.CompanySealEnabled), + AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(item.AcrossPageSealEnabled, _setting) }).ToList(); return result; } @@ -1219,6 +1187,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService DocumentSort = item.DocumentSort, OriginalDocxFileId = item.OriginalDocxFileId, SignedPdfFileId = item.SignedPdfFileId, + CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(item.CompanySealEnabled), + AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(item.AcrossPageSealEnabled, _setting), EmployeeSignedPreviewed = previewedDocumentIds.Contains(item.Id) }).ToList(); } @@ -1236,7 +1206,9 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService DocumentName = item.ContractName, DocumentSort = index + 1, OriginalDocxFileId = item.GeneratedDocxFileId ?? 0, - SignedPdfFileId = item.GeneratedPdfFileId + SignedPdfFileId = item.GeneratedPdfFileId, + CompanySealEnabled = QiyuesuoContractRequestBuilder.CompanySealEnabled(item.CompanySealEnabled), + AcrossPageSealEnabled = QiyuesuoContractRequestBuilder.AcrossPageSealEnabled(item.AcrossPageSealEnabled, _setting) }).ToList(); } return result; @@ -1431,15 +1403,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService 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; + return QiyuesuoContractRequestBuilder.ResolvePendingStatus(providerContract); } private static bool IsTerminal(string status) @@ -1588,10 +1552,9 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService { throw Oops.Bah("请配置契约锁企业成员姓名和手机号"); } - if (string.IsNullOrWhiteSpace(_setting.EmployeeSignKeyword) - || string.IsNullOrWhiteSpace(_setting.CompanySealKeyword)) + if (string.IsNullOrWhiteSpace(_setting.EmployeeSignKeyword)) { - throw Oops.Bah("请配置员工签名和企业盖章定位关键字"); + throw Oops.Bah("请配置员工签名定位关键字"); } if (_setting.CompanySealOffsetX <= -1m || _setting.CompanySealOffsetX >= 1m || _setting.CompanySealOffsetY <= -1m || _setting.CompanySealOffsetY >= 1m) diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs index 1028451..ee216e0 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClient.cs @@ -40,84 +40,9 @@ public class QiyuesuoClient : IQiyuesuoClient 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 = false, - sealId = input.SealId, - corpSealIds = new[] { sealId }, - actionNo = "company_seal" - } - } - } - } - }; - return await PostJson( "/v2/contract/draft", - body); + QiyuesuoContractRequestBuilder.BuildDraft(input)); } /// diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs index ffade02..2ee5aa7 100644 --- a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoClientModels.cs @@ -34,6 +34,9 @@ public class QiyuesuoCreateDraftInput /// 契约锁服务器回调地址。 public string CallbackUrl { get; set; } + + /// 是否需要企业签章节点;仅员工签字的流程不创建该节点。 + public bool RequiresCompanySign { get; set; } = true; } /// diff --git a/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoContractRequestBuilder.cs b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoContractRequestBuilder.cs new file mode 100644 index 0000000..857bd10 --- /dev/null +++ b/SimpleAdmin.Application/Services/Onboarding/ElectronicSign/Qiyuesuo/QiyuesuoContractRequestBuilder.cs @@ -0,0 +1,206 @@ +namespace SimpleAdmin.Application; + +/// 发起签约前按文件冻结的签字、盖章要求。 +internal sealed record OnboardingSigningDocument( + string DocumentId, + string EmployeeSignKeyword, + string CompanySealKeyword, + bool? CompanySealEnabled, + bool? AcrossPageSealEnabled); + +/// 构造契约锁请求;无数据库和网络访问,员工签字始终覆盖全部文件。 +internal static class QiyuesuoContractRequestBuilder +{ + internal static bool CompanySealEnabled(bool? enabled) => enabled ?? true; + + internal static bool AcrossPageSealEnabled(bool? enabled, QiyuesuoSettingOptions setting) => + enabled ?? setting.EnableAcrossPageSeal; + + internal static bool RequiresCompanySign( + bool? companySealEnabled, + bool? acrossPageSealEnabled, + QiyuesuoSettingOptions setting) => + CompanySealEnabled(companySealEnabled) || AcrossPageSealEnabled(acrossPageSealEnabled, setting); + + internal static object BuildDraft(QiyuesuoCreateDraftInput input) + { + long sealId = 0; + if (input.RequiresCompanySign && !long.TryParse(input.SealId, out sealId)) + throw Oops.Bah("契约锁 SealId 配置格式不正确"); + + var signatories = new List + { + new + { + tenantType = "PERSONAL", + tenantName = input.EmployeeName, + receiver = new + { + name = input.EmployeeName, + contact = input.EmployeeMobile, + contactType = "MOBILE" + }, + serialNo = 1, + signatoryNo = "employee", + actions = new object[] + { + new + { + type = "PERSONAL", + name = "员工本人签署", + serialNo = 0, + actionNo = "employee_sign" + } + } + } + }; + if (input.RequiresCompanySign) + { + signatories.Add(new + { + tenantType = "COMPANY", + tenantName = input.CompanyName, + receiver = new + { + name = input.CreatorName, + contact = input.CreatorMobile, + contactType = "MOBILE" + }, + serialNo = 2, + signatoryNo = "company", + actions = new object[] + { + new + { + type = "COMPANY", + name = "企业人工盖章", + serialNo = 0, + autoSign = false, + sealId = input.SealId, + corpSealIds = new[] { sealId }, + actionNo = "company_seal" + } + } + }); + } + + return new + { + bizId = input.BizId, + subject = input.Subject, + tenantName = input.CompanyName, + description = "OnboardX 入职电子签", + send = false, + callbackUrl = string.IsNullOrWhiteSpace(input.CallbackUrl) ? null : input.CallbackUrl.Trim(), + category = new { id = input.CategoryId }, + creator = new + { + name = input.CreatorName, + contact = input.CreatorMobile, + contactType = "MOBILE" + }, + signatories + }; + } + + internal static List BuildStampers( + IReadOnlyList documents, + string personalSignatoryId, + string companyActionId, + string sealId, + QiyuesuoSettingOptions setting) + { + if (documents.Count == 0) + throw Oops.Bah("当前电子签流程没有可签署的文件"); + if (documents.Any(item => string.IsNullOrWhiteSpace(item.DocumentId))) + throw Oops.Bah("契约锁未返回签署文档 Id"); + if (string.IsNullOrWhiteSpace(personalSignatoryId)) + throw Oops.Bah("契约锁未返回员工签署方 Id"); + if (documents.Any(item => RequiresCompanySign(item.CompanySealEnabled, item.AcrossPageSealEnabled, setting)) + && string.IsNullOrWhiteSpace(companyActionId)) + throw Oops.Bah("契约锁草稿未返回企业盖章动作"); + + var stampers = new List(); + foreach (var document in documents) + { + if (string.IsNullOrWhiteSpace(document.EmployeeSignKeyword)) + throw Oops.Bah("请配置员工签名定位关键字"); + stampers.Add(new QiyuesuoStamperInput + { + SignatoryId = personalSignatoryId, + Type = "PERSONAL", + DocumentId = document.DocumentId, + Keyword = document.EmployeeSignKeyword, + KeywordIndex = Math.Max(1, setting.EmployeeSignKeywordIndex) + }); + if (CompanySealEnabled(document.CompanySealEnabled)) + { + if (string.IsNullOrWhiteSpace(document.CompanySealKeyword)) + throw Oops.Bah("需要公司盖章的文件未配置企业盖章定位关键字"); + stampers.Add(new QiyuesuoStamperInput + { + ActionId = companyActionId, + Type = "COMPANY", + DocumentId = document.DocumentId, + SealId = sealId, + Keyword = document.CompanySealKeyword, + KeywordIndex = Math.Max(1, setting.CompanySealKeywordIndex), + OffsetX = setting.CompanySealOffsetX, + OffsetY = setting.CompanySealOffsetY + }); + } + } + + // 合并骑缝章也只能包含已开启该开关的文件,不能把只需个人签字的材料混入。 + var acrossDocuments = documents + .Where(item => AcrossPageSealEnabled(item.AcrossPageSealEnabled, setting)) + .Select(item => item.DocumentId) + .ToList(); + if (acrossDocuments.Count == 0) + return stampers; + var acrossType = setting.AcrossPageSealType?.Trim().ToUpperInvariant(); + if (acrossType is not ("ALL_DOCUMENT" or "SINGLE_DOCUMENT")) + throw Oops.Bah("契约锁骑缝章模式只能配置为 ALL_DOCUMENT 或 SINGLE_DOCUMENT"); + if (acrossType == "ALL_DOCUMENT") + { + stampers.Add(new QiyuesuoStamperInput + { + ActionId = companyActionId, + Type = "ACROSS_PAGE", + DocumentId = acrossDocuments[0], + DocumentIds = string.Join(",", acrossDocuments), + SealId = sealId, + AcrossType = acrossType + }); + } + else + { + foreach (var documentId in acrossDocuments) + { + stampers.Add(new QiyuesuoStamperInput + { + ActionId = companyActionId, + Type = "ACROSS_PAGE", + DocumentId = documentId, + SealId = sealId, + AcrossType = acrossType + }); + } + } + return stampers; + } + + internal static string ResolvePendingStatus(QiyuesuoContractResult contract) + { + var employeeSigned = contract.Signatories.Any(item => + string.Equals(item.TenantType, "PERSONAL", StringComparison.OrdinalIgnoreCase) + && string.Equals(item.Status, "SIGNED", StringComparison.OrdinalIgnoreCase)); + var hasCompanyAction = contract.Signatories.Any(item => + string.Equals(item.TenantType, "COMPANY", StringComparison.OrdinalIgnoreCase) + && item.Actions.Any(action => string.Equals(action.Type, "COMPANY", StringComparison.OrdinalIgnoreCase))); + // 纯员工签字流程等待平台 COMPLETE 回执,不制造不存在的企业盖章阶段。 + return employeeSigned && hasCompanyAction + ? ElectronicSignStatus.PendingCompanySeal + : ElectronicSignStatus.PendingEmployeeSign; + } +} diff --git a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs index 88c85cb..2236f3f 100644 --- a/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs +++ b/SimpleAdmin.Application/Services/Onboarding/Task/OnboardingTaskService.cs @@ -171,6 +171,7 @@ public class OnboardingTaskService : IOnboardingTaskService var contracts = contractConfigs.Select(config => { var template = contractTemplates.First(item => item.Id == config.ContractTemplateId); + var version = taskVersions.First(item => item.Id == config.ContractTemplateVersionId); return new ObTaskContract { Id = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(), @@ -180,6 +181,8 @@ public class OnboardingTaskService : IOnboardingTaskService ContractName = template.TemplateName, ContractType = template.ContractType, Required = config.Required, + CompanySealEnabled = version.CompanySealEnabled, + AcrossPageSealEnabled = version.AcrossPageSealEnabled, PrintCopies = config.PrintCopies }; }).ToList(); @@ -447,6 +450,8 @@ public class OnboardingTaskService : IOnboardingTaskService ContractName = source.ContractName, ContractType = source.ContractType, Required = source.Required, + CompanySealEnabled = source.CompanySealEnabled, + AcrossPageSealEnabled = source.AcrossPageSealEnabled, PrintCopies = source.PrintCopies }).ToList(); await _db.Insertable(copiedContracts).ExecuteCommandAsync(); diff --git a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs index 04ebfe3..939715d 100644 --- a/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs +++ b/SimpleAdmin.Web.Core/Controllers/Application/Onboarding/OnboardingContractTemplateController.cs @@ -135,6 +135,16 @@ public class OnboardingContractTemplateController await _service.SetVersionStatus(input); } + /// + /// 修改合同版本的公司盖章及骑缝章开关。 + /// + [HttpPost("version/set-seals")] + [DisplayName("设置合同版本盖章配置")] + public async Task SetVersionSeals([FromBody] OnboardingContractVersionSealInput input) + { + await _service.SetVersionSeals(input); + } + /// /// 删除未被入职模板或任务使用的合同模板版本。 /// diff --git a/database/migrations/20261015_contract_version_seal_switches.sql b/database/migrations/20261015_contract_version_seal_switches.sql new file mode 100644 index 0000000..11e5d7e --- /dev/null +++ b/database/migrations/20261015_contract_version_seal_switches.sql @@ -0,0 +1,75 @@ +-- 签署材料版本的公司盖章/骑缝章开关及任务、电子签快照。MySQL 8.0+ +-- 上线顺序:先在目标数据库执行本文件,再部署后端及后台前端。 +-- 可重复执行;只新增可空列,不回填或覆盖历史业务数据。 +-- NULL 表示历史规则:公司盖章需要,骑缝章沿用 QiyuesuoSetting.EnableAcrossPageSeal。 +-- 新上传版本显式保存 true/false;任务及电子签文档分别保存快照。 +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS ob_upgrade_contract_version_seal_switches; +DELIMITER $$ +CREATE PROCEDURE ob_upgrade_contract_version_seal_switches() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ob_contract_template_version' + AND COLUMN_NAME = 'CompanySealEnabled' + ) THEN + ALTER TABLE `ob_contract_template_version` + ADD COLUMN `CompanySealEnabled` TINYINT(1) NULL DEFAULT NULL + COMMENT '公司盖章开关;NULL沿用历史需要盖章'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ob_contract_template_version' + AND COLUMN_NAME = 'AcrossPageSealEnabled' + ) THEN + ALTER TABLE `ob_contract_template_version` + ADD COLUMN `AcrossPageSealEnabled` TINYINT(1) NULL DEFAULT NULL + COMMENT '骑缝章开关;NULL沿用历史系统配置'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ob_task_contract' + AND COLUMN_NAME = 'CompanySealEnabled' + ) THEN + ALTER TABLE `ob_task_contract` + ADD COLUMN `CompanySealEnabled` TINYINT(1) NULL DEFAULT NULL + COMMENT '公司盖章开关;NULL沿用历史需要盖章'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ob_task_contract' + AND COLUMN_NAME = 'AcrossPageSealEnabled' + ) THEN + ALTER TABLE `ob_task_contract` + ADD COLUMN `AcrossPageSealEnabled` TINYINT(1) NULL DEFAULT NULL + COMMENT '骑缝章开关;NULL沿用历史系统配置'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ob_electronic_sign_document' + AND COLUMN_NAME = 'CompanySealEnabled' + ) THEN + ALTER TABLE `ob_electronic_sign_document` + ADD COLUMN `CompanySealEnabled` TINYINT(1) NULL DEFAULT NULL + COMMENT '公司盖章开关;NULL沿用历史需要盖章'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'ob_electronic_sign_document' + AND COLUMN_NAME = 'AcrossPageSealEnabled' + ) THEN + ALTER TABLE `ob_electronic_sign_document` + ADD COLUMN `AcrossPageSealEnabled` TINYINT(1) NULL DEFAULT NULL + COMMENT '骑缝章开关;NULL沿用历史系统配置'; + END IF; +END$$ +DELIMITER ; +CALL ob_upgrade_contract_version_seal_switches(); +DROP PROCEDURE IF EXISTS ob_upgrade_contract_version_seal_switches; diff --git a/tests/SecurityRegression/Program.cs b/tests/SecurityRegression/Program.cs index 25ffb87..05cbaa5 100644 --- a/tests/SecurityRegression/Program.cs +++ b/tests/SecurityRegression/Program.cs @@ -68,6 +68,39 @@ ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSecond Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow), "equal start/end rejected"); Reject(() => ContractTemplateVersionValidity.ValidateRange(validityNow, validityNow.AddSeconds(-1)), "end before start rejected"); +// Uses the real partial-update builder and ORM SQL generation, never executes the SQL. +Reject(() => ContractTemplateVersionSealConfiguration.BuildUpdate(db, new() { Id = 42 }), + "seal update rejects an empty patch instead of resetting both flags"); +Reject(() => ContractTemplateVersionSealConfiguration.BuildUpdate(db, new() { Id = 0, CompanySealEnabled = false }), + "seal update requires a specific version"); +foreach (var enabled in new[] { false, true }) +{ + foreach (var companySeal in new[] { true, false }) + { + var patch = new OnboardingContractVersionSealInput { Id = 42 }; + var changedField = companySeal ? "CompanySealEnabled" : "AcrossPageSealEnabled"; + var untouchedField = companySeal ? "AcrossPageSealEnabled" : "CompanySealEnabled"; + if (companySeal) patch.CompanySealEnabled = enabled; + else patch.AcrossPageSealEnabled = enabled; + var generated = ContractTemplateVersionSealConfiguration.BuildUpdate(db, patch).ToSql(); + var setClause = System.Text.RegularExpressions.Regex.Match(generated.Key, @"\bSET\b([\s\S]*?)\bWHERE\b", + System.Text.RegularExpressions.RegexOptions.IgnoreCase).Groups[1].Value; + Check(setClause.Contains(changedField) && !setClause.Contains(untouchedField) + && !setClause.Contains("VersionStatus") && !System.Text.RegularExpressions.Regex.IsMatch(setClause, @"\bId\b"), + $"{changedField}={enabled} changes only its own column, never the other flag, status or primary key"); + Check(generated.Value.Any(item => item.Value is bool flag && flag == enabled), + $"{changedField}={enabled} preserves the explicit boolean value in parameterized SQL"); + Check(System.Text.RegularExpressions.Regex.IsMatch(generated.Key, @"\bWHERE\b[\s\S]*\bId\b", + System.Text.RegularExpressions.RegexOptions.IgnoreCase) + && generated.Value.Any(item => item.Value is long id && id == 42), + $"{changedField} update is scoped to the requested version ID"); + } +} +var bothSealUpdates = ContractTemplateVersionSealConfiguration.BuildUpdate(db, + new() { Id = 42, CompanySealEnabled = false, AcrossPageSealEnabled = true }).ToSql().Key; +Check(bothSealUpdates.Contains("CompanySealEnabled") && bothSealUpdates.Contains("AcrossPageSealEnabled"), + "both seal flags can be explicitly updated without overwriting unrelated version data"); + // Exercises the real async gate with a fake external lease. Does NOT claim MySQL GET_LOCK integration. var leases = new FakeLeases(); Task Gate(string key, Func> action) => OnboardingOperationGate.Run(key, () => leases.Acquire(key), action); @@ -91,6 +124,105 @@ Check(leases.AcquisitionCount - before == 2, "reentrant gate acquires each resou try { await Gate("failure", () => throw new ApplicationException("synthetic fault")); } catch (ApplicationException) {} Check(await Gate("failure", () => Task.FromResult(4)) == 4, "lock released on exception"); Check(leases.ActiveCount == 0, "no leaked leases or caller async context"); +// Uses the real request builder and provider models. No provider or database requests. +var sealSettings = new QiyuesuoSettingOptions { EnableAcrossPageSeal = true, AcrossPageSealType = "SINGLE_DOCUMENT" }; +var signDocuments = new[] +{ + new OnboardingSigningDocument("labor", "员工签字", "公司盖章", true, true), + new OnboardingSigningDocument("confidentiality", "员工签字", "公司盖章", true, true), + new OnboardingSigningDocument("notice", "员工签字", null, false, false), + new OnboardingSigningDocument("no_release_certificate", "员工签字", null, false, false) +}; +var positions = QiyuesuoContractRequestBuilder.BuildStampers(signDocuments, "person", "company", "123", sealSettings); +Check(positions.Count(item => item.Type == "PERSONAL") == 4, "all four materials require employee signatures"); +Check(positions.Where(item => item.Type == "PERSONAL").Select(item => item.DocumentId).SequenceEqual(signDocuments.Select(item => item.DocumentId)), + "employee signatures retain each distinct material and its order"); +Check(positions.Count(item => item.Type == "COMPANY") == 2, "only labor contract and confidentiality agreement receive company seals"); +Check(positions.Count(item => item.Type == "ACROSS_PAGE") == 2, "selected materials receive separate across-page seals"); +Check(positions.Where(item => item.Type != "PERSONAL").All(item => item.DocumentId is "labor" or "confidentiality"), + "employee-only materials never receive any company signing position"); +Check(positions.Where(item => item.Type == "COMPANY").All(item => item.ActionId == "company" && item.SealId == "123"), + "company positions use the intended action and company seal"); + +sealSettings.AcrossPageSealType = "ALL_DOCUMENT"; +var reorderedDocuments = new[] { signDocuments[2], signDocuments[0], signDocuments[3], signDocuments[1] }; +positions = QiyuesuoContractRequestBuilder.BuildStampers(reorderedDocuments, "person", "company", "123", sealSettings); +var mergedAcross = positions.Single(item => item.Type == "ACROSS_PAGE"); +Check(mergedAcross.DocumentId == "labor", "merged across-page anchor is the first selected material, not the first uploaded file"); +Check(mergedAcross.DocumentIds == "labor,confidentiality", "merged across-page seal excludes both employee-only materials"); + +sealSettings.EnableAcrossPageSeal = false; +var legacyDocuments = new[] { new OnboardingSigningDocument("legacy", "员工签字", "公司盖章", null, null) }; +positions = QiyuesuoContractRequestBuilder.BuildStampers(legacyDocuments, "person", "company", "123", sealSettings); +Check(positions.Count(item => item.Type == "COMPANY") == 1 && positions.All(item => item.Type != "ACROSS_PAGE"), + "old versions retain company seals and the disabled global across-page setting"); +sealSettings.EnableAcrossPageSeal = true; +positions = QiyuesuoContractRequestBuilder.BuildStampers(legacyDocuments, "person", "company", "123", sealSettings); +Check(positions.Count(item => item.Type == "ACROSS_PAGE") == 1, "old versions retain the enabled global across-page setting"); +sealSettings.EnableAcrossPageSeal = false; +positions = QiyuesuoContractRequestBuilder.BuildStampers(signDocuments, "person", "company", "123", sealSettings); +Check(positions.Count(item => item.Type == "ACROSS_PAGE") == 1, "new explicit across-page switch overrides legacy global fallback"); + +var acrossOnly = new[] { new OnboardingSigningDocument("across_only", "员工签字", null, false, true) }; +positions = QiyuesuoContractRequestBuilder.BuildStampers(acrossOnly, "person", "company", "123", sealSettings); +Check(positions.Count(item => item.Type == "COMPANY") == 0 && positions.Count(item => item.Type == "ACROSS_PAGE") == 1, + "company seal and across-page seal switches operate independently"); +Check(QiyuesuoContractRequestBuilder.RequiresCompanySign(false, true, sealSettings), + "across-page-only materials still require a company signing action"); +var companyOnly = new[] { new OnboardingSigningDocument("company_only", "员工签字", "公司盖章", true, false) }; +positions = QiyuesuoContractRequestBuilder.BuildStampers(companyOnly, "person", "company", "123", sealSettings); +Check(positions.Count(item => item.Type == "COMPANY") == 1 && positions.All(item => item.Type != "ACROSS_PAGE"), + "company seal without across-page seal is supported"); +var personalOnly = new[] { new OnboardingSigningDocument("personal_only", "员工签字", null, false, false) }; +positions = QiyuesuoContractRequestBuilder.BuildStampers(personalOnly, "person", null, null, sealSettings); +Check(positions.Count == 1 && positions[0].Type == "PERSONAL", "all switches off produces only personal signature positions"); +Check(!QiyuesuoContractRequestBuilder.RequiresCompanySign(false, false, sealSettings), + "employee-only signing needs no company action"); +Reject(() => QiyuesuoContractRequestBuilder.BuildStampers(signDocuments, "person", null, "123", sealSettings), + "company action cannot be silently omitted for materials requiring seals"); +Reject(() => QiyuesuoContractRequestBuilder.BuildStampers( + new[] { new OnboardingSigningDocument(null, "员工签字", "公司盖章", true, true) }, "person", "company", "123", sealSettings), + "missing document ID is rejected instead of broadening seal scope"); +sealSettings.AcrossPageSealType = "INVALID"; +Reject(() => QiyuesuoContractRequestBuilder.BuildStampers(acrossOnly, "person", "company", "123", sealSettings), + "invalid across-page mode is checked even when the global fallback is off"); + +var draftInput = new QiyuesuoCreateDraftInput +{ + RequiresCompanySign = false, CompanyName = "测试企业", EmployeeName = "测试员工", + EmployeeMobile = "13800138000", CreatorName = "测试经办人", CreatorMobile = "13900139000", + CategoryId = "456", CallbackUrl = " https://example.invalid/callback " +}; +var employeeOnlyDraft = System.Text.Json.JsonSerializer.SerializeToElement(QiyuesuoContractRequestBuilder.BuildDraft(draftInput)); +Check(employeeOnlyDraft.GetProperty("signatories").GetArrayLength() == 1 + && employeeOnlyDraft.GetProperty("signatories")[0].GetProperty("tenantType").GetString() == "PERSONAL", + "employee-only draft never creates a company signature node"); +Check(employeeOnlyDraft.GetProperty("tenantName").GetString() == "测试企业", + "employee-only workflow retains its sponsoring company"); +Check(employeeOnlyDraft.GetProperty("callbackUrl").GetString() == "https://example.invalid/callback", + "draft builder preserves normalized server callback"); +draftInput.RequiresCompanySign = true; +Reject(() => QiyuesuoContractRequestBuilder.BuildDraft(draftInput), "company signing draft requires a valid seal"); +draftInput.SealId = "123"; +var companyDraft = System.Text.Json.JsonSerializer.SerializeToElement(QiyuesuoContractRequestBuilder.BuildDraft(draftInput)); +Check(companyDraft.GetProperty("signatories").GetArrayLength() == 2 + && companyDraft.GetProperty("signatories")[1].GetProperty("actions")[0].GetProperty("type").GetString() == "COMPANY", + "mixed materials keep one company signing action after employee signing"); +Check(!companyDraft.GetProperty("signatories")[1].GetProperty("actions")[0].GetProperty("autoSign").GetBoolean(), + "company seal remains manually triggered"); + +var employeeOnlyProvider = new QiyuesuoContractResult { Signatories = new() +{ + new QiyuesuoSignatoryResult { TenantType = "PERSONAL", Status = "SIGNED" } +}}; +Check(QiyuesuoContractRequestBuilder.ResolvePendingStatus(employeeOnlyProvider) != ElectronicSignStatus.PendingCompanySeal, + "employee-only workflow cannot become stuck in a nonexistent company-seal stage"); +employeeOnlyProvider.Signatories.Add(new QiyuesuoSignatoryResult { TenantType = "COMPANY", Actions = new() +{ + new QiyuesuoActionResult { Type = "COMPANY", Status = "START" } +}}); +Check(QiyuesuoContractRequestBuilder.ResolvePendingStatus(employeeOnlyProvider) == ElectronicSignStatus.PendingCompanySeal, + "mixed workflow waits for company seal after employee signing"); Console.WriteLine("ALL " + passed + " CHECKS PASSED (offline; no business database or provider contacted)"); [SugarTable("test_row")] diff --git a/tests/SecurityRegression/README.md b/tests/SecurityRegression/README.md index 897220b..ccdda37 100644 --- a/tests/SecurityRegression/README.md +++ b/tests/SecurityRegression/README.md @@ -26,6 +26,12 @@ dotnet run --project tests/SecurityRegression/SecurityRegression.csproj -p:NuGet - 签约终态、防回退、归档状态保持。 - 64 个竞争调用与同任务互斥;不同任务互不阻塞。 - 同调用链嵌套任务锁可重入;正常/异常结束释放锁;调用方上下文不泄漏。 +- 按版本选择公司盖章及骑缝章:四份材料全部员工签字,仅指定两份盖章。 +- 单份/合并骑缝章只覆盖已选文件;两个开关独立;历史空值兼容全局设置。 +- 纯员工签字的草稿无企业盖章节点;混合材料仍由后台人工触发企业盖章。 +- 版本盖章开关局部更新:开启/关闭均保留布尔值,只更新传入的字段,版本 ID 只用作条件,不更新主键、另一个开关或版本状态;空请求被拒绝。 +- 局部更新测试直接链接生产 SQL 构造代码和请求 DTO;版本实体仅使用带主键映射的测试替身,不代表真实数据库写入或权限集成测试。 +- 请求构造器直接链接生产代码,只校验构造出的参数,不向契约锁发起真实签约。 ## 仍需测试环境验证 diff --git a/tests/SecurityRegression/SecurityRegression.csproj b/tests/SecurityRegression/SecurityRegression.csproj index f763a52..f2bca07 100644 --- a/tests/SecurityRegression/SecurityRegression.csproj +++ b/tests/SecurityRegression/SecurityRegression.csproj @@ -11,6 +11,11 @@ + + + + + - \ No newline at end of file + diff --git a/tests/SecurityRegression/Stubs.cs b/tests/SecurityRegression/Stubs.cs index 8aea31f..13bfc4c 100644 --- a/tests/SecurityRegression/Stubs.cs +++ b/tests/SecurityRegression/Stubs.cs @@ -1,11 +1,22 @@ global using System.ComponentModel.DataAnnotations; +global using SimpleAdmin.Core; namespace SimpleAdmin.Core { [AttributeUsage(AttributeTargets.Property)] public sealed class DataValidationAttribute : Attribute { public DataValidationAttribute(ValidationTypes type) {} } + [AttributeUsage(AttributeTargets.Property)] + public sealed class IdNotNullAttribute : Attribute { public string ErrorMessage { get; set; } } public enum ValidationTypes { Numeric } public static class Oops { public static Exception Bah(string text) => new InvalidOperationException(text); } } namespace SimpleAdmin.Application { public static class Oops { public static Exception Bah(string text) => new InvalidOperationException(text); } - public sealed class ObContractTemplateVersion { public string VersionStatus { get; set; } public DateTime? EffectiveDate { get; set; } public DateTime? ExpiryDate { get; set; } } + [SqlSugar.SugarTable("ob_contract_template_version")] + public sealed class ObContractTemplateVersion { + [SqlSugar.SugarColumn(IsPrimaryKey = true)] public long Id { get; set; } + public string VersionStatus { get; set; } + public DateTime? EffectiveDate { get; set; } + public DateTime? ExpiryDate { get; set; } + public bool? CompanySealEnabled { get; set; } + public bool? AcrossPageSealEnabled { get; set; } + } }