完善第三方签署

This commit is contained in:
fuenmao
2026-09-08 17:48:30 +08:00
parent da777327f5
commit c015bc356c
22 changed files with 961 additions and 62 deletions
@@ -17,17 +17,19 @@
"AppToken": "rMKU99gkts", "AppToken": "rMKU99gkts",
"AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O", "AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O",
"SignatureVersion": "", "SignatureVersion": "",
"CategoryId": "3533041076673835142",
"SealId": "3533379903657587306",
"CreatorName": "袁林", "CreatorName": "袁林",
"CreatorMobile": "15123135384", "CreatorMobile": "15123135384",
"CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev", "CallbackUrl": "https://undiffusively-hemielytral-myrtle.ngrok-free.dev/api/open/onboarding/qiyuesuo/callback",
"SignPageCallbackUrl": "", "SignPageCallbackUrl": "",
"SignUrlExpireMinutes": 10, "SignUrlExpireMinutes": 10,
"EmployeeSignKeyword": "乙方(劳动者)签字:", "EmployeeSignKeyword": "乙方(劳动者)签字:",
"EmployeeSignKeywordIndex": 1, "EmployeeSignKeywordIndex": 1,
"CompanySealKeyword": "甲方(盖章):", "CompanySealKeyword": "甲方(盖章):",
"CompanySealKeywordIndex": 1, "CompanySealKeywordIndex": 1,
"CompanySealOffsetX": 0,
"CompanySealOffsetY": -0.05,
"EnableAcrossPageSeal": true,
"AcrossPageSealType": "ALL_DOCUMENT",
"TimeoutSeconds": 90 "TimeoutSeconds": 90
} }
} }
@@ -17,8 +17,6 @@
"AppToken": "rMKU99gkts", "AppToken": "rMKU99gkts",
"AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O", "AppSecret": "sjhcNcV1YTIhA124yrjLMdKRoBlP8O",
"SignatureVersion": "", "SignatureVersion": "",
"CategoryId": "3533041076673835142",
"SealId": "3533042752893948009",
"CreatorName": "袁林", "CreatorName": "袁林",
"CreatorMobile": "15123135384", "CreatorMobile": "15123135384",
"CallbackUrl": "https://onboardx-api.cnzhyx.cn/api/open/onboarding/qiyuesuo/callback", "CallbackUrl": "https://onboardx-api.cnzhyx.cn/api/open/onboarding/qiyuesuo/callback",
@@ -28,6 +26,10 @@
"EmployeeSignKeywordIndex": 1, "EmployeeSignKeywordIndex": 1,
"CompanySealKeyword": "甲方(盖章):", "CompanySealKeyword": "甲方(盖章):",
"CompanySealKeywordIndex": 1, "CompanySealKeywordIndex": 1,
"CompanySealOffsetX": 0,
"CompanySealOffsetY": -0.12,
"EnableAcrossPageSeal": true,
"AcrossPageSealType": "ALL_DOCUMENT",
"TimeoutSeconds": 90 "TimeoutSeconds": 90
} }
} }
@@ -52,6 +52,16 @@ public class ObCompany : BaseEntity
/// </summary> /// </summary>
public string ContactPhone { get; set; } public string ContactPhone { get; set; }
/// <summary>
/// 契约锁默认业务分类 Id;每个签约公司独立配置。
/// </summary>
public string QiyuesuoCategoryId { get; set; }
/// <summary>
/// 契约锁企业印章 Id;每个签约公司独立配置。
/// </summary>
public string QiyuesuoSealId { get; set; }
/// <summary> /// <summary>
/// 业务备注。 /// 业务备注。
/// </summary> /// </summary>
@@ -176,7 +176,7 @@ public static class ElectronicSignStatus
public const string PendingEmployeeSign = "pending_employee_sign"; public const string PendingEmployeeSign = "pending_employee_sign";
/// <summary> /// <summary>
/// 员工已签,等待企业自动盖章或平台完成流程。 /// 员工已签,等待后台管理员核对文件并确认企业盖章。
/// </summary> /// </summary>
public const string PendingCompanySeal = "pending_company_seal"; public const string PendingCompanySeal = "pending_company_seal";
@@ -30,16 +30,6 @@ public class QiyuesuoSettingOptions
/// </summary> /// </summary>
public string SignatureVersion { get; set; } public string SignatureVersion { get; set; }
/// <summary>
/// 默认业务分类 Id。
/// </summary>
public string CategoryId { get; set; }
/// <summary>
/// 企业自动盖章使用的印章 Id。
/// </summary>
public string SealId { get; set; }
/// <summary> /// <summary>
/// 契约锁企业成员中的合同创建人姓名。 /// 契约锁企业成员中的合同创建人姓名。
/// </summary> /// </summary>
@@ -85,6 +75,26 @@ public class QiyuesuoSettingOptions
/// </summary> /// </summary>
public int CompanySealKeywordIndex { get; set; } = 1; public int CompanySealKeywordIndex { get; set; } = 1;
/// <summary>
/// 企业盖章相对关键字的横向偏移量,范围 (-1, 1),正数向右、负数向左。
/// </summary>
public decimal CompanySealOffsetX { get; set; }
/// <summary>
/// 企业盖章相对关键字的纵向偏移量,范围 (-1, 1),正数向上、负数向下。
/// </summary>
public decimal CompanySealOffsetY { get; set; }
/// <summary>
/// 是否在后台确认企业盖章时同时加盖骑缝章。
/// </summary>
public bool EnableAcrossPageSeal { get; set; } = true;
/// <summary>
/// 骑缝章文档范围:ALL_DOCUMENT(全部文档合并)或 SINGLE_DOCUMENT(每份文档单独)。
/// </summary>
public string AcrossPageSealType { get; set; } = "ALL_DOCUMENT";
/// <summary> /// <summary>
/// 单次 HTTP 请求超时秒数。 /// 单次 HTTP 请求超时秒数。
/// </summary> /// </summary>
@@ -43,6 +43,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
/// </summary> /// </summary>
public async Task Add(OnboardingCompanyAddInput input) public async Task Add(OnboardingCompanyAddInput input)
{ {
NormalizeAndValidateQiyuesuoConfig(input);
await EnsureCodeUnique(input.CompanyCode, 0); await EnsureCodeUnique(input.CompanyCode, 0);
await InsertAsync(input.Adapt<ObCompany>()); await InsertAsync(input.Adapt<ObCompany>());
} }
@@ -52,6 +53,7 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
/// </summary> /// </summary>
public async Task Edit(OnboardingCompanyEditInput input) public async Task Edit(OnboardingCompanyEditInput input)
{ {
NormalizeAndValidateQiyuesuoConfig(input);
await Detail(new BaseIdInput { Id = input.Id }); await Detail(new BaseIdInput { Id = input.Id });
await EnsureCodeUnique(input.CompanyCode, input.Id); await EnsureCodeUnique(input.CompanyCode, input.Id);
await UpdateAsync(input.Adapt<ObCompany>()); await UpdateAsync(input.Adapt<ObCompany>());
@@ -98,6 +100,24 @@ public class OnboardingCompanyService : DbRepository<ObCompany>, IOnboardingComp
await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray()); await DeleteByIdsAsync(input.Ids.Cast<object>().ToArray());
} }
private static void NormalizeAndValidateQiyuesuoConfig(ObCompany company)
{
company.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) private async Task EnsureCodeUnique(string code, long id)
{ {
var exists = await Context.Queryable<ObCompany>() var exists = await Context.Queryable<ObCompany>()
@@ -17,25 +17,39 @@ public static class DocxTemplateProcessor
/// </summary> /// </summary>
public static List<string> ExtractVariableCodes(byte[] docx) public static List<string> ExtractVariableCodes(byte[] docx)
{ {
using var input = new MemoryStream(docx); ValidateDocxHeader(docx);
using var archive = new global::System.IO.Compression.ZipArchive( try
input,
global::System.IO.Compression.ZipArchiveMode.Read);
var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
{ {
using var stream = entry.Open(); using var input = new MemoryStream(docx);
var document = global::System.Xml.Linq.XDocument.Load(stream); using var archive = new global::System.IO.Compression.ZipArchive(
foreach (var paragraph in document.Descendants(WordNamespace + "p")) input,
{ global::System.IO.Compression.ZipArchiveMode.Read);
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value)); if (archive.GetEntry("word/document.xml") == null)
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text)) throw Oops.Bah("上传文件不是有效的 DOCX 文档,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
codes.Add(match.Groups["code"].Value.Trim());
}
}
return codes.OrderBy(code => code).ToList(); var codes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in archive.Entries.Where(entry => IsWordTextXml(entry.FullName)))
{
using var stream = entry.Open();
var document = global::System.Xml.Linq.XDocument.Load(stream);
foreach (var paragraph in document.Descendants(WordNamespace + "p"))
{
var text = string.Concat(paragraph.Descendants(WordNamespace + "t").Select(node => node.Value));
foreach (global::System.Text.RegularExpressions.Match match in PlaceholderRegex.Matches(text))
codes.Add(match.Groups["code"].Value.Trim());
}
}
return codes.OrderBy(code => code).ToList();
}
catch (global::System.IO.InvalidDataException)
{
throw Oops.Bah("DOCX 文件结构已经损坏或上传不完整,请重新另存为 DOCX 后上传");
}
catch (global::System.Xml.XmlException)
{
throw Oops.Bah("DOCX 文件内部内容损坏,Word 可以尝试修复该文件;请修复并重新另存为 DOCX 后上传");
}
} }
/// <summary> /// <summary>
@@ -43,6 +57,7 @@ public static class DocxTemplateProcessor
/// </summary> /// </summary>
public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values) public static byte[] Render(byte[] docx, IReadOnlyDictionary<string, string> values)
{ {
ValidateDocxHeader(docx);
using var input = new MemoryStream(docx); using var input = new MemoryStream(docx);
using var archive = new global::System.IO.Compression.ZipArchive( using var archive = new global::System.IO.Compression.ZipArchive(
input, input,
@@ -157,4 +172,26 @@ public static class DocxTemplateProcessor
|| entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase)) || entryName.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase))
&& entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)); && entryName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
} }
}
/// <summary>
/// 根据文件头识别真实格式,避免只修改扩展名的旧版 DOC 在解压时变成系统异常。
/// </summary>
private static void ValidateDocxHeader(byte[] docx)
{
if (docx == null || docx.Length < 4)
throw Oops.Bah("上传的 DOCX 文件为空或上传不完整,请重新选择文件上传");
// 旧版 Word 97-2003 二进制 DOC 使用 OLE Compound File 文件头:D0 CF 11 E0。
if (docx[0] == 0xD0
&& docx[1] == 0xCF
&& docx[2] == 0x11
&& docx[3] == 0xE0)
{
throw Oops.Bah("上传文件实际是旧版 DOC 格式,不能直接修改后缀名;请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
}
// DOCX 是 Open XML ZIP 包,正常文件以 PK 开头。
if (docx[0] != 0x50 || docx[1] != 0x4B)
throw Oops.Bah("上传文件的真实格式不是 DOCX,请使用 Word 或 WPS 另存为“Word 文档(*.docx)”后重新上传");
}
}
@@ -37,6 +37,7 @@ public class OnboardingContractTemplateService : DbRepository<ObContractTemplate
var page = await query var page = await query
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}") .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.TemplateName) .OrderBy(it => it.TemplateName)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
await PopulateScopeDisplay(page.List.ToList()); await PopulateScopeDisplay(page.List.ToList());
@@ -14,6 +14,18 @@ public class OnboardingElectronicSignTaskInput
public long TaskId { get; set; } public long TaskId { get; set; }
} }
/// <summary>
/// 管理员查看一份员工已签合同的参数。
/// </summary>
public class OnboardingElectronicSignPreviewInput : OnboardingElectronicSignTaskInput
{
/// <summary>
/// 本地电子签文档 Id。
/// </summary>
[IdNotNull(ErrorMessage = "电子签文档Id不能为空")]
public long SignDocumentId { get; set; }
}
/// <summary> /// <summary>
/// 作废入职电子签流程的参数。 /// 作废入职电子签流程的参数。
/// </summary> /// </summary>
@@ -85,6 +85,18 @@ public class OnboardingElectronicSignDetailOutput
public List<OnboardingElectronicSignHistoryOutput> HistoryProcesses { get; set; } = new(); public List<OnboardingElectronicSignHistoryOutput> HistoryProcesses { get; set; } = new();
} }
/// <summary>
/// 契约锁员工已签文件只读预览结果。
/// </summary>
public class OnboardingElectronicSignPreviewOutput
{
/// <summary>本地电子签文档 Id。</summary>
public long SignDocumentId { get; set; }
/// <summary>契约锁生成的只读浏览页面地址。</summary>
public string PreviewUrl { get; set; }
}
/// <summary> /// <summary>
/// 已被新流程替代的历史电子签流程摘要。 /// 已被新流程替代的历史电子签流程摘要。
@@ -149,4 +161,7 @@ public class OnboardingElectronicSignDocumentOutput
/// <summary>签署完成后的 PDF 文件 Id。</summary> /// <summary>签署完成后的 PDF 文件 Id。</summary>
public long? SignedPdfFileId { get; set; } public long? SignedPdfFileId { get; set; }
/// <summary>当前管理员流程中是否已生成过员工签后预览。</summary>
public bool EmployeeSignedPreviewed { get; set; }
} }
@@ -24,6 +24,14 @@ public interface IOnboardingElectronicSignService : ITransient
Task<OnboardingElectronicSignDetailOutput> GetSignLink( Task<OnboardingElectronicSignDetailOutput> GetSignLink(
OnboardingElectronicSignTaskInput input); OnboardingElectronicSignTaskInput input);
/// <summary>获取一份员工已签合同的只读浏览页面。</summary>
Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
OnboardingElectronicSignPreviewInput input);
/// <summary>确认全部文件已核对后,由后台执行企业盖章。</summary>
Task<OnboardingElectronicSignDetailOutput> CompanySeal(
OnboardingElectronicSignTaskInput input);
/// <summary>催办当前待签员工。</summary> /// <summary>催办当前待签员工。</summary>
Task Notice(OnboardingElectronicSignTaskInput input); Task Notice(OnboardingElectronicSignTaskInput input);
@@ -5,10 +5,14 @@ using System.Text.Json;
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 实现入职合同“员工先签、企业自动盖章”的契约锁电子签流程。 /// 实现入职合同“员工先签、后台核对、企业人工盖章”的契约锁电子签流程。
/// </summary> /// </summary>
public class OnboardingElectronicSignService : IOnboardingElectronicSignService 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 SqlSugarScope _db;
private readonly IQiyuesuoClient _qiyuesuoClient; private readonly IQiyuesuoClient _qiyuesuoClient;
private readonly IOnboardingContractDocumentService _contractDocumentService; private readonly IOnboardingContractDocumentService _contractDocumentService;
@@ -68,11 +72,22 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
var context = await GetInitiationContext(task); var context = await GetInitiationContext(task);
if (process == null) if (process == null)
{ {
var created = await CreateProcess(task, context.Employee); var created = await CreateProcess(
task,
context.Employee,
context.CategoryId,
context.SealId);
process = created.Process; process = created.Process;
isNewProcess = created.Created; isNewProcess = created.Created;
} }
if (string.IsNullOrWhiteSpace(process.ProviderContractId))
{
// 第三方合同尚未创建时允许采用公司资料中的最新配置,便于修正配置后直接重试。
process.CategoryId = context.CategoryId;
process.SealId = context.SealId;
}
return await StartProcess( return await StartProcess(
task, task,
context.CompanyName, context.CompanyName,
@@ -97,6 +112,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
var process = await CreateReplacementProcess( var process = await CreateReplacementProcess(
task, task,
context.Employee, context.Employee,
context.CategoryId,
context.SealId,
previousProcess, previousProcess,
input.Reason.Trim()); input.Reason.Trim());
@@ -114,6 +131,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
private async Task<( private async Task<(
ObEmployee Employee, ObEmployee Employee,
string CompanyName, string CompanyName,
string CategoryId,
string SealId,
List<ObTaskContract> TaskContracts)> GetInitiationContext( List<ObTaskContract> TaskContracts)> GetInitiationContext(
ObOnboardingTask task) ObOnboardingTask task)
{ {
@@ -140,6 +159,15 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
if (string.IsNullOrWhiteSpace(companyName)) if (string.IsNullOrWhiteSpace(companyName))
throw Oops.Bah("任务所属公司法定名称为空,不能发起电子签"); 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<ObTaskContract>() var taskContracts = await _db.Queryable<ObTaskContract>()
.Where(item => item.TaskId == task.Id) .Where(item => item.TaskId == task.Id)
.OrderBy(item => item.Id) .OrderBy(item => item.Id)
@@ -152,7 +180,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
if (unconfirmed != null) if (unconfirmed != null)
throw Oops.Bah($"合同“{unconfirmed.ContractName}”尚未由员工确认"); throw Oops.Bah($"合同“{unconfirmed.ContractName}”尚未由员工确认");
return (employee, companyName, taskContracts); return (employee, companyName, categoryId, sealId, taskContracts);
} }
/// <summary> /// <summary>
@@ -258,6 +286,159 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
return await BuildDetail(task, process); return await BuildDetail(task, process);
} }
/// <inheritdoc />
public async Task<OnboardingElectronicSignPreviewOutput> 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<ObElectronicSignDocument>()
.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
};
}
/// <inheritdoc />
public async Task<OnboardingElectronicSignDetailOutput> 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<ObElectronicSignDocument>()
.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);
}
/// <inheritdoc /> /// <inheritdoc />
public async Task Notice(OnboardingElectronicSignTaskInput input) public async Task Notice(OnboardingElectronicSignTaskInput input)
{ {
@@ -284,6 +465,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
await _qiyuesuoClient.Invalid( await _qiyuesuoClient.Invalid(
process.ProviderContractId, process.ProviderContractId,
process.SealId,
input.Reason.Trim()); input.Reason.Trim());
process.SignStatus = ElectronicSignStatus.Invalid; process.SignStatus = ElectronicSignStatus.Invalid;
process.ProviderStatus = "INVALIDED"; process.ProviderStatus = "INVALIDED";
@@ -390,9 +572,11 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// </summary> /// </summary>
private async Task<(ObElectronicSignProcess Process, bool Created)> CreateProcess( private async Task<(ObElectronicSignProcess Process, bool Created)> CreateProcess(
ObOnboardingTask task, ObOnboardingTask task,
ObEmployee employee) ObEmployee employee,
string categoryId,
string sealId)
{ {
var process = BuildProcess(task, employee); var process = BuildProcess(task, employee, categoryId, sealId);
try try
{ {
await _db.Insertable(process).ExecuteCommandAsync(); await _db.Insertable(process).ExecuteCommandAsync();
@@ -415,10 +599,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
private async Task<ObElectronicSignProcess> CreateReplacementProcess( private async Task<ObElectronicSignProcess> CreateReplacementProcess(
ObOnboardingTask task, ObOnboardingTask task,
ObEmployee employee, ObEmployee employee,
string categoryId,
string sealId,
ObElectronicSignProcess previousProcess, ObElectronicSignProcess previousProcess,
string reason) string reason)
{ {
var process = BuildProcess(task, employee); var process = BuildProcess(task, employee, categoryId, sealId);
var now = DateTime.Now; var now = DateTime.Now;
await _db.Ado.BeginTranAsync(); await _db.Ado.BeginTranAsync();
@@ -458,7 +644,9 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
/// </summary> /// </summary>
private ObElectronicSignProcess BuildProcess( private ObElectronicSignProcess BuildProcess(
ObOnboardingTask task, ObOnboardingTask task,
ObEmployee employee) ObEmployee employee,
string categoryId,
string sealId)
{ {
var now = DateTime.Now; var now = DateTime.Now;
var processId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId(); var processId = global::SimpleAdmin.Core.Utils.CommonUtils.GetSingleId();
@@ -470,8 +658,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
Provider = ElectronicSignProvider.Qiyuesuo, Provider = ElectronicSignProvider.Qiyuesuo,
BizId = $"{task.TaskNo}-SIGN-{processId}", BizId = $"{task.TaskNo}-SIGN-{processId}",
IsCurrent = true, IsCurrent = true,
CategoryId = _setting.CategoryId.Trim(), CategoryId = categoryId,
SealId = _setting.SealId.Trim(), SealId = sealId,
Subject = $"{employee.EmployeeName}-{task.TaskNo}-入职合同", Subject = $"{employee.EmployeeName}-{task.TaskNo}-入职合同",
EmployeeName = employee.EmployeeName.Trim(), EmployeeName = employee.EmployeeName.Trim(),
EmployeeMobile = employee.Phone.Trim(), EmployeeMobile = employee.Phone.Trim(),
@@ -612,11 +800,12 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
?? throw Oops.Bah("契约锁草稿未返回企业签署方"); ?? throw Oops.Bah("契约锁草稿未返回企业签署方");
var companyAction = companySignatory.Actions.FirstOrDefault(item => var companyAction = companySignatory.Actions.FirstOrDefault(item =>
string.Equals(item.Type, "COMPANY", StringComparison.OrdinalIgnoreCase)) string.Equals(item.Type, "COMPANY", StringComparison.OrdinalIgnoreCase))
?? throw Oops.Bah("契约锁草稿未返回企业自动盖章动作"); ?? throw Oops.Bah("契约锁草稿未返回企业盖章动作");
var personalSignatoryId = GetSignatoryId(personalSignatory); var personalSignatoryId = GetSignatoryId(personalSignatory);
var companyActionId = GetActionId(companyAction); var companyActionId = GetActionId(companyAction);
var stampers = new List<QiyuesuoStamperInput>(); var stampers = new List<QiyuesuoStamperInput>();
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.EmployeeActionId = personalSignatoryId;
document.CompanyActionId = companyActionId; document.CompanyActionId = companyActionId;
@@ -637,10 +826,50 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
DocumentId = document.ProviderDocumentId, DocumentId = document.ProviderDocumentId,
SealId = process.SealId, SealId = process.SealId,
Keyword = document.CompanySealKeyword, 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 var sent = await _qiyuesuoClient.Send(new QiyuesuoSendContractInput
{ {
ContractId = process.ProviderContractId, ContractId = process.ProviderContractId,
@@ -861,6 +1090,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
.Where(item => item.SignProcessId == process.Id) .Where(item => item.SignProcessId == process.Id)
.OrderBy(item => item.DocumentSort) .OrderBy(item => item.DocumentSort)
.ToListAsync(); .ToListAsync();
var previewedDocumentIds = await GetPreviewedDocumentIds(process.Id);
result.ProcessId = process.Id; result.ProcessId = process.Id;
result.ProviderContractId = process.ProviderContractId; result.ProviderContractId = process.ProviderContractId;
result.BizId = process.BizId; result.BizId = process.BizId;
@@ -890,7 +1120,8 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
DocumentName = item.DocumentName, DocumentName = item.DocumentName,
DocumentSort = item.DocumentSort, DocumentSort = item.DocumentSort,
OriginalDocxFileId = item.OriginalDocxFileId, OriginalDocxFileId = item.OriginalDocxFileId,
SignedPdfFileId = item.SignedPdfFileId SignedPdfFileId = item.SignedPdfFileId,
EmployeeSignedPreviewed = previewedDocumentIds.Contains(item.Id)
}).ToList(); }).ToList();
} }
else else
@@ -913,6 +1144,108 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
return result; return result;
} }
/// <summary>
/// 读取任务当前所属公司的法定名称。集团共用令牌时,该名称用于明确子公司主体。
/// </summary>
private async Task<string> GetCompanyLegalName(ObOnboardingTask task)
{
var company = await _db.Queryable<ObCompany>()
.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();
}
/// <summary>
/// 返回当前流程中已由管理员生成过员工签后预览的文档 Id。
/// </summary>
private async Task<HashSet<long>> GetPreviewedDocumentIds(long processId)
{
var eventTypes = await _db.Queryable<ObElectronicSignEvent>()
.Where(item =>
item.SignProcessId == processId
&& item.EventSource == AdminPreviewEventSource
&& item.ProcessResult == "已生成预览地址")
.Select(item => item.EventType)
.ToListAsync();
var result = new HashSet<long>();
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;
}
/// <summary>
/// 将管理员预览、确认盖章等关键操作写入现有电子签事件表。
/// 固定操作键生成固定哈希,可防止双击或重试留下重复审计记录。
/// </summary>
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<ObElectronicSignEvent>()
.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<ObElectronicSignEvent>()
.FirstAsync(item => item.PayloadHash == payloadHash);
if (existing == null)
throw;
}
}
private static string BuildPreviewEventType(long documentId)
{
return $"{PreviewEventTypePrefix}{documentId}";
}
/// <summary> /// <summary>
/// 电子签管理沿用入职审核人权限,不新增另一套权限来源。 /// 电子签管理沿用入职审核人权限,不新增另一套权限来源。
/// </summary> /// </summary>
@@ -1151,10 +1484,7 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
throw Oops.Bah("请先配置契约锁 AppToken"); throw Oops.Bah("请先配置契约锁 AppToken");
if (string.IsNullOrWhiteSpace(_setting.AppSecret)) if (string.IsNullOrWhiteSpace(_setting.AppSecret))
throw Oops.Bah("请先配置契约锁 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) if (string.IsNullOrWhiteSpace(_setting.CreatorName)
|| string.IsNullOrWhiteSpace(_setting.CreatorMobile)) || string.IsNullOrWhiteSpace(_setting.CreatorMobile))
{ {
@@ -1165,6 +1495,17 @@ public class OnboardingElectronicSignService : IOnboardingElectronicSignService
{ {
throw Oops.Bah("请配置员工签名和企业盖章定位关键字"); 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( private static void EnsureCanOpenEmployeeSignPage(
@@ -6,7 +6,7 @@ namespace SimpleAdmin.Application;
public interface IQiyuesuoClient : ITransient public interface IQiyuesuoClient : ITransient
{ {
/// <summary> /// <summary>
/// 创建包含个人签署方和企业自动盖章动作的合同草稿。 /// 创建包含个人签署方和企业人工盖章动作的合同草稿。
/// </summary> /// </summary>
Task<QiyuesuoContractResult> CreateDraft( Task<QiyuesuoContractResult> CreateDraft(
QiyuesuoCreateDraftInput input); QiyuesuoCreateDraftInput input);
@@ -29,6 +29,12 @@ public interface IQiyuesuoClient : ITransient
Task<string> GetSignPageUrl( Task<string> GetSignPageUrl(
QiyuesuoSignPageInput input); QiyuesuoSignPageInput input);
/// <summary>
/// 获取当前合同中指定文档的只读浏览页面地址。
/// </summary>
Task<string> GetViewPageUrl(
QiyuesuoViewPageInput input);
/// <summary> /// <summary>
/// 查询契约锁合同详情。 /// 查询契约锁合同详情。
/// </summary> /// </summary>
@@ -39,10 +45,15 @@ public interface IQiyuesuoClient : ITransient
/// </summary> /// </summary>
Task Notice(string contractId); Task Notice(string contractId);
/// <summary>
/// 由后台管理员确认后执行企业盖章节点。
/// </summary>
Task CompanySign(QiyuesuoCompanySignInput input);
/// <summary> /// <summary>
/// 作废尚未完成的合同流程。 /// 作废尚未完成的合同流程。
/// </summary> /// </summary>
Task Invalid(string contractId, string reason); Task Invalid(string contractId, string sealId, string reason);
/// <summary> /// <summary>
/// 下载单份签署完成的 PDF 文档。 /// 下载单份签署完成的 PDF 文档。
@@ -103,9 +103,9 @@ public class QiyuesuoClient : IQiyuesuoClient
new new
{ {
type = "COMPANY", type = "COMPANY",
name = "企业自动盖章", name = "企业人工盖章",
serialNo = 0, serialNo = 0,
autoSign = true, autoSign = false,
sealId = input.SealId, sealId = input.SealId,
corpSealIds = new[] { sealId }, corpSealIds = new[] { sealId },
actionNo = "company_seal" actionNo = "company_seal"
@@ -185,6 +185,33 @@ public class QiyuesuoClient : IQiyuesuoClient
throw Oops.Bah("契约锁未返回员工签署页面地址"); throw Oops.Bah("契约锁未返回员工签署页面地址");
} }
/// <inheritdoc />
public async Task<string> GetViewPageUrl(
QiyuesuoViewPageInput input)
{
var result = await GetJsonElement(
"/v2/contract/viewurl",
new Dictionary<string, string>
{
["contractId"] = input.ContractId,
["documentId"] = input.DocumentId,
["tenantName"] = input.CompanyName,
["downloadButton"] = "false",
["printButton"] = "false"
});
if (result.ValueKind == JsonValueKind.String)
return result.GetString();
if (TryGetString(result, "url", out var url)
|| TryGetString(result, "viewUrl", out url)
|| TryGetString(result, "pageUrl", out url))
{
return url;
}
throw Oops.Bah("契约锁未返回合同浏览页面地址");
}
/// <inheritdoc /> /// <inheritdoc />
public Task<QiyuesuoContractResult> GetContractDetail(string contractId) public Task<QiyuesuoContractResult> GetContractDetail(string contractId)
{ {
@@ -212,14 +239,27 @@ public class QiyuesuoClient : IQiyuesuoClient
} }
/// <inheritdoc /> /// <inheritdoc />
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
});
}
/// <inheritdoc />
public async Task Invalid(string contractId, string sealId, string reason)
{ {
await PostJsonElement( await PostJsonElement(
"/v2/contract/invalid", "/v2/contract/invalid",
new new
{ {
contractId, contractId,
sealId = _setting.SealId, sealId,
reason, reason,
deleteDoc = false, deleteDoc = false,
autoSign = true autoSign = true
@@ -318,6 +358,15 @@ public class QiyuesuoClient : IQiyuesuoClient
private async Task<T> GetJson<T>( private async Task<T> GetJson<T>(
string path, string path,
Dictionary<string, string> query) Dictionary<string, string> query)
{
var result = await GetJsonElement(path, query);
return result.Deserialize<T>(JsonOptions)
?? throw Oops.Bah("契约锁返回数据为空");
}
private async Task<JsonElement> GetJsonElement(
string path,
Dictionary<string, string> query)
{ {
ValidateSettings(); ValidateSettings();
using var request = new HttpRequestMessage( using var request = new HttpRequestMessage(
@@ -328,9 +377,7 @@ public class QiyuesuoClient : IQiyuesuoClient
path, path,
HttpMethod.Get, HttpMethod.Get,
query); query);
var result = await SendForJsonResult(request); return await SendForJsonResult(request);
return result.Deserialize<T>(JsonOptions)
?? throw Oops.Bah("契约锁返回数据为空");
} }
private async Task<byte[]> Download( private async Task<byte[]> Download(
@@ -77,6 +77,12 @@ public class QiyuesuoStamperInput
/// <summary>契约锁文档 Id。</summary> /// <summary>契约锁文档 Id。</summary>
public string DocumentId { get; set; } public string DocumentId { get; set; }
/// <summary>骑缝章覆盖的契约锁文档 Id,多个 Id 使用英文逗号分隔。</summary>
public string DocumentIds { get; set; }
/// <summary>骑缝章位置指定方式。</summary>
public string AcrossType { get; set; }
/// <summary>企业印章 Id。</summary> /// <summary>企业印章 Id。</summary>
public string SealId { get; set; } public string SealId { get; set; }
@@ -84,7 +90,13 @@ public class QiyuesuoStamperInput
public string Keyword { get; set; } public string Keyword { get; set; }
/// <summary>同名关键字的序号,从 1 开始。</summary> /// <summary>同名关键字的序号,从 1 开始。</summary>
public int KeywordIndex { get; set; } = 1; public int? KeywordIndex { get; set; }
/// <summary>相对关键字的横向偏移量;契约锁要求必传,不偏移时为 0。</summary>
public decimal OffsetX { get; set; }
/// <summary>相对关键字的纵向偏移量;契约锁要求必传,不偏移时为 0。</summary>
public decimal OffsetY { get; set; }
} }
/// <summary> /// <summary>
@@ -120,6 +132,36 @@ public class QiyuesuoSignPageInput
public string CallbackPage { get; set; } public string CallbackPage { get; set; }
} }
/// <summary>
/// 获取契约锁合同文档只读浏览页所需参数。
/// </summary>
public class QiyuesuoViewPageInput
{
/// <summary>契约锁合同流程 Id。</summary>
public string ContractId { get; set; }
/// <summary>契约锁合同文档 Id。</summary>
public string DocumentId { get; set; }
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
public string CompanyName { get; set; }
}
/// <summary>
/// 契约锁企业人工盖章所需参数。
/// </summary>
public class QiyuesuoCompanySignInput
{
/// <summary>契约锁合同流程 Id。</summary>
public string ContractId { get; set; }
/// <summary>签署企业法定名称;集团模式下用于定位子公司。</summary>
public string CompanyName { get; set; }
/// <summary>当前流程使用的企业印章 Id 快照。</summary>
public string SealId { get; set; }
}
/// <summary> /// <summary>
/// 契约锁合同返回对象。 /// 契约锁合同返回对象。
/// </summary> /// </summary>
@@ -1,4 +1,4 @@
namespace SimpleAdmin.Application; namespace SimpleAdmin.Application;
/// <summary> /// <summary>
/// 实现入职业务岗位业务能力。 /// 实现入职业务岗位业务能力。
@@ -17,6 +17,7 @@ public class OnboardingPositionService : DbRepository<ObPosition>, IOnboardingPo
.WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName)) .WhereIF(!string.IsNullOrWhiteSpace(input.PositionName), it => it.PositionName.Contains(input.PositionName))
.WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status) .WhereIF(!string.IsNullOrWhiteSpace(input.Status), it => it.Status == input.Status)
.OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}") .OrderByIF(!string.IsNullOrWhiteSpace(input.SortField), $"{input.SortField} {input.SortOrder}")
.OrderByDescending(it => it.CreateTime)
.OrderBy(it => it.PositionName) .OrderBy(it => it.PositionName)
.ToPagedListAsync(input.PageNum, input.PageSize); .ToPagedListAsync(input.PageNum, input.PageSize);
@@ -64,6 +64,7 @@ public class OnboardingContractTemplateController
/// 查询入职模板当前公司、部门及岗位可选择的合同模板。 /// 查询入职模板当前公司、部门及岗位可选择的合同模板。
/// </summary> /// </summary>
[HttpPost("list-available")] [HttpPost("list-available")]
[DisplayName("查询可选合同模板")]
public async Task<dynamic> ListAvailable([FromBody] OnboardingContractTemplateAvailableInput input) public async Task<dynamic> ListAvailable([FromBody] OnboardingContractTemplateAvailableInput input)
{ {
return await _service.ListAvailable(input); return await _service.ListAvailable(input);
@@ -113,6 +114,7 @@ public class OnboardingContractTemplateController
/// 新增合同模板版本。 /// 新增合同模板版本。
/// </summary> /// </summary>
[HttpPost("version/add")] [HttpPost("version/add")]
[DisplayName("新增合同模板版本")]
public async Task AddVersion([FromBody] OnboardingContractVersionAddInput input) public async Task AddVersion([FromBody] OnboardingContractVersionAddInput input)
{ {
await _service.AddVersion(input); await _service.AddVersion(input);
@@ -122,6 +124,7 @@ public class OnboardingContractTemplateController
/// 设置合同模板版本状态。 /// 设置合同模板版本状态。
/// </summary> /// </summary>
[HttpPost("version/set-status")] [HttpPost("version/set-status")]
[DisplayName("设置合同模板版本状态")]
public async Task SetVersionStatus([FromBody] OnboardingContractVersionStatusInput input) public async Task SetVersionStatus([FromBody] OnboardingContractVersionStatusInput input)
{ {
await _service.SetVersionStatus(input); await _service.SetVersionStatus(input);
@@ -131,6 +134,7 @@ public class OnboardingContractTemplateController
/// 删除未被入职模板或任务使用的合同模板版本。 /// 删除未被入职模板或任务使用的合同模板版本。
/// </summary> /// </summary>
[HttpPost("version/delete")] [HttpPost("version/delete")]
[DisplayName("删除合同模板版本")]
public async Task DeleteVersion([FromBody] BaseIdInput input) public async Task DeleteVersion([FromBody] BaseIdInput input)
{ {
await _service.DeleteVersion(input); await _service.DeleteVersion(input);
@@ -150,6 +154,7 @@ public class OnboardingContractTemplateController
/// 编辑模板变量。 /// 编辑模板变量。
/// </summary> /// </summary>
[HttpPost("variable/edit")] [HttpPost("variable/edit")]
[DisplayName("编辑合同模板变量")]
public async Task EditVariable([FromBody] OnboardingTemplateVariableEditInput input) public async Task EditVariable([FromBody] OnboardingTemplateVariableEditInput input)
{ {
await _service.EditVariable(input); await _service.EditVariable(input);
@@ -73,6 +73,28 @@ public class OnboardingElectronicSignController
return await _service.GetSignLink(input); return await _service.GetSignLink(input);
} }
/// <summary>
/// 获取员工已签、企业未盖章文件的只读浏览页面。
/// </summary>
[HttpPost("preview-employee-signed")]
[DisplayName("预览员工已签合同")]
public async Task<OnboardingElectronicSignPreviewOutput> PreviewEmployeeSignedDocument(
[FromBody] OnboardingElectronicSignPreviewInput input)
{
return await _service.PreviewEmployeeSignedDocument(input);
}
/// <summary>
/// 全部文件核对完成后,由后台管理员确认企业盖章。
/// </summary>
[HttpPost("company-seal")]
[DisplayName("确认入职合同企业盖章")]
public async Task<OnboardingElectronicSignDetailOutput> CompanySeal(
[FromBody] OnboardingElectronicSignTaskInput input)
{
return await _service.CompanySeal(input);
}
/// <summary> /// <summary>
/// 催办当前待签员工。 /// 催办当前待签员工。
/// </summary> /// </summary>
@@ -51,6 +51,7 @@ public class BizUserController : IDynamicApiController
/// <returns></returns> /// <returns></returns>
[HttpGet(template: "template")] [HttpGet(template: "template")]
[SuppressMonitor] [SuppressMonitor]
[DisplayName("下载人员导入模板")]
public async Task<dynamic> Template() public async Task<dynamic> Template()
{ {
return await _userService.Template(); return await _userService.Template();
@@ -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;
@@ -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`;
@@ -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;